From 1376020656a57ee341b5f76f9ce127e76fc657bd Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 15:31:17 -0500 Subject: [PATCH 1/9] feat: `TxStatus` const for `StatusManager` states --- packages/fast-usdc/src/constants.js | 27 +++++++++++++++++++++++ packages/fast-usdc/test/constants.test.ts | 12 ++++++++++ 2 files changed, 39 insertions(+) create mode 100644 packages/fast-usdc/src/constants.js create mode 100644 packages/fast-usdc/test/constants.test.ts diff --git a/packages/fast-usdc/src/constants.js b/packages/fast-usdc/src/constants.js new file mode 100644 index 00000000000..97fbd9c3f47 --- /dev/null +++ b/packages/fast-usdc/src/constants.js @@ -0,0 +1,27 @@ +/** + * Status values for FastUSDC. + * + * @enum {(typeof TxStatus)[keyof typeof TxStatus]} + */ +export const TxStatus = /** @type {const} */ ({ + /** tx was observed but not advanced */ + Observed: 'OBSERVED', + /** IBC transfer is initiated */ + Advanced: 'ADVANCED', + /** settlement for matching advance received and funds dispersed */ + Settled: 'SETTLED', +}); +harden(TxStatus); + +/** + * Status values for the StatusManager. + * + * @enum {(typeof PendingTxStatus)[keyof typeof PendingTxStatus]} + */ +export const PendingTxStatus = /** @type {const} */ ({ + /** tx was observed but not advanced */ + Observed: 'OBSERVED', + /** IBC transfer is initiated */ + Advanced: 'ADVANCED', +}); +harden(PendingTxStatus); diff --git a/packages/fast-usdc/test/constants.test.ts b/packages/fast-usdc/test/constants.test.ts new file mode 100644 index 00000000000..b2daefd0f9e --- /dev/null +++ b/packages/fast-usdc/test/constants.test.ts @@ -0,0 +1,12 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { TxStatus, PendingTxStatus } from '../src/constants.js'; + +const { values } = Object; + +test('PendingTxStatus is a subset of TxStatus', t => { + const txStatuses = values(TxStatus); + const difference = values(PendingTxStatus).filter( + status => !txStatuses.includes(status), + ); + t.deepEqual(difference, [], 'PendingTxStatus value(s) not in TxStatus'); +}); From 646df7869d79e11a32fcde15f001d52b8494303f Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 15:33:19 -0500 Subject: [PATCH 2/9] types: `CctpTxEvidence`, `PendingTx` --- packages/fast-usdc/src/types-index.d.ts | 1 + packages/fast-usdc/src/types-index.js | 1 + packages/fast-usdc/src/types.ts | 33 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+) create mode 100644 packages/fast-usdc/src/types-index.d.ts create mode 100644 packages/fast-usdc/src/types-index.js create mode 100644 packages/fast-usdc/src/types.ts diff --git a/packages/fast-usdc/src/types-index.d.ts b/packages/fast-usdc/src/types-index.d.ts new file mode 100644 index 00000000000..06c33f562f4 --- /dev/null +++ b/packages/fast-usdc/src/types-index.d.ts @@ -0,0 +1 @@ +export type * from './types.js'; diff --git a/packages/fast-usdc/src/types-index.js b/packages/fast-usdc/src/types-index.js new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/packages/fast-usdc/src/types-index.js @@ -0,0 +1 @@ +export {}; diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts new file mode 100644 index 00000000000..9770ace414e --- /dev/null +++ b/packages/fast-usdc/src/types.ts @@ -0,0 +1,33 @@ +import type { ChainAddress } from '@agoric/orchestration'; +import type { IBCChannelID } from '@agoric/vats'; +import type { PendingTxStatus } from './constants.js'; + +export type EvmHash = `0x${string}`; +export type NobleAddress = `noble1${string}`; + +export interface CctpTxEvidence { + /** from Noble RPC */ + aux: { + forwardingChannel: IBCChannelID; + recipientAddress: ChainAddress['value']; + }; + blockHash: EvmHash; + blockNumber: bigint; + blockTimestamp: bigint; + chainId: number; + /** data covered by signature (aka txHash) */ + tx: { + amount: bigint; + forwardingAddress: NobleAddress; + }; + txHash: EvmHash; +} + +export interface PendingTx extends CctpTxEvidence { + status: PendingTxStatus; +} + +/** internal key for `StatusManager` exo */ +export type PendingTxKey = `pendingTx:${string}`; + +export type * from './constants.js'; From 5e4139c3fd4e5772516fa461fb80b301cde05859 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 15:34:30 -0500 Subject: [PATCH 3/9] test: `MockCctpTxEvidences` and `MockVTransferEvents` fixtures --- packages/fast-usdc/test/fixtures.ts | 86 +++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/fast-usdc/test/fixtures.ts diff --git a/packages/fast-usdc/test/fixtures.ts b/packages/fast-usdc/test/fixtures.ts new file mode 100644 index 00000000000..138e137930f --- /dev/null +++ b/packages/fast-usdc/test/fixtures.ts @@ -0,0 +1,86 @@ +import type { VTransferIBCEvent } from '@agoric/vats'; +import { buildVTransferEvent } from '@agoric/orchestration/tools/ibc-mocks.js'; +import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; +import type { CctpTxEvidence } from '../src/types.js'; + +const mockScenarios = ['AGORIC_PLUS_OSMO', 'AGORIC_PLUS_DYDX'] as const; + +type MockScenario = (typeof mockScenarios)[number]; + +export const MockCctpTxEvidences: Record< + MockScenario, + (receiverAddress?: string) => CctpTxEvidence +> = { + AGORIC_PLUS_OSMO: (receiverAddress?: string) => ({ + blockHash: + '0x90d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee665', + blockNumber: 21037663n, + blockTimestamp: 1730762090n, + txHash: + '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702', + tx: { + amount: 150000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek?EUD=osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + chainId: 1, + }), + AGORIC_PLUS_DYDX: (receiverAddress?: string) => ({ + blockHash: + '0x80d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + blockTimestamp: 1730762099n, + txHash: + '0xd81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 200000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelktz', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek?EUD=dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + chainId: 1, + }), +}; + +const nobleDefaultVTransferParams = { + // (XXX confirm) FungibleTokenPacketData is from the perspective of the counterparty + denom: 'uusdc', + sourceChannel: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel + .counterPartyChannelId, + destinationChannel: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, +}; + +export const MockVTransferEvents: Record< + MockScenario, + (receiverAddress?: string) => VTransferIBCEvent +> = { + AGORIC_PLUS_OSMO: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_OSMO().tx.amount, + sender: MockCctpTxEvidences.AGORIC_PLUS_OSMO().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_PLUS_OSMO().aux.recipientAddress, + }), + AGORIC_PLUS_DYDX: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_PLUS_DYDX().tx.amount, + sender: MockCctpTxEvidences.AGORIC_PLUS_DYDX().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_PLUS_DYDX().aux.recipientAddress, + }), +}; From 5a7b3d25cb7853e9109f74a7b45feb29b8ff69fe Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 15:35:38 -0500 Subject: [PATCH 4/9] feat: `CctpTxEvidenceShape`, `PendingTxShape` typeGuards --- packages/fast-usdc/src/typeGuards.js | 39 ++++++++++++++++++++++ packages/fast-usdc/test/typeGuards.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 packages/fast-usdc/src/typeGuards.js create mode 100644 packages/fast-usdc/test/typeGuards.test.ts diff --git a/packages/fast-usdc/src/typeGuards.js b/packages/fast-usdc/src/typeGuards.js new file mode 100644 index 00000000000..2f10318fc57 --- /dev/null +++ b/packages/fast-usdc/src/typeGuards.js @@ -0,0 +1,39 @@ +import { M } from '@endo/patterns'; +import { PendingTxStatus } from './constants.js'; + +/** + * @import {TypedPattern} from '@agoric/internal'; + * @import {CctpTxEvidence, PendingTx} from './types.js'; + */ + +/** @type {TypedPattern} */ +export const EvmHashShape = M.string({ + stringLengthLimit: 66, +}); +harden(EvmHashShape); + +/** @type {TypedPattern} */ +export const CctpTxEvidenceShape = { + aux: { + forwardingChannel: M.string(), + recipientAddress: M.string(), + }, + blockHash: EvmHashShape, + blockNumber: M.bigint(), + blockTimestamp: M.bigint(), + chainId: M.number(), + tx: { + amount: M.bigint(), + forwardingAddress: M.string(), + }, + txHash: EvmHashShape, +}; +harden(CctpTxEvidenceShape); + +/** @type {TypedPattern} */ +// @ts-expect-error TypedPattern can't handle spreading? +export const PendingTxShape = { + ...CctpTxEvidenceShape, + status: M.or(...Object.values(PendingTxStatus)), +}; +harden(PendingTxShape); diff --git a/packages/fast-usdc/test/typeGuards.test.ts b/packages/fast-usdc/test/typeGuards.test.ts new file mode 100644 index 00000000000..9920fd7ce34 --- /dev/null +++ b/packages/fast-usdc/test/typeGuards.test.ts @@ -0,0 +1,39 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { mustMatch } from '@endo/patterns'; +import { TxStatus, PendingTxStatus } from '../src/constants.js'; +import { CctpTxEvidenceShape, PendingTxShape } from '../src/typeGuards.js'; +import type { CctpTxEvidence } from '../src/types.js'; + +import { MockCctpTxEvidences } from './fixtures.js'; + +test('CctpTxEvidenceShape', t => { + const specimen: CctpTxEvidence = harden( + MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + ); + + t.notThrows(() => mustMatch(specimen, CctpTxEvidenceShape)); +}); + +test('PendingTxShape', t => { + const specimen: CctpTxEvidence & { status: TxStatus } = harden({ + ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + status: PendingTxStatus.Observed, + }); + + t.notThrows(() => mustMatch(specimen, PendingTxShape)); + + t.notThrows(() => + mustMatch( + harden({ ...specimen, status: PendingTxStatus.Advanced }), + PendingTxShape, + ), + ); + + t.throws(() => + mustMatch( + harden({ ...specimen, status: TxStatus.Settled }), + PendingTxShape, + ), + ); +}); From 1063efd661cc672b491783f14776cc5953bc1205 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 15:36:16 -0500 Subject: [PATCH 5/9] chore: export `denomHash` helper --- .../orchestration-imports.test.js.md | 1 + .../orchestration-imports.test.js.snap | Bin 3397 -> 3430 bytes packages/orchestration/index.js | 1 + .../test/snapshots/exports.test.ts.md | 1 + .../test/snapshots/exports.test.ts.snap | Bin 582 -> 601 bytes 5 files changed, 3 insertions(+) diff --git a/packages/builders/test/snapshots/orchestration-imports.test.js.md b/packages/builders/test/snapshots/orchestration-imports.test.js.md index b0a30362b20..4ef4fc7dc4e 100644 --- a/packages/builders/test/snapshots/orchestration-imports.test.js.md +++ b/packages/builders/test/snapshots/orchestration-imports.test.js.md @@ -524,6 +524,7 @@ Generated by [AVA](https://avajs.dev). }, }, }, + denomHash: Function denomHash {}, prepareChainHubAdmin: Function prepareChainHubAdmin {}, prepareCosmosInterchainService: Function prepareCosmosInterchainService {}, withOrchestration: Function withOrchestration {}, diff --git a/packages/builders/test/snapshots/orchestration-imports.test.js.snap b/packages/builders/test/snapshots/orchestration-imports.test.js.snap index d6c502b9743f72f59e14b545cfd8b5a5fb45f1cc..32dc1cdcc424341f1c755a8cd6fa3abfb78c1fd4 100644 GIT binary patch literal 3430 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 literal 3397 zcmV-L4Z89{RzVJ2tRYQFV05M+nP#1WMdn{x;~XprJ-P!* zJ>5|dgdd9t00000000BcT6t_7)fxZ2T_3w^dyn|GbJ$Mo_1fi1IMM`YZ09oII1ncx zq#+saKHH0TXErlq6N{EMDwhi3C<+Ax6bhv!k_IRgQKd_0bcL`2RuN~3-o${wO(Mm7r4O- z?DYa~d4Z}jpq5nx8H=!rAtf=D;^A-@tOl$FEXdgLWk4PCf|W;DurIAl$$FnUzHUOw za1-As;E_IgW{sp4JcY#(fTxv>rPfnc>$}4t#brcXgN>uadY|dgi8fNcsZAqG#&P30wZWLoLxLF z-~kiQW4{%!$YB4#z=R~rJTvCXQi2b0eL_iSCg;}zr!J%NjHGgNQ>~sF?MtMSvdMa1V9m|3%ndjw>s&Q$ zujA@;GM=4opRfroPU;iGVk9xbglEhLqkF3Aa#?_*{6_<%h=;CUbL4pr3Izh>#4Pkq1_lvW>M5zDZaNhNjb zu;&!DexTV;6isf5g??Z$RkXV)&h-Nusp4BD6=5*Hp)1e$foVT*jUV`>A9#k^IMF!) zVX#2%)5kn-`hmBoWQkk1mhMwO@CDVK={ku4pgBMk{jQT302WilD%VL202`^|>-YG9|1OMg}6> zPK7mK%nUG_tMUP+G9%5T6e%IVmz9FOCj5X1n~o1!SNS6X;Nx;!NhIZQ6L~^F%BQ8& z6c@UC*4>-;++lyUE4;82TxQLL0GEwQniYVr7FzvAt9eUXmzV&RkE)Vv4gE>6*_;!> zhePfzhH6~llilGh?(h|*V2=s!6=8iQ!`G`R6L_ko=!#jY--)0s$%=Iw{y{XR@wg%z z*QD*rEAF0t?(S)8Fb`LcbKTC}Oms*fEn z^X7gFg+eCr)*x_a5ZFVAAqP>6`gRQ88w8#rvIb(`^Fk2VPe{$wi8q75TS4HTgxEoe z9|wWYg8)KK;&HUwst^zfk+61BgoUhNsPeR;OQYEbfIXs~5O5+@Eutxgk9h_|z%V5) zE@9rsJQE>6CO$8vKJN$tmlD!<=nPyH01kTH^9uHuQ3>?Xnjv zWL(R+E^88eOqxpR<{WEgYh?}P*eTQ+Da~`O%_Uz(=C}^m3Sm%KM@{gsqt2exIBz$HUQT*01q?(`>13=!Q35e06uB}DjEs(_yQ-|8-XaLolrpQZv<9T+QNd4 zCmVt7jld<1&e(J^jZIfH0@qM`-=_9_4>baN8-dpvfy0eJYZGuv6EM^SB$|l)^n!)? zeiQJcCg6@H;OQpdwI<+wDqBWmPINgnTIOp8g3Z8!W?-P17+X;=CA=A!YzB5x>Ri*8>KR#-9Ig* zk_kyyRAEdXiiCt!pUo2-_iK4d8s#bb+ko{dvv0{y=Hzb4`E$k8;}~qzfK^&G#JPeu z@oXX1nuZYpsyE*vTwN=v`pkfXc7Z^fCDPU^s-ER+6F5FaO>i~$uHGde6`8!^Mxni2 z(903n~~#$6j4maq*PzdO7VQTTIHIaFYr!a@wfREdNFWQ{BV#Jv%I*rx3R?^GcfvR@We`|!*WdB}aSk1lz z3ac^1)`E7RhuZ2a`KTCZ2L>s+>Xe^Bjd)oHP}u==bPz**C6?1Nu|zP@ z*8!~R0LH0_l?5iMORQ_RIiM>$fL+wc8n>#*y&b^)RB^7G;)M=iKUG}dvJZXC^IivV zh-$uDN@I`F=QukF`p)vs2mJGiRc0=f9I)OiR0*5+*a2K$+3)tL6%ncVj z;CFNZmv#ZSbOFzH0q=JKfhdukS5Q@mMuFp^&Jk^TH-U{8i-myqMA@`*@rDzHe7CL_Xz3*mv=x0YE$anVQtCw|9z4g8WmU*DaoO5iVy&;} b1+Jl8PZmtmQKI|?W9T>Ft z)TZ%_H8X(*iA8U~f>n1#;s7k!a0E82khlR$4glCNL!H(u+wY@qe!lN~ayXUAu|9pz zO=hN?XM*vQObc4*R2qGH&KNcHpt0v;8RbJ($nAFk&H#J@@V)#;fV%`35a0y?-V)#= z0lpI8CjkNvxbFeaJ>ZQ8eDHuT9`M5hdOq;b2M&DTwGW*6z~?{T-+bVg58P`29kSFW z%}^;iC&a!5K>*SMxdpP04)M?>K1j1oHo{!agbCHreo71eFDAt1?NH30Q1e&Sh-XRX zBGG$1k&0R5yE;)yGZ8YTxYo5g4Y!qw#9|_AdDG_INa|ebP-|{3sig&Vb**Kqf&riM zBWf}!YGB2J%>frOzv9@dZo8GZv_#A1ttzi&YwkAUeiWrt2tJJmHL~t*^#WO3~FjSl5gD(O6NTCtQt+($+h*Y2jKNJ=@jwiD}z#XEZa1awgdS zC5Ke7DOa(zTdvSkC5?PgE7Wl}FwSzW4b6*6)lkzzweT^q@_uk&_aK=AR7Cr(CDrj!0ZJ=^cS<#aMr$y;;rk!)sX zM41$Ho*BstlPYU2mO^mL-_(vmY-07XEq4C{fJ*?M0elDW8^A*bJYm3;0q+^`i2>gj z@QVT40q`gQMgg!0fMo!D4S=5k&z5ZBpX{Mxy#mZJE)pBqLpNIWxZgWh-fUb5Ph zr##_;PROQ87-tTAn+r_^*HkXm@q7}AJd;k{b?Sjr Date: Tue, 5 Nov 2024 16:01:05 -0500 Subject: [PATCH 6/9] feat: minimal `addressTools` for query param parsing --- packages/fast-usdc/src/utils/address.js | 63 +++++++++++++++ packages/fast-usdc/test/utils/address.test.ts | 78 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 packages/fast-usdc/src/utils/address.js create mode 100644 packages/fast-usdc/test/utils/address.test.ts diff --git a/packages/fast-usdc/src/utils/address.js b/packages/fast-usdc/src/utils/address.js new file mode 100644 index 00000000000..f66b839f5ac --- /dev/null +++ b/packages/fast-usdc/src/utils/address.js @@ -0,0 +1,63 @@ +import { makeError, q } from '@endo/errors'; + +/** + * Very minimal 'URL query string'-like parser that handles: + * - Query string delimiter (?) + * - Key-value separator (=) + * - Query parameter separator (&) + * + * Does not handle: + * - Subpaths (`agoric1bech32addr/opt/account?k=v`) + * - URI encoding/decoding (`%20` -> ` `) + * - note: `decodeURIComponent` seems to be available in XS + * - Multiple question marks (foo?bar=1?baz=2) + * - Empty parameters (foo=) + * - Array parameters (`foo?k=v1&k=v2` -> k: [v1, v2]) + * - Parameters without values (foo&bar=2) + */ +export const addressTools = { + /** + * @param {string} address + * @returns {boolean} + */ + hasQueryParams: address => { + try { + const { params } = addressTools.getQueryParams(address); + return Object.keys(params).length > 0; + } catch { + return false; + } + }, + /** + * @param {string} address + * @returns {{ address: string, params: Record}} + */ + getQueryParams: address => { + const parts = address.split('?'); + if (parts.length === 0 || parts.length > 2) { + throw makeError( + `Invalid input. Must be of the form 'address?params': ${q(address)}`, + ); + } + const result = { + address: parts[0], + params: {}, + }; + + // no parameters, return early + if (parts.length === 1) { + return result; + } + + const paramPairs = parts[1].split('&'); + for (const pair of paramPairs) { + const [key, value] = pair.split('='); + if (!key || !value) { + throw makeError(`Invalid parameter format in pair: ${q(pair)}`); + } + result.params[key] = value; + } + + return result; + }, +}; diff --git a/packages/fast-usdc/test/utils/address.test.ts b/packages/fast-usdc/test/utils/address.test.ts new file mode 100644 index 00000000000..107cb640a67 --- /dev/null +++ b/packages/fast-usdc/test/utils/address.test.ts @@ -0,0 +1,78 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { addressTools } from '../../src/utils/address.js'; + +const FIXTURES = { + AGORIC_WITH_DYDX: + 'agoric1bech32addr?EUD=dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + AGORIC_WITH_OSMO: + 'agoric1bech32addr?EUD=osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + AGORIC_WITH_MULTIPLE: + 'agoric1bech32addr?EUD=osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men&CID=dydx-mainnet-1', + AGORIC_NO_PARAMS: 'agoric1bech32addr', + INVALID_MULTIPLE_QUESTION: 'agoric1bech32addr?param1=value1?param2=value2', + INVALID_PARAM_FORMAT: 'agoric1bech32addr?invalidparam', +} as const; + +// hasQueryParams tests +test('hasQueryParams: returns true when address has parameters', t => { + t.true(addressTools.hasQueryParams(FIXTURES.AGORIC_WITH_DYDX)); + t.true(addressTools.hasQueryParams(FIXTURES.AGORIC_WITH_OSMO)); + t.true(addressTools.hasQueryParams(FIXTURES.AGORIC_WITH_MULTIPLE)); +}); + +test('hasQueryParams: returns false when address has no parameters', t => { + t.false(addressTools.hasQueryParams(FIXTURES.AGORIC_NO_PARAMS)); +}); + +test('hasQueryParams: returns false for invalid parameter formats', t => { + t.false(addressTools.hasQueryParams(FIXTURES.INVALID_MULTIPLE_QUESTION)); + t.false(addressTools.hasQueryParams(FIXTURES.INVALID_PARAM_FORMAT)); +}); + +// getQueryParams tests - positive cases +test('getQueryParams: correctly parses address with single EUD parameter', t => { + const result = addressTools.getQueryParams(FIXTURES.AGORIC_WITH_DYDX); + t.deepEqual(result, { + address: 'agoric1bech32addr', + params: { + EUD: 'dydx183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + }, + }); +}); + +test('getQueryParams: correctly parses address with multiple parameters', t => { + const result = addressTools.getQueryParams(FIXTURES.AGORIC_WITH_MULTIPLE); + t.deepEqual(result, { + address: 'agoric1bech32addr', + params: { + EUD: 'osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men', + CID: 'dydx-mainnet-1', + }, + }); +}); + +test('getQueryParams: correctly handles address with no parameters', t => { + const result = addressTools.getQueryParams(FIXTURES.AGORIC_NO_PARAMS); + t.deepEqual(result, { + address: 'agoric1bech32addr', + params: {}, + }); +}); + +// getQueryParams tests - negative cases +test('getQueryParams: throws error for multiple question marks', t => { + t.throws( + () => addressTools.getQueryParams(FIXTURES.INVALID_MULTIPLE_QUESTION), + { + message: + 'Invalid input. Must be of the form \'address?params\': "agoric1bech32addr?param1=value1?param2=value2"', + }, + ); +}); + +test('getQueryParams: throws error for invalid parameter format', t => { + t.throws(() => addressTools.getQueryParams(FIXTURES.INVALID_PARAM_FORMAT), { + message: 'Invalid parameter format in pair: "invalidparam"', + }); +}); From 3f71342a729ae6bd02826ab6c9717604e79d7ecc Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Mon, 11 Nov 2024 10:34:16 -0500 Subject: [PATCH 7/9] refactor: `getChainInfoByAddress` -> `makeChainAddress` - removes `getChainInfoByAddress` and provides `makeChainAddress`, better aligning the helper with intended usage - arguably a breaking change, but we've yet to release `getChainInfoByAddress` --- packages/orchestration/src/exos/chain-hub.js | 21 +++++++++++++------ .../orchestration/test/exos/chain-hub.test.ts | 14 ++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/orchestration/src/exos/chain-hub.js b/packages/orchestration/src/exos/chain-hub.js index 6b86cfe881a..3cc0ff20fe4 100644 --- a/packages/orchestration/src/exos/chain-hub.js +++ b/packages/orchestration/src/exos/chain-hub.js @@ -4,7 +4,11 @@ import { M } from '@endo/patterns'; import { BrandShape } from '@agoric/ertp/src/typeGuards.js'; import { VowShape } from '@agoric/vow'; -import { CosmosChainInfoShape, IBCConnectionInfoShape } from '../typeGuards.js'; +import { + ChainAddressShape, + CosmosChainInfoShape, + IBCConnectionInfoShape, +} from '../typeGuards.js'; import { getBech32Prefix } from '../utils/address.js'; /** @@ -13,7 +17,7 @@ import { getBech32Prefix } from '../utils/address.js'; * @import {Zone} from '@agoric/zone'; * @import {CosmosAssetInfo, CosmosChainInfo, IBCConnectionInfo} from '../cosmos-api.js'; * @import {ChainInfo, KnownChains} from '../chain-info.js'; - * @import {Denom} from '../orchestration-api.js'; + * @import {ChainAddress, Denom} from '../orchestration-api.js'; * @import {Remote} from '@agoric/internal'; * @import {TypedPattern} from '@agoric/internal'; */ @@ -181,7 +185,7 @@ const ChainHubI = M.interface('ChainHub', { registerAsset: M.call(M.string(), DenomDetailShape).returns(), getAsset: M.call(M.string()).returns(M.or(DenomDetailShape, M.undefined())), getDenom: M.call(BrandShape).returns(M.or(M.string(), M.undefined())), - getChainInfoByAddress: M.call(M.string()).returns(CosmosChainInfoShape), + makeChainAddress: M.call(M.string()).returns(ChainAddressShape), }); /** @@ -440,15 +444,20 @@ export const makeChainHub = (zone, agoricNames, vowTools) => { }, /** * @param {string} address bech32 address - * @returns {CosmosChainInfo} + * @returns {ChainAddress} */ - getChainInfoByAddress(address) { + makeChainAddress(address) { const prefix = getBech32Prefix(address); if (!bech32PrefixToChainName.has(prefix)) { throw makeError(`Chain info not found for bech32Prefix ${q(prefix)}`); } const chainName = bech32PrefixToChainName.get(prefix); - return chainInfos.get(chainName); + const { chainId } = chainInfos.get(chainName); + return harden({ + chainId, + value: address, + encoding: /** @type {const} */ ('bech32'), + }); }, }); diff --git a/packages/orchestration/test/exos/chain-hub.test.ts b/packages/orchestration/test/exos/chain-hub.test.ts index 2f3185cb6a3..b4f4823be97 100644 --- a/packages/orchestration/test/exos/chain-hub.test.ts +++ b/packages/orchestration/test/exos/chain-hub.test.ts @@ -160,7 +160,7 @@ test('toward asset info in agoricNames (#9572)', async t => { } }); -test('getChainInfoByAddress', async t => { +test('makeChainAddress', async t => { const { chainHub, nameAdmin, vt } = setup(); // use fetched chain info await registerKnownChains(nameAdmin); @@ -170,24 +170,24 @@ test('getChainInfoByAddress', async t => { const MOCK_ICA_ADDRESS = 'osmo1ht7u569vpuryp6utadsydcne9ckeh2v8dkd38v5hptjl3u2ewppqc6kzgd'; - t.like(chainHub.getChainInfoByAddress(MOCK_ICA_ADDRESS), { + t.deepEqual(chainHub.makeChainAddress(MOCK_ICA_ADDRESS), { chainId: 'osmosis-1', - bech32Prefix: 'osmo', + value: MOCK_ICA_ADDRESS, + encoding: 'bech32', }); t.throws( - () => - chainHub.getChainInfoByAddress(MOCK_ICA_ADDRESS.replace('osmo1', 'foo1')), + () => chainHub.makeChainAddress(MOCK_ICA_ADDRESS.replace('osmo1', 'foo1')), { message: 'Chain info not found for bech32Prefix "foo"', }, ); - t.throws(() => chainHub.getChainInfoByAddress('notbech32'), { + t.throws(() => chainHub.makeChainAddress('notbech32'), { message: 'No separator character for "notbech32"', }); - t.throws(() => chainHub.getChainInfoByAddress('1notbech32'), { + t.throws(() => chainHub.makeChainAddress('1notbech32'), { message: 'Missing prefix for "1notbech32"', }); }); From 980463f422a674676f0faf036c4bfae930824482 Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Tue, 5 Nov 2024 17:27:18 -0500 Subject: [PATCH 8/9] feat: `StatusManager` scaffold - shows the various `StatusManager` MapStore states updates (`OBSERVED`, `ADVANCED`) via `Advancer` and `Settler` stubs --- packages/fast-usdc/package.json | 1 + packages/fast-usdc/src/exos/README.md | 26 +++ packages/fast-usdc/src/exos/advancer.js | 118 ++++++++++- packages/fast-usdc/src/exos/settler.js | 87 ++++++++- packages/fast-usdc/src/exos/status-manager.js | 135 ++++++++++++- packages/fast-usdc/src/fast-usdc.contract.js | 17 +- packages/fast-usdc/src/types.ts | 2 + packages/fast-usdc/test/exos/advancer.test.ts | 184 ++++++++++++++++++ packages/fast-usdc/test/exos/settler.test.ts | 99 ++++++++++ .../test/exos/status-manager.test.ts | 157 +++++++++++++++ packages/fast-usdc/test/fixtures.ts | 34 +++- packages/fast-usdc/test/mocks.ts | 51 +++++ 12 files changed, 896 insertions(+), 15 deletions(-) create mode 100644 packages/fast-usdc/src/exos/README.md create mode 100644 packages/fast-usdc/test/exos/advancer.test.ts create mode 100644 packages/fast-usdc/test/exos/settler.test.ts create mode 100644 packages/fast-usdc/test/exos/status-manager.test.ts create mode 100644 packages/fast-usdc/test/mocks.ts diff --git a/packages/fast-usdc/package.json b/packages/fast-usdc/package.json index cd889223866..e887503bf07 100644 --- a/packages/fast-usdc/package.json +++ b/packages/fast-usdc/package.json @@ -36,6 +36,7 @@ "@agoric/orchestration": "^0.1.0", "@agoric/store": "^0.9.2", "@agoric/vow": "^0.1.0", + "@endo/base64": "^1.0.8", "@endo/common": "^1.2.7", "@endo/errors": "^1.2.7", "@endo/eventual-send": "^1.2.7", diff --git a/packages/fast-usdc/src/exos/README.md b/packages/fast-usdc/src/exos/README.md new file mode 100644 index 00000000000..f65fb20a958 --- /dev/null +++ b/packages/fast-usdc/src/exos/README.md @@ -0,0 +1,26 @@ +## **StatusManager** state diagram, showing different transitions + + +### Contract state diagram + +*Transactions are qualified by the OCW and EventFeed before arriving to the Advancer.* + +```mermaid +stateDiagram-v2 + [*] --> Advanced: Advancer .advance() + Advanced --> Settled: Settler .settle() after fees + [*] --> Observed: Advancer .observed() + Observed --> Settled: Settler .settle() sans fees + Settled --> [*] +``` + +### Complete state diagram (starting from OCW) + +```mermaid +stateDiagram-v2 + Observed --> Qualified + Observed --> Unqualified + Qualified --> Advanced + Advanced --> Settled + Qualified --> Settled +``` diff --git a/packages/fast-usdc/src/exos/advancer.js b/packages/fast-usdc/src/exos/advancer.js index 28f146515f1..ab5443066d3 100644 --- a/packages/fast-usdc/src/exos/advancer.js +++ b/packages/fast-usdc/src/exos/advancer.js @@ -1,19 +1,125 @@ +import { assertAllDefined } from '@agoric/internal'; +import { ChainAddressShape } from '@agoric/orchestration'; +import { VowShape } from '@agoric/vow'; +import { makeError, q } from '@endo/errors'; +import { E } from '@endo/far'; +import { M } from '@endo/patterns'; +import { CctpTxEvidenceShape } from '../typeGuards.js'; +import { addressTools } from '../utils/address.js'; + /** + * @import {HostInterface} from '@agoric/async-flow'; + * @import {ChainAddress, ChainHub, Denom, DenomAmount, OrchestrationAccount} from '@agoric/orchestration'; + * @import {VowTools} from '@agoric/vow'; * @import {Zone} from '@agoric/zone'; - * @import {TransactionFeed} from './transaction-feed.js'; + * @import {CctpTxEvidence, LogFn} from '../types.js'; * @import {StatusManager} from './status-manager.js'; + * @import {TransactionFeed} from './transaction-feed.js'; */ -import { assertAllDefined } from '@agoric/internal'; - /** * @param {Zone} zone * @param {object} caps + * @param {ChainHub} caps.chainHub * @param {TransactionFeed} caps.feed + * @param {LogFn} caps.log * @param {StatusManager} caps.statusManager + * @param {VowTools} caps.vowTools */ -export const prepareAdvancer = (zone, { feed, statusManager }) => { - assertAllDefined({ feed, statusManager }); - return zone.exo('Fast USDC Advancer', undefined, {}); +export const prepareAdvancer = ( + zone, + { chainHub, feed, log, statusManager, vowTools: { watch } }, +) => { + assertAllDefined({ feed, statusManager, watch }); + + const transferHandler = zone.exo( + 'Fast USDC Advance Transfer Handler', + M.interface('TransferHandlerI', { + // TODO confirm undefined, and not bigint (sequence) + onFulfilled: M.call(M.undefined(), { + amount: M.bigint(), + destination: ChainAddressShape, + }).returns(M.undefined()), + onRejected: M.call(M.error(), { + amount: M.bigint(), + destination: ChainAddressShape, + }).returns(M.undefined()), + }), + { + /** + * @param {undefined} result TODO confirm this is not a bigint (sequence) + * @param {{ destination: ChainAddress; amount: bigint; }} ctx + */ + onFulfilled(result, { destination, amount }) { + log( + 'Advance transfer fulfilled', + q({ amount, destination, result }).toString(), + ); + }, + onRejected(error) { + // XXX retry logic? + // What do we do if we fail, should we keep a Status? + log('Advance transfer rejected', q(error).toString()); + }, + }, + ); + + return zone.exoClass( + 'Fast USDC Advancer', + M.interface('AdvancerI', { + handleTransactionEvent: M.call(CctpTxEvidenceShape).returns(VowShape), + }), + /** + * @param {{ + * localDenom: Denom; + * poolAccount: HostInterface>; + * }} config + */ + config => harden(config), + { + /** @param {CctpTxEvidence} evidence */ + handleTransactionEvent(evidence) { + // TODO EventFeed will perform input validation checks. + const { recipientAddress } = evidence.aux; + const { EUD } = addressTools.getQueryParams(recipientAddress).params; + if (!EUD) { + statusManager.observe(evidence); + throw makeError( + `recipientAddress does not contain EUD param: ${q(recipientAddress)}`, + ); + } + + // TODO #10391 this can throw, and should make a status update in the catch + const destination = chainHub.makeChainAddress(EUD); + + /** @type {DenomAmount} */ + const requestedAmount = harden({ + denom: this.state.localDenom, + value: BigInt(evidence.tx.amount), + }); + + // TODO #10391 ensure there's enough funds in poolAccount + + const transferV = E(this.state.poolAccount).transfer( + destination, + requestedAmount, + ); + + // mark as Advanced since `transferV` initiates the advance + statusManager.advance(evidence); + + return watch(transferV, transferHandler, { + destination, + amount: requestedAmount.value, + }); + }, + }, + { + stateShape: harden({ + localDenom: M.string(), + poolAccount: M.remotable(), + }), + }, + ); }; harden(prepareAdvancer); diff --git a/packages/fast-usdc/src/exos/settler.js b/packages/fast-usdc/src/exos/settler.js index 59356b1b795..4b33e635b29 100644 --- a/packages/fast-usdc/src/exos/settler.js +++ b/packages/fast-usdc/src/exos/settler.js @@ -1,10 +1,19 @@ +import { assertAllDefined } from '@agoric/internal'; +import { atob } from '@endo/base64'; +import { makeError, q } from '@endo/errors'; +import { M } from '@endo/patterns'; + +import { addressTools } from '../utils/address.js'; + /** + * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js'; + * @import {Denom} from '@agoric/orchestration'; + * @import {IBCChannelID, VTransferIBCEvent} from '@agoric/vats'; * @import {Zone} from '@agoric/zone'; + * @import {NobleAddress} from '../types.js'; * @import {StatusManager} from './status-manager.js'; */ -import { assertAllDefined } from '@agoric/internal'; - /** * @param {Zone} zone * @param {object} caps @@ -12,6 +21,78 @@ import { assertAllDefined } from '@agoric/internal'; */ export const prepareSettler = (zone, { statusManager }) => { assertAllDefined({ statusManager }); - return zone.exo('Fast USDC Settler', undefined, {}); + return zone.exoClass( + 'Fast USDC Settler', + M.interface('SettlerI', { + receiveUpcall: M.call(M.record()).returns(M.promise()), + }), + /** + * + * @param {{ + * sourceChannel: IBCChannelID; + * remoteDenom: Denom + * }} config + */ + config => harden(config), + { + /** @param {VTransferIBCEvent} event */ + async receiveUpcall(event) { + if (event.packet.source_channel !== this.state.sourceChannel) { + // TODO #10390 log all early returns + // only interested in packets from the issuing chain + return; + } + const tx = /** @type {FungibleTokenPacketData} */ ( + JSON.parse(atob(event.packet.data)) + ); + if (tx.denom !== this.state.remoteDenom) { + // only interested in uusdc + return; + } + + if (!addressTools.hasQueryParams(tx.receiver)) { + // only interested in receivers with query params + return; + } + + const { params } = addressTools.getQueryParams(tx.receiver); + // TODO - what's the schema address parameter schema for FUSDC? + if (!params?.EUD) { + // only interested in receivers with EUD parameter + return; + } + + // TODO discern between SETTLED and OBSERVED; each has different fees/destinations + const hasPendingSettlement = statusManager.hasPendingSettlement( + // given the sourceChannel check, we can be certain of this cast + /** @type {NobleAddress} */ (tx.sender), + BigInt(tx.amount), + ); + if (!hasPendingSettlement) { + // TODO FAILURE PATH -> put money in recovery account or .transfer to receiver + // TODO should we have an ORPHANED TxStatus for this? + throw makeError( + `🚨 No pending settlement found for ${q(tx.sender)} ${q(tx.amount)}`, + ); + } + + // TODO disperse funds + // ~1. fee to contractFeeAccount + // ~2. remainder in poolAccount + + // update status manager, marking tx `SETTLED` + statusManager.settle( + /** @type {NobleAddress} */ (tx.sender), + BigInt(tx.amount), + ); + }, + }, + { + stateShape: harden({ + sourceChannel: M.string(), + remoteDenom: M.string(), + }), + }, + ); }; harden(prepareSettler); diff --git a/packages/fast-usdc/src/exos/status-manager.js b/packages/fast-usdc/src/exos/status-manager.js index ea1f69b5827..152a534a0d2 100644 --- a/packages/fast-usdc/src/exos/status-manager.js +++ b/packages/fast-usdc/src/exos/status-manager.js @@ -1,12 +1,145 @@ +import { M } from '@endo/patterns'; +import { makeError, q } from '@endo/errors'; + +import { appendToStoredArray } from '@agoric/store/src/stores/store-utils.js'; +import { CctpTxEvidenceShape, PendingTxShape } from '../typeGuards.js'; +import { PendingTxStatus } from '../constants.js'; + /** + * @import {MapStore} from '@agoric/store'; * @import {Zone} from '@agoric/zone'; + * @import {CctpTxEvidence, NobleAddress, PendingTxKey, PendingTx} from '../types.js'; + */ + +/** + * Create the key for the pendingTxs MapStore. + * + * The key is a composite of `txHash` and `chainId` and not meant to be + * parsable. + * + * @param {NobleAddress} addr + * @param {bigint} amount + * @returns {PendingTxKey} */ +const makePendingTxKey = (addr, amount) => + `pendingTx:${JSON.stringify([addr, String(amount)])}`; + +/** + * Get the key for the pendingTxs MapStore. + * + * @param {CctpTxEvidence} evidence + * @returns {PendingTxKey} + */ +const pendingTxKeyOf = evidence => { + const { amount, forwardingAddress } = evidence.tx; + return makePendingTxKey(forwardingAddress, amount); +}; /** + * The `StatusManager` keeps track of Pending and Seen Transactions + * via {@link PendingTxStatus} states, aiding in coordination between the `Advancer` + * and `Settler`. + * + * XXX consider separate facets for `Advancing` and `Settling` capabilities. + * * @param {Zone} zone */ export const prepareStatusManager = zone => { - return zone.exo('Fast USDC Status Manager', undefined, {}); + /** @type {MapStore} */ + const pendingTxs = zone.mapStore('PendingTxs', { + keyShape: M.string(), + valueShape: M.arrayOf(PendingTxShape), + }); + + /** + * @param {CctpTxEvidence} evidence + * @param {PendingTxStatus} status + */ + const recordPendingTx = (evidence, status) => { + appendToStoredArray( + pendingTxs, + pendingTxKeyOf(evidence), + harden({ ...evidence, status }), + ); + }; + + return zone.exo( + 'Fast USDC Status Manager', + M.interface('StatusManagerI', { + advance: M.call(CctpTxEvidenceShape).returns(M.undefined()), + observe: M.call(CctpTxEvidenceShape).returns(M.undefined()), + hasPendingSettlement: M.call(M.string(), M.bigint()).returns(M.boolean()), + settle: M.call(M.string(), M.bigint()).returns(M.undefined()), + lookupPending: M.call(M.string(), M.bigint()).returns( + M.arrayOf(PendingTxShape), + ), + }), + { + /** + * Add a new transaction with ADVANCED status + * @param {CctpTxEvidence} evidence + */ + advance(evidence) { + recordPendingTx(evidence, PendingTxStatus.Advanced); + }, + + /** + * Add a new transaction with OBSERVED status + * @param {CctpTxEvidence} evidence + */ + observe(evidence) { + recordPendingTx(evidence, PendingTxStatus.Observed); + }, + + /** + * Find an `ADVANCED` or `OBSERVED` tx waiting to be `SETTLED` + * + * @param {NobleAddress} address + * @param {bigint} amount + * @returns {boolean} + */ + hasPendingSettlement(address, amount) { + const key = makePendingTxKey(address, amount); + const pending = pendingTxs.get(key); + return !!pending.length; + }, + + /** + * Mark an `ADVANCED` or `OBSERVED` transaction as `SETTLED` and remove it + * + * @param {NobleAddress} address + * @param {bigint} amount + */ + settle(address, amount) { + const key = makePendingTxKey(address, amount); + const pending = pendingTxs.get(key); + + if (!pending.length) { + throw makeError(`No unsettled entry for ${q(key)}`); + } + + const pendingCopy = [...pending]; + pendingCopy.shift(); + // TODO, vstorage update for `TxStatus.Settled` + pendingTxs.set(key, harden(pendingCopy)); + }, + + /** + * Lookup all pending entries for a given address and amount + * + * @param {NobleAddress} address + * @param {bigint} amount + * @returns {PendingTx[]} + */ + lookupPending(address, amount) { + const key = makePendingTxKey(address, amount); + if (!pendingTxs.has(key)) { + throw makeError(`Key ${q(key)} not yet observed`); + } + return pendingTxs.get(key); + }, + }, + ); }; harden(prepareStatusManager); diff --git a/packages/fast-usdc/src/fast-usdc.contract.js b/packages/fast-usdc/src/fast-usdc.contract.js index fb1197e4ae5..445073dcfb5 100644 --- a/packages/fast-usdc/src/fast-usdc.contract.js +++ b/packages/fast-usdc/src/fast-usdc.contract.js @@ -1,12 +1,14 @@ import { BrandShape } from '@agoric/ertp/src/typeGuards.js'; import { withOrchestration } from '@agoric/orchestration'; import { M } from '@endo/patterns'; -import { assertAllDefined } from '@agoric/internal'; +import { assertAllDefined, makeTracer } from '@agoric/internal'; import { prepareTransactionFeed } from './exos/transaction-feed.js'; import { prepareSettler } from './exos/settler.js'; import { prepareAdvancer } from './exos/advancer.js'; import { prepareStatusManager } from './exos/status-manager.js'; +const trace = makeTracer('FastUsdc'); + /** * @import {OrchestrationPowers, OrchestrationTools} from '@agoric/orchestration/src/utils/start-helper.js'; * @import {Zone} from '@agoric/zone'; @@ -43,9 +45,16 @@ export const contract = async (zcf, privateArgs, zone, tools) => { const statusManager = prepareStatusManager(zone); const feed = prepareTransactionFeed(zone); - const settler = prepareSettler(zone, { statusManager }); - const advancer = prepareAdvancer(zone, { feed, statusManager }); - assertAllDefined({ feed, settler, advancer, statusManager }); + const makeSettler = prepareSettler(zone, { statusManager }); + const { chainHub, vowTools } = tools; + const makeAdvancer = prepareAdvancer(zone, { + chainHub, + feed, + log: trace, + statusManager, + vowTools, + }); + assertAllDefined({ feed, makeAdvancer, makeSettler, statusManager }); const creatorFacet = zone.exo('Fast USDC Creator', undefined, {}); diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index 9770ace414e..efcdeaa7f4f 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -23,6 +23,8 @@ export interface CctpTxEvidence { txHash: EvmHash; } +export type LogFn = (...args: unknown[]) => void; + export interface PendingTx extends CctpTxEvidence { status: PendingTxStatus; } diff --git a/packages/fast-usdc/test/exos/advancer.test.ts b/packages/fast-usdc/test/exos/advancer.test.ts new file mode 100644 index 00000000000..c285a46c6d4 --- /dev/null +++ b/packages/fast-usdc/test/exos/advancer.test.ts @@ -0,0 +1,184 @@ +import type { TestFn } from 'ava'; +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import { denomHash, type Denom } from '@agoric/orchestration'; +import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; +import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js'; +import type { Zone } from '@agoric/zone'; +import type { VowTools } from '@agoric/vow'; +import { prepareAdvancer } from '../../src/exos/advancer.js'; +import { prepareStatusManager } from '../../src/exos/status-manager.js'; +import { prepareTransactionFeed } from '../../src/exos/transaction-feed.js'; + +import { commonSetup } from '../supports.js'; +import { MockCctpTxEvidences } from '../fixtures.js'; +import { + makeTestLogger, + prepareMockOrchAccounts, + type TestLogger, +} from '../mocks.js'; +import { PendingTxStatus } from '../../src/constants.js'; + +const test = anyTest as TestFn<{ + localDenom: Denom; + makeAdvancer: ReturnType; + rootZone: Zone; + statusManager: ReturnType; + vowTools: VowTools; + inspectLogs: TestLogger['inspectLogs']; +}>; + +test.beforeEach(async t => { + const common = await commonSetup(t); + const { + bootstrap: { rootZone, vowTools }, + facadeServices: { chainHub }, + } = common; + + const { log, inspectLogs } = makeTestLogger(t.log); + + chainHub.registerChain('dydx', fetchedChainInfo.dydx); + chainHub.registerChain('osmosis', fetchedChainInfo.osmosis); + + const statusManager = prepareStatusManager( + rootZone.subZone('status-manager'), + ); + const feed = prepareTransactionFeed(rootZone.subZone('feed')); + const makeAdvancer = prepareAdvancer(rootZone.subZone('advancer'), { + chainHub, + feed, + statusManager, + vowTools, + log, + }); + const localDenom = `ibc/${denomHash({ + denom: 'uusdc', + channelId: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel.channelId, + })}`; + + t.context = { + localDenom, + makeAdvancer, + rootZone, + statusManager, + vowTools, + inspectLogs, + }; +}); + +test('advancer updated status to ADVANCED', async t => { + const { + inspectLogs, + localDenom, + makeAdvancer, + statusManager, + rootZone, + vowTools, + } = t.context; + + const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( + rootZone.subZone('poolAcct'), + { vowTools, log: t.log }, + ); + + const advancer = makeAdvancer({ + poolAccount, + localDenom, + }); + t.truthy(advancer, 'advancer instantiates'); + + // simulate input from EventFeed + const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + advancer.handleTransactionEvent(mockCttpTxEvidence); + t.log('Simulate advance `.transfer()` vow fulfillment'); + poolAccountTransferVResolver.resolve(); + await eventLoopIteration(); // wait for StatusManager to receive update + const entries = statusManager.lookupPending( + mockCttpTxEvidence.tx.forwardingAddress, + mockCttpTxEvidence.tx.amount, + ); + t.deepEqual( + entries, + [{ ...mockCttpTxEvidence, status: PendingTxStatus.Advanced }], + 'tx status updated to ADVANCED', + ); + + t.deepEqual( + inspectLogs(0), + [ + 'Advance transfer fulfilled', + '{"amount":"[150000000n]","destination":{"chainId":"osmosis-1","encoding":"bech32","value":"osmo183dejcnmkka5dzcu9xw6mywq0p2m5peks28men"},"result":"[undefined]"}', + ], + 'contract logs advance', + ); +}); + +test('advancer does not update status on failed transfer', async t => { + const { + inspectLogs, + localDenom, + makeAdvancer, + statusManager, + rootZone, + vowTools, + } = t.context; + + const { poolAccount, poolAccountTransferVResolver } = prepareMockOrchAccounts( + rootZone.subZone('poolAcct2'), + { vowTools, log: t.log }, + ); + + const advancer = makeAdvancer({ poolAccount, localDenom }); + t.truthy(advancer, 'advancer instantiates'); + + // simulate input from EventFeed + const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_DYDX(); + advancer.handleTransactionEvent(mockCttpTxEvidence); + t.log('Simulate advance `.transfer()` vow rejection'); + poolAccountTransferVResolver.reject(new Error('simulated error')); + await eventLoopIteration(); // wait for StatusManager to receive update + const entries = statusManager.lookupPending( + mockCttpTxEvidence.tx.forwardingAddress, + mockCttpTxEvidence.tx.amount, + ); + t.deepEqual( + entries, + [{ ...mockCttpTxEvidence, status: PendingTxStatus.Advanced }], + 'tx status is still Advanced even though advance failed', + ); + t.deepEqual(inspectLogs(0), [ + 'Advance transfer rejected', + '"[Error: simulated error]"', + ]); +}); + +test('advancer updated status to OBSERVED if pre-condition checks fail', async t => { + const { localDenom, makeAdvancer, statusManager, rootZone, vowTools } = + t.context; + + const { poolAccount } = prepareMockOrchAccounts( + rootZone.subZone('poolAcct2'), + { vowTools, log: t.log }, + ); + + const advancer = makeAdvancer({ poolAccount, localDenom }); + t.truthy(advancer, 'advancer instantiates'); + + // simulate input from EventFeed + const mockCttpTxEvidence = MockCctpTxEvidences.AGORIC_NO_PARAMS(); + t.throws(() => advancer.handleTransactionEvent(mockCttpTxEvidence), { + message: + 'recipientAddress does not contain EUD param: "agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek"', + }); + + const entries = statusManager.lookupPending( + mockCttpTxEvidence.tx.forwardingAddress, + mockCttpTxEvidence.tx.amount, + ); + t.deepEqual( + entries, + [{ ...mockCttpTxEvidence, status: PendingTxStatus.Observed }], + 'tx status is still OBSERVED', + ); +}); diff --git a/packages/fast-usdc/test/exos/settler.test.ts b/packages/fast-usdc/test/exos/settler.test.ts new file mode 100644 index 00000000000..e8ff695f0b1 --- /dev/null +++ b/packages/fast-usdc/test/exos/settler.test.ts @@ -0,0 +1,99 @@ +import type { TestFn } from 'ava'; +import { test as anyTest } from '@agoric/zoe/tools/prepare-test-env-ava.js'; + +import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; +import type { IBCChannelID } from '@agoric/vats'; +import type { Denom } from '@agoric/orchestration'; +import { PendingTxStatus } from '../../src/constants.js'; +import { prepareStatusManager } from '../../src/exos/status-manager.js'; +import { prepareSettler } from '../../src/exos/settler.js'; + +import { provideDurableZone } from '../supports.js'; +import { MockCctpTxEvidences, MockVTransferEvents } from '../fixtures.js'; +import type { CctpTxEvidence } from '../../src/types.js'; + +const test = anyTest as TestFn<{ + makeSettler: ReturnType; + statusManager: ReturnType; + defaultSettlerParams: { + sourceChannel: IBCChannelID; + remoteDenom: Denom; + }; + simulateAdvance: (evidence?: CctpTxEvidence) => CctpTxEvidence; +}>; + +test.before(t => { + const zone = provideDurableZone('settler-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + const makeSettler = prepareSettler(zone.subZone('settler'), { + statusManager, + }); + + const defaultSettlerParams = harden({ + sourceChannel: + fetchedChainInfo.agoric.connections['noble-1'].transferChannel + .counterPartyChannelId, + remoteDenom: 'uusdc', + }); + + const simulateAdvance = (evidence: CctpTxEvidence) => { + const cctpTxEvidence: CctpTxEvidence = { + ...MockCctpTxEvidences.AGORIC_PLUS_OSMO(), + ...evidence, + }; + t.log('Mock CCTP Evidence:', cctpTxEvidence); + t.log('Pretend we initiated advance, mark as `ADVANCED`'); + statusManager.advance(cctpTxEvidence); + + return cctpTxEvidence; + }; + + t.context = { + makeSettler, + statusManager, + defaultSettlerParams, + simulateAdvance, + }; +}); + +test('StatusManger gets `SETTLED` update in happy path', async t => { + const { makeSettler, statusManager, defaultSettlerParams, simulateAdvance } = + t.context; + const settler = makeSettler(defaultSettlerParams); + + const cctpTxEvidence = simulateAdvance(); + t.deepEqual( + statusManager.lookupPending( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [ + { + ...cctpTxEvidence, + status: PendingTxStatus.Advanced, + }, + ], + ); + + t.log('Simulate incoming IBC settlement'); + void settler.receiveUpcall(MockVTransferEvents.AGORIC_PLUS_OSMO()); + + t.log('TODO test funds settled in right places'); + // TODO, test settlement of funds + + t.deepEqual( + statusManager.lookupPending( + cctpTxEvidence.tx.forwardingAddress, + cctpTxEvidence.tx.amount, + ), + [], + 'SETTLED entry removed from StatusManger', + ); + // TODO, confirm vstorage write for TxStatus.SETTLED +}); + +test.todo("StatusManager does not receive update when we can't settle"); + +test.todo('settler disperses funds'); + +test.todo('Observed -> Settle flow'); diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts new file mode 100644 index 00000000000..21c67b359a1 --- /dev/null +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -0,0 +1,157 @@ +import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js'; +import { PendingTxStatus } from '../../src/constants.js'; +import { prepareStatusManager } from '../../src/exos/status-manager.js'; +import { provideDurableZone } from '../supports.js'; +import { MockCctpTxEvidences } from '../fixtures.js'; +import type { CctpTxEvidence } from '../../src/types.js'; + +test('advance creates new entry with ADVANCED status', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.advance(evidence); + + const entries = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries[0]?.status, PendingTxStatus.Advanced); +}); + +test('observe creates new entry with OBSERVED status', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.observe(evidence); + + const entries = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries[0]?.status, PendingTxStatus.Observed); +}); + +test('settle removes entries from PendingTxs', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.advance(evidence); + statusManager.observe({ ...evidence, txHash: '0xtest1' }); + + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + + const entries = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + t.is(entries.length, 0, 'Settled entry should be deleted'); +}); + +test('cannot SETTLE without an ADVANCED or OBSERVED entry', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + + t.throws( + () => + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount), + { + message: + 'key "pendingTx:[\\"noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd\\",\\"150000000\\"]" not found in collection "PendingTxs"', + }, + ); +}); + +test('settle SETTLES first matched entry', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + + // advance two + statusManager.advance(evidence); + statusManager.advance({ ...evidence, txHash: '0xtest2' }); + // also settles OBSERVED statuses + statusManager.observe({ ...evidence, txHash: '0xtest3' }); + + // settle will settle the first match + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + const entries0 = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + t.is(entries0.length, 2); + // TODO, check vstorage for PendingTxStatus.Settled for 1st tx + t.is( + entries0?.[0].status, + PendingTxStatus.Advanced, + 'first settled entry deleted', + ); + t.is( + entries0?.[1].status, + PendingTxStatus.Observed, + 'order of remaining entries preserved', + ); + + // settle again wih same args settles 2nd advance + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + // settle again wih same args settles remaining observe + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + const entries1 = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + // TODO, check vstorage for TxStatus.Settled + t.is(entries1?.length, 0, 'settled entries are deleted'); + + t.throws( + () => + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount), + { + message: + 'No unsettled entry for "pendingTx:[\\"noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelqkd\\",\\"150000000\\"]"', + }, + 'No more matches to settle', + ); +}); + +test('lookup throws when presented a key it has not seen', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + t.throws(() => statusManager.lookupPending('noble123', 1n), { + message: 'Key "pendingTx:[\\"noble123\\",\\"1\\"]" not yet observed', + }); +}); + +test('StatusManagerKey logic handles addresses with hyphens', async t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence: CctpTxEvidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + evidence.tx.forwardingAddress = 'noble1-foo'; + + statusManager.advance(evidence); + + const entries = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + + t.is(entries.length, 1); + t.is(entries[0]?.status, PendingTxStatus.Advanced); + + statusManager.settle(evidence.tx.forwardingAddress, evidence.tx.amount); + const remainingEntries = statusManager.lookupPending( + evidence.tx.forwardingAddress, + evidence.tx.amount, + ); + t.is(remainingEntries.length, 0, 'Entry should be settled'); +}); diff --git a/packages/fast-usdc/test/fixtures.ts b/packages/fast-usdc/test/fixtures.ts index 138e137930f..69363c988c7 100644 --- a/packages/fast-usdc/test/fixtures.ts +++ b/packages/fast-usdc/test/fixtures.ts @@ -3,7 +3,11 @@ import { buildVTransferEvent } from '@agoric/orchestration/tools/ibc-mocks.js'; import fetchedChainInfo from '@agoric/orchestration/src/fetched-chain-info.js'; import type { CctpTxEvidence } from '../src/types.js'; -const mockScenarios = ['AGORIC_PLUS_OSMO', 'AGORIC_PLUS_DYDX'] as const; +const mockScenarios = [ + 'AGORIC_PLUS_OSMO', + 'AGORIC_PLUS_DYDX', + 'AGORIC_NO_PARAMS', +] as const; type MockScenario = (typeof mockScenarios)[number]; @@ -49,6 +53,25 @@ export const MockCctpTxEvidences: Record< }, chainId: 1, }), + AGORIC_NO_PARAMS: (receiverAddress?: string) => ({ + blockHash: + '0x70d7343e04f8160892e94f02d6a9b9f255663ed0ac34caca98544c8143fee699', + blockNumber: 21037669n, + blockTimestamp: 1730762099n, + txHash: + '0xa81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761799', + tx: { + amount: 200000000n, + forwardingAddress: 'noble1x0ydg69dh6fqvr27xjvp6maqmrldam6yfelyyy', + }, + aux: { + forwardingChannel: 'channel-21', + recipientAddress: + receiverAddress || + 'agoric16kv2g7snfc4q24vg3pjdlnnqgngtjpwtetd2h689nz09lcklvh5s8u37ek', + }, + chainId: 1, + }), }; const nobleDefaultVTransferParams = { @@ -83,4 +106,13 @@ export const MockVTransferEvents: Record< recieverAddress || MockCctpTxEvidences.AGORIC_PLUS_DYDX().aux.recipientAddress, }), + AGORIC_NO_PARAMS: (recieverAddress?: string) => + buildVTransferEvent({ + ...nobleDefaultVTransferParams, + amount: MockCctpTxEvidences.AGORIC_NO_PARAMS().tx.amount, + sender: MockCctpTxEvidences.AGORIC_NO_PARAMS().tx.forwardingAddress, + receiver: + recieverAddress || + MockCctpTxEvidences.AGORIC_NO_PARAMS().aux.recipientAddress, + }), }; diff --git a/packages/fast-usdc/test/mocks.ts b/packages/fast-usdc/test/mocks.ts new file mode 100644 index 00000000000..ba2ba51d372 --- /dev/null +++ b/packages/fast-usdc/test/mocks.ts @@ -0,0 +1,51 @@ +import type { + ChainAddress, + DenomAmount, + OrchestrationAccount, +} from '@agoric/orchestration'; +import type { Zone } from '@agoric/zone'; +import type { VowTools } from '@agoric/vow'; +import type { HostInterface } from '@agoric/async-flow'; +import type { LogFn } from '../src/types.js'; + +export const prepareMockOrchAccounts = ( + zone: Zone, + { + vowTools: { makeVowKit }, + log, + }: { vowTools: VowTools; log: (...args: any[]) => void }, +) => { + // can only be called once per test + const poolAccountTransferVK = makeVowKit(); + + const mockedPoolAccount = zone.exo('Pool LocalOrchAccount', undefined, { + transfer(destination: ChainAddress, amount: DenomAmount) { + log('PoolAccount.transfer() called with', destination, amount); + return poolAccountTransferVK.vow; + }, + }); + + const poolAccount = mockedPoolAccount as unknown as HostInterface< + OrchestrationAccount<{ + chainId: 'agoric'; + }> + >; + + return { + poolAccount, + poolAccountTransferVResolver: poolAccountTransferVK.resolver, + }; +}; + +export const makeTestLogger = (logger: LogFn) => { + const logs: unknown[][] = []; + const log = (...args: any[]) => { + logs.push(args); + logger(args); + }; + const inspectLogs = (index?: number) => + typeof index === 'number' ? logs[index] : logs; + return { log, inspectLogs }; +}; + +export type TestLogger = ReturnType; From f3d1e367ce2284338147866af586bed8ed9fc86b Mon Sep 17 00:00:00 2001 From: 0xPatrick Date: Wed, 6 Nov 2024 20:45:00 -0500 Subject: [PATCH 9/9] feat: `StatusManager` tracks `seenTxs` - use a composite key of `txHash+chainId` to track unique `EventFeed` submissions --- packages/fast-usdc/src/exos/status-manager.js | 34 +++++++++++++++++-- packages/fast-usdc/src/types.ts | 3 ++ .../test/exos/status-manager.test.ts | 23 +++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/fast-usdc/src/exos/status-manager.js b/packages/fast-usdc/src/exos/status-manager.js index 152a534a0d2..15c82f93a22 100644 --- a/packages/fast-usdc/src/exos/status-manager.js +++ b/packages/fast-usdc/src/exos/status-manager.js @@ -6,9 +6,9 @@ import { CctpTxEvidenceShape, PendingTxShape } from '../typeGuards.js'; import { PendingTxStatus } from '../constants.js'; /** - * @import {MapStore} from '@agoric/store'; + * @import {MapStore, SetStore} from '@agoric/store'; * @import {Zone} from '@agoric/zone'; - * @import {CctpTxEvidence, NobleAddress, PendingTxKey, PendingTx} from '../types.js'; + * @import {CctpTxEvidence, NobleAddress, SeenTxKey, PendingTxKey, PendingTx} from '../types.js'; */ /** @@ -35,6 +35,20 @@ const pendingTxKeyOf = evidence => { return makePendingTxKey(forwardingAddress, amount); }; +/** + * Get the key for the seenTxs SetStore. + * + * The key is a composite of `NobleAddress` and transaction `amount` and not + * meant to be parsable. + * + * @param {CctpTxEvidence} evidence + * @returns {SeenTxKey} + */ +const seenTxKeyOf = evidence => { + const { txHash, chainId } = evidence; + return `seenTx:${JSON.stringify([txHash, chainId])}`; +}; + /** * The `StatusManager` keeps track of Pending and Seen Transactions * via {@link PendingTxStatus} states, aiding in coordination between the `Advancer` @@ -51,11 +65,27 @@ export const prepareStatusManager = zone => { valueShape: M.arrayOf(PendingTxShape), }); + /** @type {SetStore} */ + const seenTxs = zone.setStore('SeenTxs', { + keyShape: M.string(), + }); + /** + * Ensures that `txHash+chainId` has not been processed + * and adds entry to `seenTxs` set. + * + * Also records the CctpTxEvidence and status in `pendingTxs`. + * * @param {CctpTxEvidence} evidence * @param {PendingTxStatus} status */ const recordPendingTx = (evidence, status) => { + const seenKey = seenTxKeyOf(evidence); + if (seenTxs.has(seenKey)) { + throw makeError(`Transaction already seen: ${q(seenKey)}`); + } + seenTxs.add(seenKey); + appendToStoredArray( pendingTxs, pendingTxKeyOf(evidence), diff --git a/packages/fast-usdc/src/types.ts b/packages/fast-usdc/src/types.ts index efcdeaa7f4f..ed6249f2ff4 100644 --- a/packages/fast-usdc/src/types.ts +++ b/packages/fast-usdc/src/types.ts @@ -32,4 +32,7 @@ export interface PendingTx extends CctpTxEvidence { /** internal key for `StatusManager` exo */ export type PendingTxKey = `pendingTx:${string}`; +/** internal key for `StatusManager` exo */ +export type SeenTxKey = `seenTx:${string}`; + export type * from './constants.js'; diff --git a/packages/fast-usdc/test/exos/status-manager.test.ts b/packages/fast-usdc/test/exos/status-manager.test.ts index 21c67b359a1..ee892fee00d 100644 --- a/packages/fast-usdc/test/exos/status-manager.test.ts +++ b/packages/fast-usdc/test/exos/status-manager.test.ts @@ -35,6 +35,29 @@ test('observe creates new entry with OBSERVED status', t => { t.is(entries[0]?.status, PendingTxStatus.Observed); }); +test('cannot process same tx twice', t => { + const zone = provideDurableZone('status-test'); + const statusManager = prepareStatusManager(zone.subZone('status-manager')); + + const evidence = MockCctpTxEvidences.AGORIC_PLUS_OSMO(); + statusManager.advance(evidence); + + t.throws(() => statusManager.advance(evidence), { + message: + 'Transaction already seen: "seenTx:[\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\",1]"', + }); + + t.throws(() => statusManager.observe(evidence), { + message: + 'Transaction already seen: "seenTx:[\\"0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702\\",1]"', + }); + + // new txHash should not throw + t.notThrows(() => statusManager.advance({ ...evidence, txHash: '0xtest2' })); + // new chainId with existing txHash should not throw + t.notThrows(() => statusManager.advance({ ...evidence, chainId: 9999 })); +}); + test('settle removes entries from PendingTxs', t => { const zone = provideDurableZone('status-test'); const statusManager = prepareStatusManager(zone.subZone('status-manager'));