Skip to content

Commit

Permalink
feat: auto-stake-it example contract (#9666)
Browse files Browse the repository at this point in the history
refs: #9042
refs: #9066
refs: #9193

## Description
- Enhances `LocalOrchestrationAccount` with `monitorTransfers`(vtransfer)  method to register handlers for incoming and outgoing ICS20 transfers.
  -   `writeAcknowledgement` (ability to confer acknowledgement or acknowledgement error to sending chain) capability is not exposed through the current orchestration api. users must work with `registerActiveTap` from `transfer.js` for this capability.
- Implements `auto-stake-it` contract that uses .monitorTransfers to react to incoming IBC transfers, delegating them via an InterchainAccount (ICA).
   - referred to as "stakeAtom" on the ticket, but this seemed like a better name. not to be confused with _restaking  (autocompounding rewards)_
- Introduces `PortfolioHolder` kit, combining `ContinuingOfferResults` from multiple `OrchestrationAccounts` into a single record.
- Adds VTransferIBCEvent type for `acknowledgementPacket` and `writeAcknowledgement` events and an example mock for unit testing

### Documentation
- Aims to improve documentation around vtransfer, monitorTransfers, registerTap, etc.

### Testing Considerations
- Includes unit tests that inspect bridge messages to observe relevant transactions firing. 
- Includes multichain test demonstrating the flow from #9042, f.k.a. "stakeAtom"
  • Loading branch information
mergify[bot] authored Jul 17, 2024
2 parents c7a5fd7 + 350ff5e commit 81ac381
Show file tree
Hide file tree
Showing 45 changed files with 2,170 additions and 237 deletions.
3 changes: 2 additions & 1 deletion multichain-testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"typescript": "^5.3.3"
},
"resolutions": {
"node-fetch": "2.6.12"
"node-fetch": "2.6.12",
"axios": "1.6.7"
},
"ava": {
"extensions": {
Expand Down
44 changes: 44 additions & 0 deletions multichain-testing/patches/axios+1.6.7.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
diff --git a/node_modules/axios/dist/node/axios.cjs b/node_modules/axios/dist/node/axios.cjs
index 9099d87..7104f6e 100644
--- a/node_modules/axios/dist/node/axios.cjs
+++ b/node_modules/axios/dist/node/axios.cjs
@@ -370,9 +370,9 @@ function merge(/* obj1, obj2, obj3, ... */) {
const extend = (a, b, thisArg, {allOwnKeys}= {}) => {
forEach(b, (val, key) => {
if (thisArg && isFunction(val)) {
- a[key] = bind(val, thisArg);
+ Object.defineProperty(a, key, {value: bind(val, thisArg)});
} else {
- a[key] = val;
+ Object.defineProperty(a, key, {value: val});
}
}, {allOwnKeys});
return a;
@@ -403,7 +403,9 @@ const stripBOM = (content) => {
*/
const inherits = (constructor, superConstructor, props, descriptors) => {
constructor.prototype = Object.create(superConstructor.prototype, descriptors);
- constructor.prototype.constructor = constructor;
+ Object.defineProperty(constructor, 'constructor', {
+ value: constructor
+ });
Object.defineProperty(constructor, 'super', {
value: superConstructor.prototype
});
@@ -565,12 +567,14 @@ const isRegExp = kindOfTest('RegExp');

const reduceDescriptors = (obj, reducer) => {
const descriptors = Object.getOwnPropertyDescriptors(obj);
- const reducedDescriptors = {};
+ let reducedDescriptors = {};

forEach(descriptors, (descriptor, name) => {
let ret;
if ((ret = reducer(descriptor, name, obj)) !== false) {
- reducedDescriptors[name] = ret || descriptor;
+ reducedDescriptors = {...reducedDescriptors,
+ [name]: ret || descriptor
+ };
}
});

56 changes: 56 additions & 0 deletions multichain-testing/patches/protobufjs+6.11.4.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
diff --git a/node_modules/protobufjs/src/util/minimal.js b/node_modules/protobufjs/src/util/minimal.js
index 7f62daa..8d60657 100644
--- a/node_modules/protobufjs/src/util/minimal.js
+++ b/node_modules/protobufjs/src/util/minimal.js
@@ -259,14 +259,9 @@ util.newError = newError;
* @returns {Constructor<Error>} Custom error constructor
*/
function newError(name) {
-
function CustomError(message, properties) {
-
if (!(this instanceof CustomError))
return new CustomError(message, properties);
-
- // Error.call(this, message);
- // ^ just returns a new error instance because the ctor can be called as a function

Object.defineProperty(this, "message", { get: function() { return message; } });

@@ -280,13 +275,31 @@ function newError(name) {
merge(this, properties);
}

- (CustomError.prototype = Object.create(Error.prototype)).constructor = CustomError;
+ // Create a new object with Error.prototype as its prototype
+ const proto = Object.create(Error.prototype);

- Object.defineProperty(CustomError.prototype, "name", { get: function() { return name; } });
+ // Define properties on the prototype
+ Object.defineProperties(proto, {
+ constructor: {
+ value: CustomError,
+ writable: true,
+ configurable: true
+ },
+ name: {
+ get: function() { return name; },
+ configurable: true
+ },
+ toString: {
+ value: function toString() {
+ return this.name + ": " + this.message;
+ },
+ writable: true,
+ configurable: true
+ }
+ });

- CustomError.prototype.toString = function toString() {
- return this.name + ": " + this.message;
- };
+ // Set the prototype of CustomError
+ CustomError.prototype = proto;

return CustomError;
}
2 changes: 1 addition & 1 deletion multichain-testing/scripts/fetch-starship-chain-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const chainInfo = await convertChainInfo({
});

const record = JSON.stringify(chainInfo, null, 2);
const src = `/** @file Generated by fetch-starship-chain-info.ts */\nexport default /** @type {const} } */ (${record});`;
const src = `/** @file Generated by fetch-starship-chain-info.ts */\nexport default /** @type {const} */ (${record});`;
const prettySrc = await prettier.format(src, {
parser: 'babel', // 'typescript' fails to preserve parens for typecast
singleQuote: true,
Expand Down
2 changes: 1 addition & 1 deletion multichain-testing/starship-chain-info.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @file Generated by fetch-starship-chain-info.ts */
export default /** @type {const} } */ ({
export default /** @type {const} */ ({
agoric: {
chainId: 'agoriclocal',
stakingTokens: [
Expand Down
239 changes: 239 additions & 0 deletions multichain-testing/test/auto-stake-it.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import anyTest from '@endo/ses-ava/prepare-endo.js';
import type { ExecutionContext, TestFn } from 'ava';
import { useChain } from 'starshipjs';
import type { CosmosChainInfo, IBCConnectionInfo } from '@agoric/orchestration';
import type { SetupContextWithWallets } from './support.js';
import { chainConfig, commonSetup } from './support.js';
import { makeQueryClient } from '../tools/query.js';
import { makeDoOffer } from '../tools/e2e-tools.js';
import chainInfo from '../starship-chain-info.js';
import {
createFundedWalletAndClient,
makeIBCTransferMsg,
} from '../tools/ibc-transfer.js';

const test = anyTest as TestFn<SetupContextWithWallets>;

const accounts = ['agoricAdmin', 'cosmoshub', 'osmosis'];

const contractName = 'autoAutoStakeIt';
const contractBuilder =
'../packages/builders/scripts/testing/start-auto-stake-it.js';

test.before(async t => {
const { deleteTestKeys, setupTestKeys, ...rest } = await commonSetup(t);
deleteTestKeys(accounts).catch();
const wallets = await setupTestKeys(accounts);
t.context = { ...rest, wallets, deleteTestKeys };

t.log('bundle and install contract', contractName);
await t.context.deployBuilder(contractBuilder);
const vstorageClient = t.context.makeQueryTool();
await t.context.retryUntilCondition(
() => vstorageClient.queryData(`published.agoricNames.instance`),
res => contractName in Object.fromEntries(res),
`${contractName} instance is available`,
);
});

test.after(async t => {
const { deleteTestKeys } = t.context;
deleteTestKeys(accounts);
});

const makeFundAndTransfer = (t: ExecutionContext<SetupContextWithWallets>) => {
const { retryUntilCondition } = t.context;
return async (chainName: string, agoricAddr: string, amount = 100n) => {
const { staking } = useChain(chainName).chainInfo.chain;
const denom = staking?.staking_tokens?.[0].denom;
if (!denom) throw Error(`no denom for ${chainName}`);

const { client, address, wallet } = await createFundedWalletAndClient(
t,
chainName,
);
const balancesResult = await retryUntilCondition(
() => client.getAllBalances(address),
coins => !!coins?.length,
`Faucet balances found for ${address}`,
);

console.log('Balances:', balancesResult);

const transferArgs = makeIBCTransferMsg(
{ denom, value: amount },
{ address: agoricAddr, chainName: 'agoric' },
{ address: address, chainName },
Date.now(),
);
console.log('Transfer Args:', transferArgs);
// TODO #9200 `sendIbcTokens` does not support `memo`
// @ts-expect-error spread argument for concise code
const txRes = await client.sendIbcTokens(...transferArgs);
if (txRes && txRes.code !== 0) {
console.error(txRes);
throw Error(`failed to ibc transfer funds to ${chainName}`);
}
const { events: _events, ...txRest } = txRes;
console.log(txRest);
t.is(txRes.code, 0, `Transaction succeeded`);
t.log(`Funds transferred to ${agoricAddr}`);
return {
client,
address,
wallet,
};
};
};

const autoStakeItScenario = test.macro({
title: (_, chainName: string) => `auto-stake-it on ${chainName}`,
exec: async (t, chainName: string) => {
const {
wallets,
makeQueryTool,
provisionSmartWallet,
retryUntilCondition,
} = t.context;

const fundAndTransfer = makeFundAndTransfer(t);

// 1. Send initial tokens so denom is available (debatably necessary, but
// allows us to trace the denom until we have ibc denoms in chainInfo)
const agAdminAddr = wallets['agoricAdmin'];
console.log('Sending tokens to', agAdminAddr, `from ${chainName}`);
await fundAndTransfer(chainName, agAdminAddr);

// 2. Find 'stakingDenom' denom on agoric
const agoricConns = chainInfo['agoric'].connections as Record<
string,
IBCConnectionInfo
>;
const remoteChainInfo = (chainInfo as Record<string, CosmosChainInfo>)[
chainName
];
// const remoteChainId = remoteChainInfo.chain.chain_id;
// const agoricToRemoteConn = agoricConns[remoteChainId];
const { portId, channelId } =
agoricConns[remoteChainInfo.chainId].transferChannel;
const agoricQueryClient = makeQueryClient(
useChain('agoric').getRestEndpoint(),
);
const stakingDenom = remoteChainInfo?.stakingTokens?.[0].denom;
if (!stakingDenom) throw Error(`staking denom found for ${chainName}`);
const { hash } = await retryUntilCondition(
() =>
agoricQueryClient.queryDenom(`/${portId}/${channelId}`, stakingDenom),
denomTrace => !!denomTrace.hash,
`local denom hash for ${stakingDenom} found`,
);
t.log(`found ibc denom hash for ${stakingDenom}:`, hash);

// 3. Find a remoteChain validator to delegate to
const remoteQueryClient = makeQueryClient(
useChain(chainName).getRestEndpoint(),
);
const { validators } = await remoteQueryClient.queryValidators();
const validatorAddress = validators[0]?.operator_address;
t.truthy(
validatorAddress,
`found a validator on ${chainName} to delegate to`,
);
t.log(
{ validatorAddress },
`found a validator on ${chainName} to delegate to`,
);

// 4. Send an Offer to make the accounts and set up the transfer tap
const agoricUserAddr = wallets[chainName];
const wdUser = await provisionSmartWallet(agoricUserAddr, {
BLD: 100n,
IST: 100n,
});
const doOffer = makeDoOffer(wdUser);
t.log(`${chainName} makeAccount offer`);
const offerId = `${chainName}-makeAccountsInvitation-${Date.now()}`;

await doOffer({
id: offerId,
invitationSpec: {
source: 'agoricContract',
instancePath: [contractName],
callPipe: [['makeAccountsInvitation']],
},
offerArgs: {
chainName,
validator: {
value: validatorAddress,
encoding: 'bech32',
chainId: remoteChainInfo.chainId,
},
localDenom: `ibc/${hash}`,
},
proposal: {},
});

// FIXME https://github.com/Agoric/agoric-sdk/issues/9643
const vstorageClient = makeQueryTool();
const currentWalletRecord = await retryUntilCondition(
() =>
vstorageClient.queryData(`published.wallet.${agoricUserAddr}.current`),
({ offerToPublicSubscriberPaths }) =>
Object.fromEntries(offerToPublicSubscriberPaths)[offerId],
`${offerId} continuing invitation is in vstorage`,
);

const offerToPublicSubscriberMap = Object.fromEntries(
currentWalletRecord.offerToPublicSubscriberPaths,
);

// 5. look up LOA address in vstorage
console.log('offerToPublicSubscriberMap', offerToPublicSubscriberMap);
const lcaAddress = offerToPublicSubscriberMap[offerId]?.agoric
.split('.')
.pop();
const icaAddress = offerToPublicSubscriberMap[offerId]?.[chainName]
.split('.')
.pop();
console.log({ lcaAddress, icaAddress });
t.regex(lcaAddress, /^agoric1/, 'LOA address is valid');
t.regex(
icaAddress,
new RegExp(`^${chainConfig[chainName].expectedAddressPrefix}1`),
'COA address is valid',
);

// 6. transfer in some tokens over IBC
const transferAmount = 99n;
await fundAndTransfer(chainName, lcaAddress, transferAmount);

// 7. verify the COA has active delegations
if (chainName === 'cosmoshub') {
// FIXME: delegations are not visible on cosmoshub
return t.pass('skipping verifying delegations on cosmoshub');
}
const { delegation_responses } = await retryUntilCondition(
() => remoteQueryClient.queryDelegations(icaAddress),
({ delegation_responses }) => !!delegation_responses.length,
`delegations visible on ${chainName}`,
);
t.log('delegation balance', delegation_responses[0]?.balance);
t.like(
delegation_responses[0].balance,
{ denom: stakingDenom, amount: String(transferAmount) },
'delegations balance',
);
t.log(
`Orchestration Account Delegations on ${chainName}`,
delegation_responses,
);

// XXX consider using PortfolioHolder continuing inv to undelegate

// XXX how to test other tokens do not result in an attempted MsgTransfer or MsgDelegate?
// query tx history of the LOA via an rpc node?
},
});

test.serial(autoStakeItScenario, 'osmosis');
test.serial(autoStakeItScenario, 'cosmoshub');
Loading

0 comments on commit 81ac381

Please sign in to comment.