@agoric/fast-usdc 0.2.0-u18a.0 → 0.2.0-u19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,20 +8,72 @@ import { M } from '@endo/patterns';
8
8
  import { decodeAddressHook } from '@agoric/cosmic-proto/address-hooks.js';
9
9
  import { PendingTxStatus } from '../constants.js';
10
10
  import { makeFeeTools } from '../utils/fees.js';
11
- import { EvmHashShape } from '../type-guards.js';
11
+ import {
12
+ CctpTxEvidenceShape,
13
+ EvmHashShape,
14
+ makeNatAmountShape,
15
+ } from '../type-guards.js';
12
16
 
13
17
  /**
14
18
  * @import {FungibleTokenPacketData} from '@agoric/cosmic-proto/ibc/applications/transfer/v2/packet.js';
19
+ * @import {Amount, Brand, NatValue, Payment} from '@agoric/ertp';
15
20
  * @import {Denom, OrchestrationAccount, ChainHub, ChainAddress} from '@agoric/orchestration';
16
21
  * @import {WithdrawToSeat} from '@agoric/orchestration/src/utils/zoe-tools'
17
- * @import {IBCChannelID, VTransferIBCEvent} from '@agoric/vats';
22
+ * @import {IBCChannelID, IBCPacket, VTransferIBCEvent} from '@agoric/vats';
18
23
  * @import {Zone} from '@agoric/zone';
19
24
  * @import {HostOf, HostInterface} from '@agoric/async-flow';
20
25
  * @import {TargetRegistration} from '@agoric/vats/src/bridge-target.js';
21
- * @import {NobleAddress, LiquidityPoolKit, FeeConfig, EvmHash, LogFn} from '../types.js';
26
+ * @import {NobleAddress, LiquidityPoolKit, FeeConfig, EvmHash, LogFn, CctpTxEvidence} from '../types.js';
22
27
  * @import {StatusManager} from './status-manager.js';
23
28
  */
24
29
 
30
+ /**
31
+ * @param {IBCPacket} data
32
+ * @param {string} remoteDenom
33
+ * @returns {{ nfa: NobleAddress, amount: bigint, EUD: string } | {error: object[]}}
34
+ */
35
+ const decodeEventPacket = ({ data }, remoteDenom) => {
36
+ // NB: may not be a FungibleTokenPacketData or even JSON
37
+ /** @type {FungibleTokenPacketData} */
38
+ let tx;
39
+ try {
40
+ tx = JSON.parse(atob(data));
41
+ } catch (e) {
42
+ return { error: ['could not parse packet data', data] };
43
+ }
44
+
45
+ // given the sourceChannel check, we can be certain of this cast
46
+ const nfa = /** @type {NobleAddress} */ (tx.sender);
47
+
48
+ if (tx.denom !== remoteDenom) {
49
+ const { denom: actual } = tx;
50
+ return { error: ['unexpected denom', { actual, expected: remoteDenom }] };
51
+ }
52
+
53
+ let EUD;
54
+ try {
55
+ ({ EUD } = decodeAddressHook(tx.receiver).query);
56
+ if (!EUD) {
57
+ return { error: ['no EUD parameter', tx.receiver] };
58
+ }
59
+ if (typeof EUD !== 'string') {
60
+ return { error: ['EUD is not a string', EUD] };
61
+ }
62
+ } catch (e) {
63
+ return { error: ['no query params', tx.receiver] };
64
+ }
65
+
66
+ let amount;
67
+ try {
68
+ amount = BigInt(tx.amount);
69
+ } catch (e) {
70
+ return { error: ['invalid amount', tx.amount] };
71
+ }
72
+
73
+ return { nfa, amount, EUD };
74
+ };
75
+ harden(decodeEventPacket);
76
+
25
77
  /**
26
78
  * NOTE: not meant to be parsable.
27
79
  *
@@ -31,6 +83,25 @@ import { EvmHashShape } from '../type-guards.js';
31
83
  const makeMintedEarlyKey = (addr, amount) =>
32
84
  `pendingTx:${JSON.stringify([addr, String(amount)])}`;
33
85
 
86
+ /** @param {Brand<'nat'>} USDC */
87
+ export const makeAdvanceDetailsShape = USDC =>
88
+ harden({
89
+ destination: ChainAddressShape,
90
+ forwardingAddress: M.string(),
91
+ fullAmount: makeNatAmountShape(USDC),
92
+ txHash: EvmHashShape,
93
+ });
94
+
95
+ export const stateShape = harden({
96
+ repayer: M.remotable('Repayer'),
97
+ settlementAccount: M.remotable('Account'),
98
+ registration: M.or(M.undefined(), M.remotable('Registration')),
99
+ sourceChannel: M.string(),
100
+ remoteDenom: M.string(),
101
+ mintedEarly: M.remotable('mintedEarly'),
102
+ intermediateRecipient: M.opt(ChainAddressShape),
103
+ });
104
+
34
105
  /**
35
106
  * @param {Zone} zone
36
107
  * @param {object} caps
@@ -67,26 +138,23 @@ export const prepareSettler = (
67
138
  tap: M.interface('SettlerTapI', {
68
139
  receiveUpcall: M.call(M.record()).returns(M.promise()),
69
140
  }),
70
- notify: M.interface('SettlerNotifyI', {
141
+ notifier: M.interface('SettlerNotifyI', {
71
142
  notifyAdvancingResult: M.call(
72
- M.record(), // XXX fill in details TODO
143
+ makeAdvanceDetailsShape(USDC),
73
144
  M.boolean(),
74
145
  ).returns(),
146
+ checkMintedEarly: M.call(
147
+ CctpTxEvidenceShape,
148
+ ChainAddressShape,
149
+ ).returns(M.boolean()),
75
150
  }),
76
151
  self: M.interface('SettlerSelfI', {
77
- disburse: M.call(EvmHashShape, M.string(), M.nat()).returns(
78
- M.promise(),
79
- ),
80
- forward: M.call(
81
- M.opt(EvmHashShape),
82
- M.string(),
83
- M.nat(),
84
- M.string(),
85
- ).returns(),
152
+ disburse: M.call(EvmHashShape, M.nat()).returns(M.promise()),
153
+ forward: M.call(EvmHashShape, M.nat(), M.string()).returns(),
86
154
  }),
87
155
  transferHandler: M.interface('SettlerTransferI', {
88
- onFulfilled: M.call(M.any(), M.record()).returns(),
89
- onRejected: M.call(M.any(), M.record()).returns(),
156
+ onFulfilled: M.call(M.undefined(), M.string()).returns(),
157
+ onRejected: M.call(M.error(), M.string()).returns(),
90
158
  }),
91
159
  },
92
160
  /**
@@ -137,61 +205,40 @@ export const prepareSettler = (
137
205
  return;
138
206
  }
139
207
 
140
- // TODO: why is it safe to cast this without a runtime check?
141
- const tx = /** @type {FungibleTokenPacketData} */ (
142
- JSON.parse(atob(packet.data))
143
- );
144
-
145
- // given the sourceChannel check, we can be certain of this cast
146
- const nfa = /** @type {NobleAddress} */ (tx.sender);
147
-
148
- if (tx.denom !== remoteDenom) {
149
- const { denom: actual } = tx;
150
- log('unexpected denom', { actual, expected: remoteDenom });
151
- return;
152
- }
153
-
154
- let EUD;
155
- try {
156
- ({ EUD } = decodeAddressHook(tx.receiver).query);
157
- if (!EUD) {
158
- log('no EUD parameter', tx.receiver);
159
- return;
160
- }
161
- if (typeof EUD !== 'string') {
162
- log('EUD is not a string', EUD);
163
- return;
164
- }
165
- } catch (e) {
166
- log('no query params', tx.receiver);
208
+ const decoded = decodeEventPacket(event.packet, remoteDenom);
209
+ if ('error' in decoded) {
210
+ log('invalid event packet', decoded.error);
167
211
  return;
168
212
  }
169
213
 
170
- const amount = BigInt(tx.amount); // TODO: what if this throws?
171
-
214
+ const { nfa, amount, EUD } = decoded;
172
215
  const { self } = this.facets;
173
216
  const found = statusManager.dequeueStatus(nfa, amount);
174
217
  log('dequeued', found, 'for', nfa, amount);
175
218
  switch (found?.status) {
176
219
  case PendingTxStatus.Advanced:
177
- return self.disburse(found.txHash, nfa, amount);
220
+ return self.disburse(found.txHash, amount);
178
221
 
179
222
  case PendingTxStatus.Advancing:
223
+ log('⚠️ tap: minted while advancing', nfa, amount);
180
224
  this.state.mintedEarly.add(makeMintedEarlyKey(nfa, amount));
181
225
  return;
182
226
 
183
227
  case PendingTxStatus.Observed:
184
228
  case PendingTxStatus.AdvanceSkipped:
185
229
  case PendingTxStatus.AdvanceFailed:
186
- return self.forward(found.txHash, nfa, amount, EUD);
230
+ return self.forward(found.txHash, amount, EUD);
187
231
 
188
232
  case undefined:
189
233
  default:
190
- log('⚠️ tap: no status for ', nfa, amount);
234
+ log('⚠️ tap: minted before observed', nfa, amount);
235
+ // XXX consider capturing in vstorage
236
+ // we would need a new key, as this does not have a txHash
237
+ this.state.mintedEarly.add(makeMintedEarlyKey(nfa, amount));
191
238
  }
192
239
  },
193
240
  },
194
- notify: {
241
+ notifier: {
195
242
  /**
196
243
  * @param {object} ctx
197
244
  * @param {EvmHash} ctx.txHash
@@ -210,16 +257,12 @@ export const prepareSettler = (
210
257
  const key = makeMintedEarlyKey(forwardingAddress, fullValue);
211
258
  if (mintedEarly.has(key)) {
212
259
  mintedEarly.delete(key);
260
+ statusManager.advanceOutcomeForMintedEarly(txHash, success);
213
261
  if (success) {
214
- void this.facets.self.disburse(
215
- txHash,
216
- forwardingAddress,
217
- fullValue,
218
- );
262
+ void this.facets.self.disburse(txHash, fullValue);
219
263
  } else {
220
264
  void this.facets.self.forward(
221
265
  txHash,
222
- forwardingAddress,
223
266
  fullValue,
224
267
  destination.value,
225
268
  );
@@ -228,14 +271,39 @@ export const prepareSettler = (
228
271
  statusManager.advanceOutcome(forwardingAddress, fullValue, success);
229
272
  }
230
273
  },
274
+ /**
275
+ * @param {CctpTxEvidence} evidence
276
+ * @param {ChainAddress} destination
277
+ * @returns {boolean}
278
+ * @throws {Error} if minted early, so advancer doesn't advance
279
+ */
280
+ checkMintedEarly(evidence, destination) {
281
+ const {
282
+ tx: { forwardingAddress, amount },
283
+ txHash,
284
+ } = evidence;
285
+ const key = makeMintedEarlyKey(forwardingAddress, amount);
286
+ const { mintedEarly } = this.state;
287
+ if (mintedEarly.has(key)) {
288
+ log(
289
+ 'matched minted early key, initiating forward',
290
+ forwardingAddress,
291
+ amount,
292
+ );
293
+ mintedEarly.delete(key);
294
+ statusManager.advanceOutcomeForUnknownMint(evidence);
295
+ void this.facets.self.forward(txHash, amount, destination.value);
296
+ return true;
297
+ }
298
+ return false;
299
+ },
231
300
  },
232
301
  self: {
233
302
  /**
234
303
  * @param {EvmHash} txHash
235
- * @param {NobleAddress} nfa
236
304
  * @param {NatValue} fullValue
237
305
  */
238
- async disburse(txHash, nfa, fullValue) {
306
+ async disburse(txHash, fullValue) {
239
307
  const { repayer, settlementAccount } = this.state;
240
308
  const received = AmountMath.make(USDC, fullValue);
241
309
  const { zcfSeat: settlingSeat } = zcf.makeEmptySeatKit();
@@ -259,76 +327,67 @@ export const prepareSettler = (
259
327
  harden([[settlingSeat, settlingSeat, { In: received }, split]]),
260
328
  );
261
329
  repayer.repay(settlingSeat, split);
330
+ settlingSeat.exit();
262
331
 
263
- // update status manager, marking tx `SETTLED`
332
+ // update status manager, marking tx `DISBURSED`
264
333
  statusManager.disbursed(txHash, split);
265
334
  },
266
335
  /**
267
336
  * @param {EvmHash} txHash
268
- * @param {NobleAddress} nfa
269
337
  * @param {NatValue} fullValue
270
338
  * @param {string} EUD
271
339
  */
272
- forward(txHash, nfa, fullValue, EUD) {
340
+ forward(txHash, fullValue, EUD) {
273
341
  const { settlementAccount, intermediateRecipient } = this.state;
274
342
 
275
- const dest = chainHub.makeChainAddress(EUD);
343
+ const dest = (() => {
344
+ try {
345
+ return chainHub.makeChainAddress(EUD);
346
+ } catch (e) {
347
+ log('⚠️ forward transfer failed!', e, txHash);
348
+ statusManager.forwarded(txHash, false);
349
+ return null;
350
+ }
351
+ })();
352
+ if (!dest) return;
276
353
 
277
- // TODO? statusManager.forwarding(txHash, sender, amount);
278
354
  const txfrV = E(settlementAccount).transfer(
279
355
  dest,
280
356
  AmountMath.make(USDC, fullValue),
281
357
  { forwardOpts: { intermediateRecipient } },
282
358
  );
283
- void vowTools.watch(txfrV, this.facets.transferHandler, {
284
- txHash,
285
- nfa,
286
- fullValue,
287
- });
359
+ void vowTools.watch(txfrV, this.facets.transferHandler, txHash);
288
360
  },
289
361
  },
290
362
  transferHandler: {
291
363
  /**
292
364
  * @param {unknown} _result
293
- * @param {SettlerTransferCtx} ctx
294
- *
295
- * @typedef {{
296
- * txHash: EvmHash;
297
- * nfa: NobleAddress;
298
- * fullValue: NatValue;
299
- * }} SettlerTransferCtx
365
+ * @param {EvmHash} txHash
300
366
  */
301
- onFulfilled(_result, ctx) {
302
- const { txHash, nfa, fullValue } = ctx;
303
- statusManager.forwarded(txHash, nfa, fullValue);
367
+ onFulfilled(_result, txHash) {
368
+ // update status manager, marking tx `FORWARDED` without fee split
369
+ statusManager.forwarded(txHash, true);
304
370
  },
305
371
  /**
306
372
  * @param {unknown} reason
307
- * @param {SettlerTransferCtx} ctx
373
+ * @param {EvmHash} txHash
308
374
  */
309
- onRejected(reason, ctx) {
310
- log('⚠️ transfer rejected!', reason, ctx);
311
- // const { txHash, nfa, amount } = ctx;
312
- // TODO(#10510): statusManager.forwardFailed(txHash, nfa, amount);
375
+ onRejected(reason, txHash) {
376
+ // funds remain in `settlementAccount` and must be recovered via a
377
+ // contract upgrade
378
+ log('🚨 forward transfer rejected!', reason, txHash);
379
+ // update status manager, flagging a terminal state that needs to be
380
+ // manual intervention or a code update to remediate
381
+ statusManager.forwarded(txHash, false);
313
382
  },
314
383
  },
315
384
  },
316
385
  {
317
- stateShape: harden({
318
- repayer: M.remotable('Repayer'),
319
- settlementAccount: M.remotable('Account'),
320
- registration: M.or(M.undefined(), M.remotable('Registration')),
321
- sourceChannel: M.string(),
322
- remoteDenom: M.string(),
323
- mintedEarly: M.remotable('mintedEarly'),
324
- intermediateRecipient: M.opt(ChainAddressShape),
325
- }),
386
+ stateShape,
326
387
  },
327
388
  );
328
389
  };
329
390
  harden(prepareSettler);
330
391
 
331
- /**
332
- * XXX consider using pickFacet (do we have pickFacets?)
333
- * @typedef {ReturnType<ReturnType<typeof prepareSettler>>} SettlerKit
334
- */
392
+ // Expose the whole kit because the contract needs `creatorFacet` and the Advancer needs `notifier`
393
+ /** @typedef {ReturnType<ReturnType<typeof prepareSettler>>} SettlerKit */
@@ -53,6 +53,12 @@ const pendingTxKeyOf = evidence => {
53
53
  * }} StatusManagerPowers
54
54
  */
55
55
 
56
+ export const stateShape = harden({
57
+ pendingSettleTxs: M.remotable(),
58
+ seenTxs: M.remotable(),
59
+ storedCompletedTxs: M.remotable(),
60
+ });
61
+
56
62
  /**
57
63
  * The `StatusManager` keeps track of Pending and Seen Transactions
58
64
  * via {@link PendingTxStatus} states, aiding in coordination between the `Advancer`
@@ -69,14 +75,15 @@ export const prepareStatusManager = (
69
75
  txnsNode,
70
76
  {
71
77
  marshaller,
72
- log = makeTracer('Advancer', true),
78
+ // eslint-disable-next-line no-unused-vars
79
+ log = makeTracer('StatusManager', true),
73
80
  } = /** @type {StatusManagerPowers} */ ({}),
74
81
  ) => {
75
82
  /**
76
83
  * Keyed by a tuple of the Noble Forwarding Account and amount.
77
84
  * @type {MapStore<PendingTxKey, PendingTx[]>}
78
85
  */
79
- const pendingTxs = zone.mapStore('PendingTxs', {
86
+ const pendingSettleTxs = zone.mapStore('PendingSettleTxs', {
80
87
  keyShape: M.string(),
81
88
  valueShape: M.arrayOf(PendingTxShape),
82
89
  });
@@ -84,13 +91,18 @@ export const prepareStatusManager = (
84
91
  /**
85
92
  * Transactions seen *ever* by the contract.
86
93
  *
87
- * Note that like all durable stores, this SetStore is stored in IAVL. It
88
- * grows without bound (though the amount of growth per incoming message to
89
- * the contract is bounded). At some point in the future we may want to prune.
90
- * @type {SetStore<EvmHash>}
94
+ * Note that like all durable stores, this MapStore is kept in IAVL. It stores
95
+ * the `blockTimestamp` so that later we can prune old transactions.
96
+ *
97
+ * Note that `blockTimestamp` can drift between chains. Fortunately all CCTP
98
+ * chains use the same Unix epoch and won't drift more than minutes apart,
99
+ * which is more than enough precision for pruning old transaction.
100
+ *
101
+ * @type {MapStore<EvmHash, NatValue>}
91
102
  */
92
- const seenTxs = zone.setStore('SeenTxs', {
103
+ const seenTxs = zone.mapStore('SeenTxs', {
93
104
  keyShape: M.string(),
105
+ valueShape: M.nat(),
94
106
  });
95
107
 
96
108
  /**
@@ -153,10 +165,10 @@ export const prepareStatusManager = (
153
165
  if (seenTxs.has(txHash)) {
154
166
  throw makeError(`Transaction already seen: ${q(txHash)}`);
155
167
  }
156
- seenTxs.add(txHash);
168
+ seenTxs.init(txHash, evidence.blockTimestamp);
157
169
 
158
170
  appendToStoredArray(
159
- pendingTxs,
171
+ pendingSettleTxs,
160
172
  pendingTxKeyOf(evidence),
161
173
  harden({ ...evidence, status }),
162
174
  );
@@ -177,8 +189,8 @@ export const prepareStatusManager = (
177
189
  */
178
190
  function setPendingTxStatus({ nfa, amount }, status) {
179
191
  const key = makePendingTxKey(nfa, amount);
180
- pendingTxs.has(key) || Fail`no advancing tx with ${{ nfa, amount }}`;
181
- const pending = pendingTxs.get(key);
192
+ pendingSettleTxs.has(key) || Fail`no advancing tx with ${{ nfa, amount }}`;
193
+ const pending = pendingSettleTxs.get(key);
182
194
  const ix = pending.findIndex(tx => tx.status === PendingTxStatus.Advancing);
183
195
  ix >= 0 || Fail`no advancing tx with ${{ nfa, amount }}`;
184
196
  const [prefix, tx, suffix] = [
@@ -187,7 +199,7 @@ export const prepareStatusManager = (
187
199
  pending.slice(ix + 1),
188
200
  ];
189
201
  const txpost = { ...tx, status };
190
- pendingTxs.set(key, harden([...prefix, txpost, ...suffix]));
202
+ pendingSettleTxs.set(key, harden([...prefix, txpost, ...suffix]));
191
203
  void publishTxnRecord(tx.txHash, harden({ status }));
192
204
  }
193
205
 
@@ -195,24 +207,19 @@ export const prepareStatusManager = (
195
207
  'Fast USDC Status Manager',
196
208
  M.interface('StatusManagerI', {
197
209
  // TODO: naming scheme for transition events
198
- advance: M.call(CctpTxEvidenceShape).returns(M.undefined()),
210
+ advance: M.call(CctpTxEvidenceShape).returns(),
199
211
  advanceOutcome: M.call(M.string(), M.nat(), M.boolean()).returns(),
200
- skipAdvance: M.call(CctpTxEvidenceShape, M.arrayOf(M.string())).returns(
201
- M.undefined(),
202
- ),
203
- observe: M.call(CctpTxEvidenceShape).returns(M.undefined()),
212
+ skipAdvance: M.call(CctpTxEvidenceShape, M.arrayOf(M.string())).returns(),
213
+ advanceOutcomeForMintedEarly: M.call(EvmHashShape, M.boolean()).returns(),
214
+ advanceOutcomeForUnknownMint: M.call(CctpTxEvidenceShape).returns(),
215
+ observe: M.call(CctpTxEvidenceShape).returns(),
204
216
  hasBeenObserved: M.call(CctpTxEvidenceShape).returns(M.boolean()),
205
217
  deleteCompletedTxs: M.call().returns(M.undefined()),
206
218
  dequeueStatus: M.call(M.string(), M.bigint()).returns(
207
219
  M.or(
208
220
  {
209
221
  txHash: EvmHashShape,
210
- status: M.or(
211
- PendingTxStatus.Advanced,
212
- PendingTxStatus.AdvanceSkipped,
213
- PendingTxStatus.AdvanceFailed,
214
- PendingTxStatus.Observed,
215
- ),
222
+ status: M.or(...Object.values(PendingTxStatus)),
216
223
  },
217
224
  M.undefined(),
218
225
  ),
@@ -220,9 +227,7 @@ export const prepareStatusManager = (
220
227
  disbursed: M.call(EvmHashShape, AmountKeywordRecordShape).returns(
221
228
  M.undefined(),
222
229
  ),
223
- forwarded: M.call(M.opt(EvmHashShape), M.string(), M.nat()).returns(
224
- M.undefined(),
225
- ),
230
+ forwarded: M.call(EvmHashShape, M.boolean()).returns(),
226
231
  lookupPending: M.call(M.string(), M.bigint()).returns(
227
232
  M.arrayOf(PendingTxShape),
228
233
  ),
@@ -258,7 +263,7 @@ export const prepareStatusManager = (
258
263
  },
259
264
 
260
265
  /**
261
- * Record result of ADVANCING
266
+ * Record result of an ADVANCING transaction
262
267
  *
263
268
  * @param {NobleAddress} nfa Noble Forwarding Account
264
269
  * @param {import('@agoric/ertp').NatValue} amount
@@ -272,6 +277,43 @@ export const prepareStatusManager = (
272
277
  );
273
278
  },
274
279
 
280
+ /**
281
+ * If minted while advancing, publish a status update for the advance
282
+ * to vstorage.
283
+ *
284
+ * Does not add or amend `pendingSettleTxs` as this has
285
+ * already settled.
286
+ *
287
+ * @param {EvmHash} txHash
288
+ * @param {boolean} success whether the Transfer succeeded
289
+ */
290
+ advanceOutcomeForMintedEarly(txHash, success) {
291
+ void publishTxnRecord(
292
+ txHash,
293
+ harden({
294
+ status: success
295
+ ? PendingTxStatus.Advanced
296
+ : PendingTxStatus.AdvanceFailed,
297
+ }),
298
+ );
299
+ },
300
+
301
+ /**
302
+ * If minted before observed and the evidence is eventually
303
+ * reported, publish the evidence without adding to `pendingSettleTxs`
304
+ *
305
+ * @param {CctpTxEvidence} evidence
306
+ */
307
+ advanceOutcomeForUnknownMint(evidence) {
308
+ const { txHash } = evidence;
309
+ // unexpected path, since `hasBeenObserved` will be called before this
310
+ if (seenTxs.has(txHash)) {
311
+ throw makeError(`Transaction already seen: ${q(txHash)}`);
312
+ }
313
+ seenTxs.init(txHash, evidence.blockTimestamp);
314
+ publishEvidence(txHash, evidence);
315
+ },
316
+
275
317
  /**
276
318
  * Add a new transaction with OBSERVED status
277
319
  * @param {CctpTxEvidence} evidence
@@ -303,34 +345,32 @@ export const prepareStatusManager = (
303
345
  },
304
346
 
305
347
  /**
306
- * Remove and return an `ADVANCED` or `OBSERVED` tx waiting to be `SETTLED`.
348
+ * Remove and return the oldest pending settlement transaction that matches the given
349
+ * forwarding account and amount. Since multiple pending transactions may exist with
350
+ * identical (account, amount) pairs, we process them in FIFO order.
307
351
  *
308
352
  * @param {NobleAddress} nfa
309
353
  * @param {bigint} amount
310
- * @returns {Pick<PendingTx, 'status' | 'txHash'> | undefined} undefined if nothing
311
- * with this address and amount has been marked pending.
354
+ * @returns {Pick<PendingTx, 'status' | 'txHash'> | undefined} undefined if no pending
355
+ * transactions exist for this address and amount combination.
312
356
  */
313
357
  dequeueStatus(nfa, amount) {
314
358
  const key = makePendingTxKey(nfa, amount);
315
- if (!pendingTxs.has(key)) return undefined;
316
- const pending = pendingTxs.get(key);
359
+ if (!pendingSettleTxs.has(key)) return undefined;
360
+ const pending = pendingSettleTxs.get(key);
317
361
 
318
- const dequeueIdx = pending.findIndex(
319
- x => x.status !== PendingTxStatus.Advancing,
320
- );
321
- if (dequeueIdx < 0) return undefined;
362
+ if (pending.length === 0) {
363
+ return undefined;
364
+ }
365
+ // extract first item
366
+ const [{ status, txHash }, ...remaining] = pending;
322
367
 
323
- if (pending.length > 1) {
324
- const pendingCopy = [...pending];
325
- pendingCopy.splice(dequeueIdx, 1);
326
- pendingTxs.set(key, harden(pendingCopy));
368
+ if (remaining.length) {
369
+ pendingSettleTxs.set(key, harden(remaining));
327
370
  } else {
328
- pendingTxs.delete(key);
371
+ pendingSettleTxs.delete(key);
329
372
  }
330
373
 
331
- const { status, txHash } = pending[dequeueIdx];
332
- // TODO: store txHash -> evidence for txs pending settlement?
333
- // If necessary for vstorage writes in `forwarded` and `settled`
334
374
  return harden({ status, txHash });
335
375
  },
336
376
 
@@ -348,21 +388,18 @@ export const prepareStatusManager = (
348
388
  },
349
389
 
350
390
  /**
351
- * Mark a transaction as `FORWARDED`
391
+ * Mark a transaction as `FORWARDED` or `FORWARD_FAILED`
352
392
  *
353
- * @param {EvmHash | undefined} txHash - undefined in case mint before observed
354
- * @param {NobleAddress} nfa
355
- * @param {bigint} amount
393
+ * @param {EvmHash} txHash
394
+ * @param {boolean} success
356
395
  */
357
- forwarded(txHash, nfa, amount) {
358
- if (txHash) {
359
- void publishTxnRecord(txHash, harden({ status: TxStatus.Forwarded }));
360
- } else {
361
- // TODO store (early) `Minted` transactions to check against incoming evidence
362
- log(
363
- `⚠️ Forwarded minted amount ${amount} from account ${nfa} before it was observed.`,
364
- );
365
- }
396
+ forwarded(txHash, success) {
397
+ void publishTxnRecord(
398
+ txHash,
399
+ harden({
400
+ status: success ? TxStatus.Forwarded : TxStatus.ForwardFailed,
401
+ }),
402
+ );
366
403
  },
367
404
 
368
405
  /**
@@ -376,12 +413,13 @@ export const prepareStatusManager = (
376
413
  */
377
414
  lookupPending(nfa, amount) {
378
415
  const key = makePendingTxKey(nfa, amount);
379
- if (!pendingTxs.has(key)) {
416
+ if (!pendingSettleTxs.has(key)) {
380
417
  return harden([]);
381
418
  }
382
- return pendingTxs.get(key);
419
+ return pendingSettleTxs.get(key);
383
420
  },
384
421
  },
422
+ { stateShape },
385
423
  );
386
424
  };
387
425
  harden(prepareStatusManager);