From 6912ab5a2f0eeaf49fed03538445339593c0f24e Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Fri, 7 Feb 2025 10:38:07 -0500 Subject: [PATCH] feat: support non-cosmos EUDs --- packages/fast-usdc/src/exos/advancer.js | 142 +++++++++++++++++++----- packages/fast-usdc/src/exos/settler.js | 2 + packages/fast-usdc/src/type-guards.js | 2 +- packages/fast-usdc/src/types.ts | 2 + packages/fast-usdc/test/fixtures.ts | 100 +++++++++++++++++ 5 files changed, 221 insertions(+), 27 deletions(-) diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index 1708b0193d8..6718c10451f 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -49,6 +49,7 @@ const AdvancerVowCtxShape = M.splitRecord( destination: ChainAddressShape, forwardingAddress: M.string(), txHash: EvmHashShape, + toIntermediate: M.opt(M.boolean()), }, { tmpSeat: M.remotable() }, ); @@ -71,6 +72,16 @@ const AdvancerKitI = harden({ }), }); +/** + * Address hooks only deal in strings, so see if we can parse + * chainId to an integer. + * @param {string} chainId + */ +const formatChainId = chainId => { + const asInt = parseInt(chainId, 10); + return !Number.isNaN(asInt) ? asInt : chainId; +}; + /** * @typedef {{ * fullAmount: NatAmount; @@ -78,14 +89,28 @@ const AdvancerKitI = harden({ * destination: ChainAddress; * forwardingAddress: NobleAddress; * txHash: EvmHash; + * toIntermediate?: boolean; * }} AdvancerVowCtx */ +/** + * @typedef {{ + * notifier: import('./settler.js').SettlerKit['notifier']; + * borrower: LiquidityPoolKit['borrower']; + * poolAccount: HostInterface>; + * settlementAddress: ChainAddress; + * intermediateRecipientAddress?: ChainAddress; + * }} AdvancerConfig + */ + +/** @typedef {AdvancerConfig & { intermediateRecipient: OrchestrationAccount<{chainId: 'noble-1'}> | undefined }} AdvancerState */ + export const stateShape = harden({ notifier: M.remotable(), borrower: M.remotable(), poolAccount: M.remotable(), - intermediateRecipient: M.opt(ChainAddressShape), + intermediateRecipient: M.opt(M.remotable()), + intermediateRecipientAddress: M.opt(ChainAddressShape), settlementAddress: M.opt(ChainAddressShape), }); @@ -121,20 +146,17 @@ export const prepareAdvancerKit = ( 'Fast USDC Advancer', AdvancerKitI, /** - * @param {{ - * notifier: import('./settler.js').SettlerKit['notifier']; - * borrower: LiquidityPoolKit['borrower']; - * poolAccount: HostInterface>; - * settlementAddress: ChainAddress; - * intermediateRecipient?: ChainAddress; - * }} config + * @param {AdvancerConfig} config */ config => - harden({ - ...config, - // make sure the state record has this property, perhaps with an undefined value - intermediateRecipient: config.intermediateRecipient, - }), + /** @type {AdvancerState}*/ ( + harden({ + ...config, + intermediateRecipient: undefined, + // make sure the state record has this property, perhaps with an undefined value + intermediateRecipientAddress: config.intermediateRecipientAddress, + }) + ), { advancer: { /** @@ -167,10 +189,19 @@ export const prepareAdvancerKit = ( if (decoded.baseAddress !== settlementAddress.value) { throw Fail`⚠️ baseAddress of address hook ${q(decoded.baseAddress)} does not match the expected address ${q(settlementAddress.value)}`; } - const { EUD } = /** @type {AddressHook['query']} */ (decoded.query); - log(`decoded EUD: ${EUD}`); - // throws if the bech32 prefix is not found - const destination = chainHub.makeChainAddress(EUD); + const { EUD, CID } = /** @type {AddressHook['query']} */ ( + decoded.query + ); + log(`decoded EUD: ${EUD}, CID: ${CID}`); + + const destination = CID + ? harden({ + value: EUD, + chainId: formatChainId(CID), + // note: omitting encoding + }) + : // only works for bech32 addrs; throws if prefix is not found in ChainHub + chainHub.makeChainAddress(EUD); const fullAmount = toAmount(evidence.tx.amount); const { borrower, notifier, poolAccount } = this.state; @@ -210,9 +241,16 @@ export const prepareAdvancerKit = ( statusManager.observe(evidence); } }, - /** @param {ChainAddress} intermediateRecipient */ - setIntermediateRecipient(intermediateRecipient) { + /** + * @param {OrchestrationAccount<{chainId: 'noble-1'}>} intermediateRecipient + @param {ChainAddress} intermediateRecipientAddress */ + setIntermediateRecipient( + intermediateRecipient, + intermediateRecipientAddress, + ) { this.state.intermediateRecipient = intermediateRecipient; + this.state.intermediateRecipientAddress = + intermediateRecipientAddress; }, }, depositHandler: { @@ -221,23 +259,49 @@ export const prepareAdvancerKit = ( * @param {AdvancerVowCtx & { tmpSeat: ZCFSeat }} ctx */ onFulfilled(result, ctx) { - const { poolAccount, intermediateRecipient, settlementAddress } = - this.state; + const { + poolAccount, + intermediateRecipientAddress, + settlementAddress, + } = this.state; const { destination, advanceAmount, tmpSeat: _, ...detail } = ctx; const amount = harden({ denom: usdc.denom, value: advanceAmount.value, }); + + // TODO: use destination.chainId to determine if non-cosmos/ibc from ChainInfo + // either: ecosystem: 'evm', 'solana', 'cosmos' etc, or maybe assert connections.length? + // use absence of encoding: 'bech32' for now + const toIntermediate = destination.encoding !== 'bech32'; + + if (!intermediateRecipientAddress) + throw Fail`no 'intermediateRecipientAddress' found`; + const transferDest = toIntermediate + ? intermediateRecipientAddress + : destination; + + /** + * To agoric (`type: local`): use bank/Send + * To `type:cosmos`: use .transfer to EUD (PFM might be autogen'ed) + * To `type:evm|cosmos`: 1) use .transfer to NobleICA (intermediateRecipient, or a new ICA?) + * and 2) call depositForBurn with EUD as destination + */ const transferOrSendV = destination.chainId === settlementAddress.chainId ? E(poolAccount).send(destination, amount) - : E(poolAccount).transfer(destination, amount, { - forwardOpts: { intermediateRecipient }, + : E(poolAccount).transfer(transferDest, amount, { + forwardOpts: { + intermediateRecipient: intermediateRecipientAddress, + }, }); + return watch(transferOrSendV, this.facets.transferHandler, { destination, advanceAmount, ...detail, + // something to indicate we need to call depositForBurn + toIntermediate, }); }, /** @@ -271,8 +335,25 @@ export const prepareAdvancerKit = ( * @param {AdvancerVowCtx} ctx */ onFulfilled(result, ctx) { - const { notifier } = this.state; - const { advanceAmount, destination, ...detail } = ctx; + const { intermediateRecipient, notifier } = this.state; + const { advanceAmount, destination, toIntermediate, ...detail } = ctx; + if (toIntermediate) { + if (!intermediateRecipient) + throw Fail`no 'intermediateRecipient' found`; + const depositForBurnV = E(intermediateRecipient).depositForBurn( + destination, + harden({ denom: 'uusdc', value: advanceAmount.value }), + ); + return watch( + depositForBurnV, + // TODO: worth a separate Settler handler, as $ won't be in the advancer account? + this.facets.transferHandler, + { + ...ctx, + toIntermediate: false, // so we don't call depositForBurn twice + }, + ); + } log('Advance succeeded', { advanceAmount, destination }); // During development, due to a bug, this call threw. // The failure was silent (no diagnostics) due to: @@ -289,8 +370,17 @@ export const prepareAdvancerKit = ( onRejected(error, ctx) { const { notifier } = this.state; log('Advance failed', error); - const { advanceAmount: _, ...restCtx } = ctx; + const { advanceAmount: _, toIntermediate: __, ...restCtx } = ctx; notifier.notifyAdvancingResult(restCtx, false); + // TODO FIX: + // 1. withdraw advanceAmount from poolAccount + // 2. returnToPool + + // TODO (maybe in a separate handler) + // If DepositForBurn failed: + // 1. transfer back to poolAccount (no hook) + // 2. withdraw advance amount + // 3. returnToPool }, }, }, diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index d9d64ea73da..f34a572cf45 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -339,6 +339,8 @@ export const prepareSettler = ( forward(txHash, fullValue, EUD) { const { settlementAccount, intermediateRecipient } = this.state; const dest = chainHub.makeChainAddress(EUD); + // TODO: should use bank send if forwarding to an agoric address + // XXX: can we factor/reuse code from Advancer here? const txfrV = E(settlementAccount).transfer( dest, AmountMath.make(USDC, fullValue), diff --git a/packages/fast-usdc/src/type-guards.js b/packages/fast-usdc/src/type-guards.js index 3bc2a3c2a49..2ab5036853e 100644 --- a/packages/fast-usdc/src/type-guards.js +++ b/packages/fast-usdc/src/type-guards.js @@ -104,7 +104,7 @@ harden(PendingTxShape); /** @type {TypedPattern} */ export const AddressHookShape = { baseAddress: M.string(), - query: { EUD: M.string() }, + query: M.splitRecord({ EUD: M.string() }, { CID: M.string() }, {}), }; harden(AddressHookShape); diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index a1d53c2fd8a..1e0bf3660a5 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -130,6 +130,8 @@ export type AddressHook = { query: { /** end user destination address */ EUD: string; + /** chain id for end user destination. necessary if EUD is not bech32 */ + CID?: string; }; }; diff --git a/packages/fast-usdc/test/fixtures.ts b/packages/fast-usdc/test/fixtures.ts index b903036660e..29719a29a02 100644 --- a/packages/fast-usdc/test/fixtures.ts +++ b/packages/fast-usdc/test/fixtures.ts @@ -11,6 +11,9 @@ const mockScenarios = [ 'AGORIC_PLUS_AGORIC', 'AGORIC_NO_PARAMS', 'AGORIC_UNKNOWN_EUD', + 'AGORIC_PLUS_SOLANA', + 'AGORIC_PLUS_BASE', + 'AGORIC_PLUS_BASE_NO_CHAIN_ID', ] as const; type MockScenario = (typeof mockScenarios)[number]; @@ -40,6 +43,7 @@ export const MockCctpTxEvidences: Record< receiverAddress || encodeAddressHook(settlementAddress.value, { EUD: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + CID: 'osmosis-1', }), }, chainId: 1, @@ -61,6 +65,7 @@ export const MockCctpTxEvidences: Record< receiverAddress || encodeAddressHook(settlementAddress.value, { EUD: 'dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + CID: 'dydx-mainnet-1', }), }, chainId: 1, @@ -82,6 +87,7 @@ export const MockCctpTxEvidences: Record< receiverAddress || encodeAddressHook(settlementAddress.value, { EUD: 'agoric13rj0cc0hm5ac2nt0sdup2l7gvkx4v9tyvgq3h2', + CID: 'agoric-3', }), }, chainId: 1, @@ -120,6 +126,72 @@ export const MockCctpTxEvidences: Record< receiverAddress || encodeAddressHook(settlementAddress.value, { EUD: 'random1addr', + CID: 'random-1', + }), + }, + chainId: 1, + }), + AGORIC_PLUS_SOLANA: (receiverAddress?: string) => ({ + blockHash: + '0x70d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + txHash: + '0xaa1bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 210000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelyyy', + sender: Senders.default, + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + encodeAddressHook(settlementAddress.value, { + EUD: 'EUdL1XDvkcu7xAE5iack1h6zbR8k6wCebTfmtQGk8fFS', + CID: 'solana', + }), + }, + chainId: 1, + }), + AGORIC_PLUS_BASE: (receiverAddress?: string) => ({ + blockHash: + '0x70d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + txHash: + '0xba1bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 210000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelyyy', + sender: Senders.default, + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + encodeAddressHook(settlementAddress.value, { + EUD: '0xe0d43135EBd2593907F8f56c25ADC1Bf94FCf993', + CID: '8453', // integer, but only string permitted + }), + }, + chainId: 1, + }), + AGORIC_PLUS_BASE_NO_CHAIN_ID: (receiverAddress?: string) => ({ + blockHash: + '0x70d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + txHash: + '0xba1bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 210000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelyyy', + sender: Senders.default, + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + encodeAddressHook(settlementAddress.value, { + EUD: '0xe0d43135EBd2593907F8f56c25ADC1Bf94FCf993', }), }, chainId: 1, @@ -185,6 +257,34 @@ export const MockVTransferEvents: Record< recieverAddress || MockCctpTxEvidences.AGORIC_UNKNOWN_EUD().aux.recipientAddress, }), + AGORIC_PLUS_SOLANA: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_SOLANA().tx.amount, + sender: MockCctpTxEvidences.AGORIC_PLUS_SOLANA().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_PLUS_SOLANA().aux.recipientAddress, + }), + AGORIC_PLUS_BASE: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_BASE().tx.amount, + sender: MockCctpTxEvidences.AGORIC_PLUS_BASE().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_PLUS_BASE().aux.recipientAddress, + }), + AGORIC_PLUS_BASE_NO_CHAIN_ID: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_BASE_NO_CHAIN_ID().tx.amount, + sender: + MockCctpTxEvidences.AGORIC_PLUS_BASE_NO_CHAIN_ID().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_PLUS_BASE_NO_CHAIN_ID().aux.recipientAddress, + }), }; export const intermediateRecipient: ChainAddress = harden({