Repository: Agoric/agoric-sdk
Pull request #11307: ForwardSkipped and refactorings for retry
Author: login: turadg
Labels: name: automerge:rebase
description: Automatically rebase updates, then merge
name: force:integration
description: Force integration tests to run on PR
Created: 2025-04-21T23:23:27Z | Merged: 2025-04-23T00:33:05Z

closes: https://github.com/Agoric/agoric-private/issues/309

## Description
This adds a ForwardSkipped state per https://github.com/Agoric/agoric-private/issues/309

It also has a series of refactorings to prepare for https://github.com/Agoric/agoric-private/issues/311

Reviewers,
- review by commit is suggested
- if you prefer to punt any refactorings to the next feature PR I can oblige

### Security Considerations
none

### Scaling Considerations
none

### Documentation Considerations
none

### Testing Considerations
CI

### Upgrade Considerations
This will be part of the CCTP beta and must successfully upgrade in Mainnet. The RunUtils test does upgrade and already caught an intermediate bug (omitting the obsolete handler facets) which I've documented.


Diff:
diff --git a/multichain-testing/test/fast-usdc/fast-usdc.test.ts b/multichain-testing/test/fast-usdc/fast-usdc.test.ts
index a8f5a43aa51..3a4b43c5311 100644
--- a/multichain-testing/test/fast-usdc/fast-usdc.test.ts
+++ b/multichain-testing/test/fast-usdc/fast-usdc.test.ts
@@ -787,7 +787,7 @@ test.serial('minted before observed; forward path', async t => {
   await assertAmtForwarded(queryClient, EUD, eudChain, mintAmt);
 });
 
-test.serial('insufficient LP funds and forward failed', async t => {
+test.serial('forward skipped due to invalid EUD', async t => {
   const mintAmt = LP_DEPOSIT_AMOUNT * 2n;
   const {
     api,
@@ -849,7 +849,7 @@ test.serial('insufficient LP funds and forward failed', async t => {
 
   nobleTools.mockCctpMint(mintAmt, userForwardingAddr);
 
-  await assertTxStatus(evidence.txHash, 'FORWARD_FAILED');
+  await assertTxStatus(evidence.txHash, 'FORWARD_SKIPPED');
 
   const endingSettlementAccountBalance = await querySettlementAccountBalance();
   t.log(
diff --git a/packages/fast-usdc-contract/README.md b/packages/fast-usdc-contract/README.md
index 2d5ab04bb1f..cd6ed78cf90 100644
--- a/packages/fast-usdc-contract/README.md
+++ b/packages/fast-usdc-contract/README.md
@@ -87,6 +87,7 @@ stateDiagram-v2
   Observed --> Forwarding : No risks, Mint deposited before advance
 
   Forwarding --> Forwarded : settler.forward() succeeds
+  Forwarding --> ForwardSkipped : settler.forward() skipped
   Advancing --> AdvancingChoice
   AdvancingChoice --> Advanced : advancer's transferHandler detects success
   Advanced --> Disbursed : settler.disburse()
@@ -94,4 +95,4 @@ stateDiagram-v2
   AdvanceFailed --> Forwarding : Mint deposited
   AdvancingChoice --> AdvanceFailed : advancer's transferHandler detects failure
   Forwarding --> ForwardFailed : settler.forward() fails
- ```
+```
diff --git a/packages/fast-usdc-contract/src/exos/settler.ts b/packages/fast-usdc-contract/src/exos/settler.ts
index 26e465d0685..77bc2c180e4 100644
--- a/packages/fast-usdc-contract/src/exos/settler.ts
+++ b/packages/fast-usdc-contract/src/exos/settler.ts
@@ -49,7 +49,7 @@ import type { WithdrawToSeat } from '@agoric/orchestration/src/utils/zoe-tools.j
 import { mustMatch, type MapStore } from '@agoric/store';
 import type { IBCChannelID, IBCPacket, VTransferIBCEvent } from '@agoric/vats';
 import type { TargetRegistration } from '@agoric/vats/src/bridge-target.js';
-import type { VowTools } from '@agoric/vow';
+import type { Vow, VowTools } from '@agoric/vow';
 import type { ZCF } from '@agoric/zoe/src/zoeService/zoe.js';
 import type { Zone } from '@agoric/zone';
 import { makeSupportsCctp } from '../utils/cctp.ts';
@@ -57,12 +57,6 @@ import { asMultiset } from '../utils/store.ts';
 import type { LiquidityPoolKit } from './liquidity-pool.js';
 import type { StatusManager } from './status-manager.js';
 
-const FORWARD_TIMEOUT = {
-  sec: 10n * 60n,
-  p: '10m',
-} as const;
-harden(FORWARD_TIMEOUT);
-
 const decodeEventPacket = (
   { data }: IBCPacket,
   remoteDenom: string,
@@ -148,9 +142,9 @@ export const stateShape = harden({
 export const prepareSettler = (
   zone: Zone,
   {
-    currentChainReference,
     chainHub,
     feeConfig,
+    forwardFunds,
     getNobleICA,
     log = makeTracer('Settler', true),
     statusManager,
@@ -159,10 +153,14 @@ export const prepareSettler = (
     withdrawToSeat,
     zcf,
   }: {
-    /** e.g., `agoric-3` */
-    currentChainReference: string;
-    chainHub: ChainHub;
+    chainHub: Pick<ChainHub, 'resolveAccountId'>;
     feeConfig: FeeConfig;
+    forwardFunds: (tx: {
+      txHash: EvmHash;
+      amount: NatAmount;
+      destination: AccountId;
+      fundsInNobleIca?: boolean;
+    }) => Vow<void>;
     getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
     log?: LogFn;
     statusManager: StatusManager;
@@ -174,7 +172,7 @@ export const prepareSettler = (
 ) => {
   assertAllDefined({ statusManager });
 
-  const supportsCctp = makeSupportsCctp(chainHub);
+  const UsdcAmountShape = makeNatAmountShape(USDC);
 
   return zone.exoClassKit(
     'Fast USDC Settler',
@@ -195,12 +193,16 @@ export const prepareSettler = (
         ),
       }),
       self: M.interface('SettlerSelfI', {
-        addMintedEarly: M.call(M.string(), M.nat()).returns(),
-        disburse: M.call(EvmHashShape, M.nat(), M.string()).returns(
+        addMintedEarly: M.call(M.string(), UsdcAmountShape).returns(),
+        disburse: M.call(EvmHashShape, UsdcAmountShape, M.string()).returns(
           M.promise(),
         ),
-        forward: M.call(EvmHashShape, M.nat(), M.string()).returns(),
+        forward: M.call(EvmHashShape, UsdcAmountShape, M.string()).returns(),
       }),
+      // XXX the following handlers are from before refactoring to an async flow for `forwardFunds`.
+      // They must remain implemented for as long as any vow might settle and need their behavior.
+      // Once all possible such vows are settled, the methods could be removed but the handler
+      // facets must remain to satisfy the kind definition backward compatibility checker.
       transferHandler: M.interface('SettlerTransferI', {
         onFulfilled: M.call(M.undefined(), M.string()).returns(),
         onRejected: M.call(M.error(), M.string()).returns(),
@@ -274,25 +276,27 @@ export const prepareSettler = (
           const { self } = this.facets;
           const found = statusManager.dequeueStatus(nfa, amount);
           log('dequeued', found, 'for', nfa, amount);
+          const fullValue = AmountMath.make(USDC, amount);
+
           switch (found?.status) {
             case PendingTxStatus.Advanced:
-              return self.disburse(found.txHash, amount, EUD);
+              return self.disburse(found.txHash, fullValue, EUD);
 
             case PendingTxStatus.Advancing:
               log('⚠️ tap: minted while advancing', nfa, amount);
-              self.addMintedEarly(nfa, amount);
+              self.addMintedEarly(nfa, fullValue);
               return;
 
             case PendingTxStatus.AdvanceSkipped:
             case PendingTxStatus.AdvanceFailed:
-              return self.forward(found.txHash, amount, EUD);
+              return self.forward(found.txHash, fullValue, EUD);
 
             case undefined:
             default:
               log('⚠️ tap: minted before observed', nfa, amount);
               // XXX consider capturing in vstorage
               // we would need a new key, as this does not have a txHash
-              self.addMintedEarly(nfa, amount);
+              self.addMintedEarly(nfa, fullValue);
           }
         },
       },
@@ -319,9 +323,9 @@ export const prepareSettler = (
             asMultiset(mintedEarly).remove(key);
             statusManager.advanceOutcomeForMintedEarly(txHash, success);
             if (success) {
-              void this.facets.self.disburse(txHash, fullValue, destination);
+              void this.facets.self.disburse(txHash, fullAmount, destination);
             } else {
-              void this.facets.self.forward(txHash, fullValue, destination);
+              void this.facets.self.forward(txHash, fullAmount, destination);
             }
           } else {
             statusManager.advanceOutcome(forwardingAddress, fullValue, success);
@@ -353,7 +357,11 @@ export const prepareSettler = (
             );
             asMultiset(mintedEarly).remove(key);
             statusManager.advanceOutcomeForUnknownMint(evidence);
-            void this.facets.self.forward(txHash, amount, destination);
+            void this.facets.self.forward(
+              txHash,
+              AmountMath.make(USDC, amount),
+              destination,
+            );
             return true;
           }
           return false;
@@ -365,8 +373,8 @@ export const prepareSettler = (
          * @param address
          * @param amount
          */
-        addMintedEarly(address: NobleAddress, amount: NatValue) {
-          const key = makeMintedEarlyKey(address, amount);
+        addMintedEarly(address: NobleAddress, amount: NatAmount) {
+          const key = makeMintedEarlyKey(address, amount.value);
           const { mintedEarly } = this.state;
           asMultiset(mintedEarly).add(key);
         },
@@ -377,11 +385,10 @@ export const prepareSettler = (
         // eslint-disable-next-line no-restricted-syntax -- will resolve before vat restart
         async disburse(
           txHash: EvmHash,
-          fullValue: NatValue,
+          received: NatAmount,
           EUD: AccountId | Bech32Address,
         ) {
           const { repayer, settlementAccount } = this.state;
-          const received = AmountMath.make(USDC, fullValue);
           const { zcfSeat: settlingSeat } = zcf.makeEmptySeatKit();
           const { calculateSplit } = makeFeeTools(feeConfig);
           // theoretically can throw, but shouldn't since Advancer already validated
@@ -412,73 +419,40 @@ export const prepareSettler = (
          * Funds were not advanced. Forward proceeds to the payee directly.
          *
          * @param {EvmHash} txHash
-         * @param {NatValue} fullValue
+         * @param {NatAmount} fullValue
          * @param {string} EUD
          */
         forward(
           txHash: EvmHash,
-          fullValue: NatValue,
+          fullValue: NatAmount,
           EUD: AccountId | Bech32Address,
         ) {
-          const { settlementAccount } = this.state;
-          log('forwarding', fullValue, 'to', EUD, 'for', txHash);
+          log('forwarding', fullValue.value, 'to', EUD, 'for', txHash);
 
           const dest: AccountId | null = (() => {
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwarded(txHash, false);
+              log(
+                '⚠️ forward not attempted',
+                'unresolvable destination',
+                txHash,
+                EUD,
+              );
+              statusManager.forwardSkipped(txHash);
               return null;
             }
           })();
           if (!dest) return;
 
-          const { namespace, reference } = parseAccountId(dest);
-          const amt = AmountMath.make(USDC, fullValue);
-
-          const intermediateRecipient = getNobleICA().getAddress();
-
-          if (namespace === 'cosmos') {
-            const transferOrSendV =
-              reference === currentChainReference
-                ? E(settlementAccount).send(dest, amt)
-                : E(settlementAccount).transfer(dest, amt, {
-                    timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-                    forwardOpts: {
-                      intermediateRecipient,
-                      timeout: FORWARD_TIMEOUT.p,
-                    },
-                  });
-            void vowTools.watch(
-              transferOrSendV,
-              this.facets.transferHandler,
-              txHash,
-            );
-          } else if (supportsCctp(dest)) {
-            // send to Noble then call `.depositForBurn(dest, amt)`
-            void vowTools.watch(
-              E(settlementAccount).transfer(intermediateRecipient, amt, {
-                timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-              }),
-              this.facets.intermediateTransferHandler,
-              { amt, dest, txHash },
-            );
-          } else {
-            log(
-              '🚨 forward not attempted!',
-              'unsupported destination',
-              txHash,
-              dest,
-            );
-            statusManager.forwarded(txHash, false);
-          }
+          // This synchronous function returns a Vow that does its own error handling.
+          void forwardFunds({ txHash, amount: fullValue, destination: dest });
         },
       },
       transferHandler: {
         onFulfilled(_result: unknown, txHash: EvmHash) {
           // update status manager, marking tx `FORWARDED` without fee split
-          statusManager.forwarded(txHash, true);
+          statusManager.forwarded(txHash);
         },
         onRejected(reason: unknown, txHash: EvmHash) {
           // funds remain in `settlementAccount` and must be recovered via a
@@ -486,7 +460,7 @@ export const prepareSettler = (
           log('🚨 forward transfer rejected!', reason, txHash);
           // update status manager, flagging a terminal state that needs to be
           // manual intervention or a code update to remediate
-          statusManager.forwarded(txHash, false);
+          statusManager.forwardFailed(txHash);
         },
       },
       intermediateTransferHandler: {
@@ -514,13 +488,13 @@ export const prepareSettler = (
           log('🚨 forward intermediate transfer rejected!', reason, txHash);
           // update status manager, flagging a terminal state that needs manual
           // intervention or a code update to remediate
-          statusManager.forwarded(txHash, false);
+          statusManager.forwardFailed(txHash);
         },
       },
       depositForBurnHandler: {
         onFulfilled(_result: unknown, txHash: EvmHash) {
           // update status manager, marking tx `FORWARDED` without fee split
-          statusManager.forwarded(txHash, true);
+          statusManager.forwarded(txHash);
         },
         onRejected(reason: unknown, txHash: EvmHash) {
           // funds remain in `nobleAccount` and must be recovered via a
@@ -528,7 +502,7 @@ export const prepareSettler = (
           log('🚨 forward depositForBurn rejected!', reason, txHash);
           // update status manager, flagging a terminal state that needs manual
           // intervention or a code update to remediate
-          statusManager.forwarded(txHash, false);
+          statusManager.forwardFailed(txHash);
         },
       },
     },
diff --git a/packages/fast-usdc-contract/src/exos/status-manager.ts b/packages/fast-usdc-contract/src/exos/status-manager.ts
index 26ee8607ab9..f4e4f6c1d4d 100644
--- a/packages/fast-usdc-contract/src/exos/status-manager.ts
+++ b/packages/fast-usdc-contract/src/exos/status-manager.ts
@@ -226,7 +226,9 @@ export const prepareStatusManager = (
       disbursed: M.call(EvmHashShape, AmountKeywordRecordShape).returns(
         M.undefined(),
       ),
-      forwarded: M.call(EvmHashShape, M.boolean()).returns(),
+      forwarded: M.call(EvmHashShape).returns(),
+      forwardFailed: M.call(EvmHashShape).returns(),
+      forwardSkipped: M.call(EvmHashShape).returns(),
       lookupPending: M.call(M.string(), M.bigint()).returns(
         M.arrayOf(PendingTxShape),
       ),
@@ -376,15 +378,40 @@ export const prepareStatusManager = (
       },
 
       /**
-       * Mark a transaction as `FORWARDED` or `FORWARD_FAILED`
+       * Mark a transaction as `FORWARDED`
+       * @param txHash
+       */
+      forwarded(txHash: EvmHash): void {
+        publishTxnRecord(
+          txHash,
+          harden({
+            status: TxStatus.Forwarded,
+          }),
+        );
+      },
+
+      /**
+       * Mark a transaction as `FORWARD_FAILED`
+       * @param txHash
+       */
+      forwardFailed(txHash: EvmHash): void {
+        publishTxnRecord(
+          txHash,
+          harden({
+            status: TxStatus.ForwardFailed,
+          }),
+        );
+      },
+
+      /**
+       * Mark a transaction as `FORWARD_SKIPPED`
        * @param txHash
-       * @param success
        */
-      forwarded(txHash: EvmHash, success: boolean): void {
+      forwardSkipped(txHash: EvmHash): void {
         publishTxnRecord(
           txHash,
           harden({
-            status: success ? TxStatus.Forwarded : TxStatus.ForwardFailed,
+            status: 'FORWARD_SKIPPED',
           }),
         );
       },
diff --git a/packages/fast-usdc-contract/src/fast-usdc.contract.ts b/packages/fast-usdc-contract/src/fast-usdc.contract.ts
index 35b123f48d5..a0255728c33 100644
--- a/packages/fast-usdc-contract/src/fast-usdc.contract.ts
+++ b/packages/fast-usdc-contract/src/fast-usdc.contract.ts
@@ -20,6 +20,7 @@ import {
   type OrchestrationPowers,
   type OrchestrationTools,
 } from '@agoric/orchestration';
+import type { HostForGuest } from '@agoric/orchestration/src/facade.js';
 import { makeZoeTools } from '@agoric/orchestration/src/utils/zoe-tools.js';
 import { provideSingleton } from '@agoric/zoe/src/contractSupport/durability.js';
 import { prepareRecorderKitMakers } from '@agoric/zoe/src/contractSupport/recorder.js';
@@ -48,6 +49,7 @@ import { prepareStatusManager } from './exos/status-manager.ts';
 import type { OperatorOfferResult } from './exos/transaction-feed.ts';
 import { prepareTransactionFeedKit } from './exos/transaction-feed.ts';
 import * as flows from './fast-usdc.flows.ts';
+import { makeSupportsCctp } from './utils/cctp.ts';
 
 const trace = makeTracer('FastUsdc');
 
@@ -133,16 +135,60 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  /** Chain, connection, and asset info can only be registered once */
+  const firstIncarnationKey = 'firstIncarnationKey';
+  // Perform this before makeLocalAccount() is called or it will cause
+  // `agoric` chain info to be pulled from agoricNames.
+  // UNTIL https://github.com/Agoric/agoric-sdk/issues/10602
+  if (!baggage.has(firstIncarnationKey)) {
+    baggage.init(firstIncarnationKey, true);
+    registerChainsAndAssets(
+      chainHub,
+      terms.brands,
+      privateArgs.chainInfo,
+      privateArgs.assetInfo,
+    );
+  }
+
+  const supportsCctp = makeSupportsCctp(chainHub);
+
+  const { makeLocalAccount, makeNobleAccount } = orchestrateAll(
+    {
+      // TODO flow picker for orchestrateAll with different contexts or ordering
+      makeLocalAccount: flows.makeLocalAccount,
+      makeNobleAccount: flows.makeNobleAccount,
+    },
+    {},
+  );
+
+  const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount());
+  const settleAccountV = zone.makeOnce('SettleAccount', () =>
+    makeLocalAccount(),
+  );
+  const { forwardFunds } = orchestrateAll(
+    // @ts-expect-error flow membrance type debt
+    { forwardFunds: flows.forwardFunds },
+    {
+      currentChainReference: privateArgs.chainInfo.agoric.chainId,
+      getNobleICA,
+      log: makeTracer('ForwardFunds'),
+      settlementAccount: settleAccountV,
+      supportsCctp,
+      statusManager,
+    },
+  ) as { forwardFunds: HostForGuest<typeof flows.forwardFunds> };
+
   const makeSettler = prepareSettler(zone, {
     statusManager,
     USDC,
     withdrawToSeat,
     feeConfig,
+    forwardFunds,
     getNobleICA,
     vowTools: tools.vowTools,
     zcf,
-    chainHub,
-    currentChainReference: privateArgs.chainInfo.agoric.chainId,
+    // UNTIL we have an generic way to attenuate an Exo https://github.com/Agoric/agoric-sdk/issues/11309
+    chainHub: { resolveAccountId: chainHub.resolveAccountId.bind(chainHub) },
   });
 
   const zoeTools = makeZoeTools(zcf, vowTools);
@@ -169,8 +215,6 @@ export const contract = async (
     { makeRecorderKit },
   );
 
-  const { makeLocalAccount, makeNobleAccount } = orchestrateAll(flows, {});
-
   const creatorFacet = zone.exo('Fast USDC Creator', undefined, {
     async makeOperatorInvitation(
       operatorId: string,
@@ -293,24 +337,8 @@ export const contract = async (
     makeLiquidityPoolKit(shareMint, privateArgs.poolMetricsNode),
   );
 
-  /** Chain, connection, and asset info can only be registered once */
-  const firstIncarnationKey = 'firstIncarnationKey';
-  if (!baggage.has(firstIncarnationKey)) {
-    baggage.init(firstIncarnationKey, true);
-    registerChainsAndAssets(
-      chainHub,
-      terms.brands,
-      privateArgs.chainInfo,
-      privateArgs.assetInfo,
-    );
-  }
-
   const feedKit = zone.makeOnce('Feed Kit', () => makeFeedKit());
 
-  const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount());
-  const settleAccountV = zone.makeOnce('SettleAccount', () =>
-    makeLocalAccount(),
-  );
   // when() is OK here since this clearly resolves promptly.
   const [poolAccount, settlementAccount] = (await vowTools.when(
     vowTools.all([poolAccountV, settleAccountV]),
diff --git a/packages/fast-usdc-contract/src/fast-usdc.flows.ts b/packages/fast-usdc-contract/src/fast-usdc.flows.ts
index dd454757a6d..03ea833c4bd 100644
--- a/packages/fast-usdc-contract/src/fast-usdc.flows.ts
+++ b/packages/fast-usdc-contract/src/fast-usdc.flows.ts
@@ -1,4 +1,30 @@
-import type { OrchestrationFlow, Orchestrator } from '@agoric/orchestration';
+import type { NatAmount } from '@agoric/ertp';
+import type { EvmHash } from '@agoric/fast-usdc/src/types.ts';
+import type {
+  AccountId,
+  OrchestrationAccount,
+  OrchestrationFlow,
+  Orchestrator,
+} from '@agoric/orchestration';
+import { parseAccountId } from '@agoric/orchestration/src/utils/address.js';
+import { assertAllDefined } from '@agoric/internal';
+import type { StatusManager } from './exos/status-manager.ts';
+
+const FORWARD_TIMEOUT = {
+  sec: 10n * 60n,
+  p: '10m',
+} as const;
+harden(FORWARD_TIMEOUT);
+
+export interface Context {
+  /** e.g., `agoric-3` */
+  currentChainReference: string;
+  supportsCctp: (destination: AccountId) => boolean;
+  log: Console['log'];
+  statusManager: StatusManager;
+  getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
+  settlementAccount: Promise<OrchestrationAccount<{ chainId: 'agoric-any' }>>;
+}
 
 export const makeLocalAccount = (async (orch: Orchestrator) => {
   const agoricChain = await orch.getChain('agoric');
@@ -11,3 +37,98 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (namespace === 'cosmos') {
+    const completion =
+      reference === currentChainReference
+        ? settlement.send(destination, amount)
+        : settlement.transfer(destination, amount, {
+            timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+            forwardOpts: {
+              intermediateRecipient,
+              timeout: FORWARD_TIMEOUT.p,
+            },
+          });
+    try {
+      await completion;
+      log('forward successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs to be
+      // manual intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else if (supportsCctp(destination)) {
+    try {
+      await settlement.transfer(intermediateRecipient, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+      });
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward intermediate transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+
+    try {
+      await getNobleICA().depositForBurn(destination, amount);
+      log('forward transfer and depositForBurn successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `nobleAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward depositForBurn rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else {
+    log(
+      '⚠️ forward not attempted',
+      'unsupported destination',
+      txHash,
+      destination,
+    );
+    statusManager.forwardSkipped(txHash);
+  }
+};
+harden(forwardFunds);
diff --git a/packages/fast-usdc-contract/test/exos/settler.test.ts b/packages/fast-usdc-contract/test/exos/settler.test.ts
index a8b47716514..27d772079e1 100644
--- a/packages/fast-usdc-contract/test/exos/settler.test.ts
+++ b/packages/fast-usdc-contract/test/exos/settler.test.ts
@@ -28,6 +28,8 @@ import {
   stateShape,
 } from '../../src/exos/settler.ts';
 import { prepareStatusManager } from '../../src/exos/status-manager.ts';
+import * as flows from '../../src/fast-usdc.flows.ts';
+import { makeSupportsCctp } from '../../src/utils/cctp.ts';
 import {
   intermediateRecipient,
   MockCctpTxEvidences,
@@ -102,17 +104,33 @@ const makeTestContext = async t => {
     chainId: `${cctpChainInfo.ethereum.namespace}:${cctpChainInfo.ethereum.reference}`,
   });
 
+  const forwardFunds = tx =>
+    flows.forwardFunds(
+      undefined as any,
+      {
+        log, // some tests check the log calls
+        currentChainReference: 'agoric-3',
+        supportsCctp: makeSupportsCctp(chainHub),
+        statusManager,
+        getNobleICA: () => mockAccounts.intermediate.account as any,
+        settlementAccount: Promise.resolve(
+          mockAccounts.settlement.account as any,
+        ),
+      },
+      tx,
+    );
+
   const makeSettler = prepareSettler(zone.subZone('settler'), {
     statusManager,
     USDC: usdc.brand,
     zcf,
     withdrawToSeat: mockWithdrawToSeat,
     feeConfig: common.commonPrivateArgs.feeConfig,
-    getNobleICA: () => mockAccounts.intermediate.account as any,
     vowTools: common.utils.vowTools,
     chainHub,
-    currentChainReference: 'agoric-3',
     log,
+    // @ts-expect-error faking the membrane
+    forwardFunds,
   });
 
   const defaultSettlerParams = harden({
@@ -1065,7 +1083,46 @@ test('forward to Ethereum EUD', async t => {
   t.is(storage.data.get(`fun.txns.${cctpTxEvidence.txHash}`), undefined);
 });
 
-test('forward not attempted; dest not supported', async t => {
+test('forward not attempted: unresolvable destination', async t => {
+  const {
+    common,
+    makeSettler,
+    defaultSettlerParams,
+    repayer,
+    accounts,
+    inspectLogs,
+    storage,
+  } = t.context;
+  const { usdc } = common.brands;
+
+  const settler = makeSettler({
+    repayer,
+    settlementAccount: accounts.settlement.account,
+    ...defaultSettlerParams,
+  });
+
+  const txHash =
+    '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702';
+  const badEud = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // missing destination
+  settler.self.forward(txHash, usdc.makeEmpty(), badEud);
+
+  // Verify the exact log message for "forward not attempted"
+  t.deepEqual(inspectLogs().at(-1), [
+    '⚠️ forward not attempted',
+    'unresolvable destination',
+    txHash,
+    badEud,
+  ]);
+
+  // Verify the transaction record in storage
+  await eventLoopIteration();
+  t.deepEqual(
+    storage.getDeserialized(`fun.txns.${txHash}`).at(-1),
+    { status: 'FORWARD_SKIPPED' }, // unresolvable destination
+  );
+});
+
+test('forward not attempted: unsupported destination', async t => {
   const {
     common,
     makeSettler,
@@ -1137,7 +1194,7 @@ test('forward not attempted; dest not supported', async t => {
 
   // Verify the exact log message for "forward not attempted"
   t.deepEqual(inspectLogs().at(-1), [
-    '🚨 forward not attempted!',
+    '⚠️ forward not attempted',
     'unsupported destination',
     '0xc81bc6105b60a234c7c50ac17816ebcd5561d366df8bf3be59ff387552761702',
     'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp:Gf4DKri6Kw5fHL3VkrX12BaFLWCJXhKShP5epvQWJtpf',
@@ -1158,7 +1215,7 @@ test('forward not attempted; dest not supported', async t => {
   t.deepEqual(storage.getDeserialized(`fun.txns.${cctpTxEvidence.txHash}`), [
     { evidence: cctpTxEvidence, status: 'OBSERVED' },
     { risksIdentified: ['TOO_LARGE_AMOUNT'], status: 'ADVANCE_SKIPPED' },
-    { status: 'FORWARD_FAILED' }, // Transaction should be marked as FORWARD_FAILED
+    { status: 'FORWARD_SKIPPED' }, // unsupported destination
   ]);
 });
 
diff --git a/packages/fast-usdc-contract/test/mocks.ts b/packages/fast-usdc-contract/test/mocks.ts
index d59ec5ca6d9..f0e392b7925 100644
--- a/packages/fast-usdc-contract/test/mocks.ts
+++ b/packages/fast-usdc-contract/test/mocks.ts
@@ -10,6 +10,7 @@ import { makeRatio } from '@agoric/ertp/src/ratio.js';
 import type { AmountUtils } from '@agoric/zoe/tools/test-utils.js';
 import type { Zone } from '@agoric/zone';
 import type { FeeConfig, LogFn } from '@agoric/fast-usdc/src/types.js';
+import { makePromiseKit } from '@endo/promise-kit';
 
 export const prepareMockOrchAccounts = (
   zone: Zone,
@@ -24,17 +25,17 @@ export const prepareMockOrchAccounts = (
   },
 ) => {
   // each can only be resolved/rejected once per test
-  const poolAccountSendVK = makeVowKit<undefined>();
-  const poolAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountSendVK = makeVowKit<undefined>();
-  const intermediateAccountTransferVK = makeVowKit<undefined>();
-  const intermediateAccountDepositForBurnVK = makeVowKit<undefined>();
+  const poolAccountSendPK = makePromiseKit<void>();
+  const poolAccountTransferPK = makePromiseKit<void>();
+  const settleAccountTransferPK = makePromiseKit<void>();
+  const settleAccountSendPK = makePromiseKit<void>();
+  const intermediateAccountTransferPK = makePromiseKit<void>();
+  const intermediateAccountDepositForBurnPK = makePromiseKit<void>();
 
   const mockedPoolAccount = zone.exo('Mock Pool LocalOrchAccount', undefined, {
     transfer(destination: CosmosChainAddress, amount: DenomAmount) {
       log('PoolAccount.transfer() called with', destination, amount);
-      return poolAccountTransferVK.vow;
+      return poolAccountTransferPK.promise;
     },
     deposit(payment: Payment<'nat'>) {
       log('PoolAccount.deposit() called with', payment);
@@ -43,7 +44,7 @@ export const prepareMockOrchAccounts = (
     },
     send(destination: CosmosChainAddress, amount: DenomAmount) {
       log('PoolAccount.send() called with', destination, amount);
-      return poolAccountSendVK.vow;
+      return poolAccountSendPK.promise;
     },
   });
 
@@ -55,11 +56,11 @@ export const prepareMockOrchAccounts = (
   const settlementAccountMock = zone.exo('Mock Settlement Account', undefined, {
     transfer(...args) {
       settlementCallLog.push(harden(['transfer', ...args]));
-      return settleAccountTransferVK.vow;
+      return settleAccountTransferPK.promise;
     },
     send(...args) {
       settlementCallLog.push(harden(['send', ...args]));
-      return settleAccountSendVK.vow;
+      return settleAccountSendPK.promise;
     },
   });
   const settlementAccount = settlementAccountMock as unknown as HostInterface<
@@ -76,11 +77,11 @@ export const prepareMockOrchAccounts = (
     },
     transfer(...args) {
       intermediateCallLog.push(harden(['transfer', ...args]));
-      return intermediateAccountTransferVK.vow;
+      return intermediateAccountTransferPK.promise;
     },
     depositForBurn(...args) {
       intermediateCallLog.push(harden(['depositForBurn', ...args]));
-      return intermediateAccountDepositForBurnVK.vow;
+      return intermediateAccountDepositForBurnPK.promise;
     },
   });
   const intermediateAccount =
@@ -88,22 +89,25 @@ export const prepareMockOrchAccounts = (
       OrchestrationAccount<{ chainId: 'noble-any' }>
     >;
   return {
+    // These each have VResolver for "vow" resolver. The mocks actually
+    // deal in promises but the flow that awaits them expects that it's actually
+    // awaiting a vow (made by the membrane to look like a promise).
     mockPoolAccount: {
       account: poolAccount,
-      transferVResolver: poolAccountTransferVK.resolver,
-      sendVResolver: poolAccountSendVK.resolver,
+      transferVResolver: poolAccountTransferPK,
+      sendVResolver: poolAccountSendPK,
     },
     settlement: {
       account: settlementAccount,
       callLog: settlementCallLog,
-      transferVResolver: settleAccountTransferVK.resolver,
-      sendVResolver: settleAccountSendVK.resolver,
+      transferVResolver: settleAccountTransferPK,
+      sendVResolver: settleAccountSendPK,
     },
     intermediate: {
       account: intermediateAccount,
       callLog: intermediateCallLog,
-      transferVResolver: intermediateAccountTransferVK.resolver,
-      depositForBurnVResolver: intermediateAccountDepositForBurnVK.resolver,
+      transferVResolver: intermediateAccountTransferPK,
+      depositForBurnVResolver: intermediateAccountDepositForBurnPK,
     },
   };
 };
diff --git a/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.md b/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.md
index 8f079e2b09f..e3254c8b024 100644
--- a/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.md
+++ b/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.md
@@ -1547,6 +1547,19 @@ Generated by [AVA](https://avajs.dev).
         },
         operators: {},
         orchestration: {
+          forwardFunds: {
+            asyncFlow_kindHandle: 'Alleged: kind',
+            endowments: {
+              0: {
+                getNobleICA_kindHandle: 'Alleged: kind',
+                getNobleICA_singleton: 'Alleged: getNobleICA',
+                log_kindHandle: 'Alleged: kind',
+                log_singleton: 'Alleged: log',
+                supportsCctp_kindHandle: 'Alleged: kind',
+                supportsCctp_singleton: 'Alleged: supportsCctp',
+              },
+            },
+          },
           makeLocalAccount: {
             asyncFlow_kindHandle: 'Alleged: kind',
           },
@@ -1622,9 +1635,13 @@ Generated by [AVA](https://avajs.dev).
         },
         LogStore_kindHandle: 'Alleged: kind',
         StateUnwrapper_kindHandle: 'Alleged: kind',
-        asyncFuncEagerWakers: [],
+        asyncFuncEagerWakers: [
+          Object @Alleged: asyncFlow flow {},
+        ],
         asyncFuncFailures: {},
-        flowForOutcomeVow: {},
+        flowForOutcomeVow: {
+          'Alleged: VowInternalsKit vowV0': 'Alleged: asyncFlow flow',
+        },
         unwrapMap: 'Alleged: weakMapStore',
       },
       chainHub: {
@@ -3183,6 +3200,19 @@ Generated by [AVA](https://avajs.dev).
           },
         },
         orchestration: {
+          forwardFunds: {
+            asyncFlow_kindHandle: 'Alleged: kind',
+            endowments: {
+              0: {
+                getNobleICA_kindHandle: 'Alleged: kind',
+                getNobleICA_singleton: 'Alleged: getNobleICA',
+                log_kindHandle: 'Alleged: kind',
+                log_singleton: 'Alleged: log',
+                supportsCctp_kindHandle: 'Alleged: kind',
+                supportsCctp_singleton: 'Alleged: supportsCctp',
+              },
+            },
+          },
           makeLocalAccount: {
             asyncFlow_kindHandle: 'Alleged: kind',
           },
diff --git a/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.snap b/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.snap
index 42ee162276a..94fb1baa46d 100644
Binary files a/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.snap and b/packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.snap differ
diff --git a/packages/fast-usdc/src/constants.js b/packages/fast-usdc/src/constants.js
index a03a8cf8827..84fa401aa1a 100644
--- a/packages/fast-usdc/src/constants.js
+++ b/packages/fast-usdc/src/constants.js
@@ -16,6 +16,8 @@ export const TxStatus = /** @type {const} */ ({
   AdvanceSkipped: 'ADVANCE_SKIPPED',
   /** settlement for matching advance received and funds disbursed */
   Disbursed: 'DISBURSED',
+  /** No route, forward not attempted */
+  ForwardedSkipped: 'FORWARD_SKIPPED',
   /** fallback: do not collect fees */
   Forwarded: 'FORWARDED',
   /** failed to forward to EUD */


Reviews:
user: login: dckc
body: looks pretty good, but I'd like to understand some parts better before approving

and I question one red alert.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:09:22Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:27:08Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:31:43Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:32:54Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:34:24Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:36:34Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:40:59Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:42:25Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:44:31Z
user: login: dckc
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:52:26Z
user: login: dckc
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:52:53Z
user: login: dckc
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:54:09Z
user: login: dckc
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:55:32Z
user: login: dckc
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T00:57:45Z
user: login: dckc
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T05:24:46Z
user: login: 0xpatrickdev
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T14:38:41Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T15:27:00Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T15:36:44Z
user: login: mhofman
body: Besides some general comments about the refactoring (to help me get situated), I am very skeptical that foregoing asyncFlow in the test is the right approach. I don't see a reason we couldn't use it.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T21:53:29Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:23:01Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:24:55Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:25:57Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:26:33Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:29:24Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:35:48Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:43:21Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:43:47Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:51:44Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:52:46Z
user: login: mhofman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:53:57Z
user: login: turadg
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:55:30Z
user: login: mhofman
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T22:57:19Z

Review comments:
diff_hunk: @@ -95,4 +95,5 @@ stateDiagram-v2
   AdvanceFailed --> Forwarding : Mint deposited
   AdvancingChoice --> AdvanceFailed : advancer's transferHandler detects failure
   Forwarding --> ForwardFailed : settler.forward() fails
+  ForwardFailed --> Forwarding : transfer to destination chain succeeds (retry)
path: packages/fast-usdc-contract/README.md
user: login: dckc
body: please note these state machine changes under **Documentation Considerations**
There's an SRE impact, IIUC.
created_at: 2025-04-21T23:29:22Z
author_association: MEMBER
diff_hunk: @@ -16,6 +16,8 @@ export const TxStatus = /** @type {const} */ ({
   AdvanceSkipped: 'ADVANCE_SKIPPED',
   /** settlement for matching advance received and funds disbursed */
   Disbursed: 'DISBURSED',
+  /** No route, forward not attempted */
+  ForwardedSkipped: 'FORWARD_SKIPPED',
path: packages/fast-usdc/src/constants.js
user: login: dckc
body: thinking out loud... "skipped" sounds like going on to some other next step. But it's terminal.
"no route" reminds me of "No route to host" from TCP ping. It might be a basis for a better name.
created_at: 2025-04-21T23:33:45Z
author_association: MEMBER
diff_hunk: @@ -427,8 +427,13 @@ export const prepareSettler = (
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwardFailed(txHash);
+              log(
+                '🚨 forward not attempted!',
+                'unresolvable destination',
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: dckc
body: I believe anybody can trigger this condition by sending a suitably formatted junk transfer to the settler account. That doesn't seem like the sort of thing we should be red-alerting about. **important**, I think.
created_at: 2025-04-21T23:37:49Z
author_association: MEMBER
diff_hunk: @@ -195,11 +197,11 @@ export const prepareSettler = (
         ),
       }),
       self: M.interface('SettlerSelfI', {
-        addMintedEarly: M.call(M.string(), M.nat()).returns(),
-        disburse: M.call(EvmHashShape, M.nat(), M.string()).returns(
+        addMintedEarly: M.call(M.string(), UsdcAmountShape).returns(),
+        disburse: M.call(EvmHashShape, UsdcAmountShape, M.string()).returns(
           M.promise(),
         ),
-        forward: M.call(EvmHashShape, M.nat(), M.string()).returns(),
+        forward: M.call(EvmHashShape, UsdcAmountShape, M.string()).returns(),
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: dckc
body: thinking out loud... about upgrade considerations... this _looks_ a bit like a breaking change; but I suppose it's an internal interface.
created_at: 2025-04-21T23:39:54Z
author_association: MEMBER
diff_hunk: @@ -148,9 +142,9 @@ export const stateShape = harden({
 export const prepareSettler = (
   zone: Zone,
   {
-    currentChainReference,
-    chainHub,
+    chainHub, // TODO POLA
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: dckc
body: TODO when?
created_at: 2025-04-21T23:42:53Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,100 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  }
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: dckc
body: don't we want an early `return` in this `if`?

the agoric chain is also in the `cosmos` namespace, right?
created_at: 2025-04-21T23:49:18Z
author_association: MEMBER
diff_hunk: @@ -133,16 +135,43 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  const supportsCctp = makeSupportsCctp(chainHub);
+
+  const { makeLocalAccount, makeNobleAccount } = orchestrateAll(
+    {
+      // TODO flow picker for orchestrateAll with different contexts or ordering
+      makeLocalAccount: flows.makeLocalAccount,
+      makeNobleAccount: flows.makeNobleAccount,
+    },
+    {},
+  );
+
+  const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount());
+  const settleAccountV = zone.makeOnce('SettleAccount', () =>
+    makeLocalAccount(),
+  );
+  const { forwardFunds } = orchestrateAll(
+    // @ts-expect-error flow membrance type debt
+    { forwardFunds: flows.forwardFunds },
+    {
+      currentChainReference: privateArgs.chainInfo.agoric.chainId,
+      getNobleICA,
path: packages/fast-usdc-contract/src/fast-usdc.contract.ts
user: login: dckc
body: `getNobleICA` is a plain function; i.e. not `Passable`. Don't the context properties have to be passable?

I suppose we have enough tests to say otherwise, but I'm uncomfortable approving while I don't understand this.
created_at: 2025-04-21T23:56:58Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,30 @@
-import type { OrchestrationFlow, Orchestrator } from '@agoric/orchestration';
+import type { NatAmount } from '@agoric/ertp';
+import type { EvmHash } from '@agoric/fast-usdc/src/types.ts';
+import type {
+  AccountId,
+  OrchestrationAccount,
+  OrchestrationFlow,
+  Orchestrator,
+} from '@agoric/orchestration';
+import { parseAccountId } from '@agoric/orchestration/src/utils/address.js';
+import { assertAllDefined, makeTracer } from '@agoric/internal';
+import type { StatusManager } from './exos/status-manager.ts';
+
+const FORWARD_TIMEOUT = {
+  sec: 10n * 60n,
+  p: '10m',
+} as const;
+harden(FORWARD_TIMEOUT);
+
+export interface Context {
+  /** e.g., `agoric-3` */
+  currentChainReference: string;
+  supportsCctp: (destination: AccountId) => boolean;
+  log?: Console['log'];
+  statusManager: StatusManager;
+  getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
+  settlementAccount: Promise<OrchestrationAccount<{ chainId: 'agoric-any' }>>;
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: dckc
body: thinking out loud... we pass a callback to get the noble ICA, but a promise to get the settlementAccount. seems a little inconsistent. I suppose there are constraints that motivate the different approaches.
created_at: 2025-04-22T00:01:24Z
author_association: MEMBER
diff_hunk: @@ -807,6 +825,10 @@ test('slow path, and forward fails (terminal state)', async t => {
     ...defaultSettlerParams,
   });
 
+  // FIXME this rejection isn't being caught within the flow
+  // The console shows SES_UNHANDLED_REJECTION for this error
+  // and the await within the flow succeeds when it should throw.
path: packages/fast-usdc-contract/test/exos/settler.test.ts
user: login: dckc
body: FIXME when?
I got the impression that you found a solution to this.
created_at: 2025-04-22T00:04:15Z
author_association: MEMBER
diff_hunk: @@ -1622,9 +1633,13 @@ Generated by [AVA](https://avajs.dev).
         },
         LogStore_kindHandle: 'Alleged: kind',
         StateUnwrapper_kindHandle: 'Alleged: kind',
-        asyncFuncEagerWakers: [],
+        asyncFuncEagerWakers: [
+          Object @Alleged: asyncFlow flow {},
+        ],
         asyncFuncFailures: {},
-        flowForOutcomeVow: {},
+        flowForOutcomeVow: {
+          'Alleged: VowInternalsKit vowV0': 'Alleged: asyncFlow flow',
+        },
path: packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.md
user: login: dckc
body: **note:** I don't exactly understand what `asyncFuncEagerWakers` and `flowForOutcomeVow` are, but I suppose these are compatible changes.

and we do have a relevant upgrade test: `deploy HEAD; upgrade to support EVM destinations`
created_at: 2025-04-22T00:06:59Z
author_association: MEMBER
diff_hunk: @@ -807,6 +825,10 @@ test('slow path, and forward fails (terminal state)', async t => {
     ...defaultSettlerParams,
   });
 
+  // FIXME this rejection isn't being caught within the flow
+  // The console shows SES_UNHANDLED_REJECTION for this error
+  // and the await within the flow succeeds when it should throw.
path: packages/fast-usdc-contract/test/exos/settler.test.ts
user: login: turadg
body: oops, leftover from debugging. will remove
created_at: 2025-04-22T00:27:08Z
author_association: MEMBER
diff_hunk: @@ -95,4 +95,5 @@ stateDiagram-v2
   AdvanceFailed --> Forwarding : Mint deposited
   AdvancingChoice --> AdvanceFailed : advancer's transferHandler detects failure
   Forwarding --> ForwardFailed : settler.forward() fails
+  ForwardFailed --> Forwarding : transfer to destination chain succeeds (retry)
path: packages/fast-usdc-contract/README.md
user: login: turadg
body: >### Documentation Considerations
> Give our docs folks some hints about what needs to be described to downstream users.  Backwards compatibility: what happens to existing data or deployments when this code is shipped? Do we need to instruct users to do something to upgrade their saved data? If there is no upgrade path possible, how bad will that be for users?

What is there for "docs folks" to do? 

I'll give SRE a heads up. I'm not clear on how making note in this part of the PR would help with that. Is the idea that I tell reviewers what I intend to communicate?
created_at: 2025-04-22T00:31:43Z
author_association: MEMBER
diff_hunk: @@ -148,9 +142,9 @@ export const stateShape = harden({
 export const prepareSettler = (
   zone: Zone,
   {
-    currentChainReference,
-    chainHub,
+    chainHub, // TODO POLA
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: turadg
body: POLA for principle of least authority… the Settler doesn't need all the authority of chainHub. It just needs `resolveAccountId` and should never make modifications.

TODO when… I don't know. XXX better? 
created_at: 2025-04-22T00:32:54Z
author_association: MEMBER
diff_hunk: @@ -427,8 +427,13 @@ export const prepareSettler = (
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwardFailed(txHash);
+              log(
+                '🚨 forward not attempted!',
+                'unresolvable destination',
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: turadg
body: Good comment. I'll revert the change in severity. The new words are appropriate?
created_at: 2025-04-22T00:34:24Z
author_association: MEMBER
diff_hunk: @@ -133,16 +135,43 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  const supportsCctp = makeSupportsCctp(chainHub);
+
+  const { makeLocalAccount, makeNobleAccount } = orchestrateAll(
+    {
+      // TODO flow picker for orchestrateAll with different contexts or ordering
+      makeLocalAccount: flows.makeLocalAccount,
+      makeNobleAccount: flows.makeNobleAccount,
+    },
+    {},
+  );
+
+  const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount());
+  const settleAccountV = zone.makeOnce('SettleAccount', () =>
+    makeLocalAccount(),
+  );
+  const { forwardFunds } = orchestrateAll(
+    // @ts-expect-error flow membrance type debt
+    { forwardFunds: flows.forwardFunds },
+    {
+      currentChainReference: privateArgs.chainInfo.agoric.chainId,
+      getNobleICA,
path: packages/fast-usdc-contract/src/fast-usdc.contract.ts
user: login: turadg
body: >Don't the context properties have to be passable?

I don't think so: https://github.com/Agoric/agoric-sdk/blob/ea3fbea1acd04edb1750032e8a7f4c8c53f1fbc5/packages/orchestration/src/facade.js#L102


created_at: 2025-04-22T00:36:34Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,30 @@
-import type { OrchestrationFlow, Orchestrator } from '@agoric/orchestration';
+import type { NatAmount } from '@agoric/ertp';
+import type { EvmHash } from '@agoric/fast-usdc/src/types.ts';
+import type {
+  AccountId,
+  OrchestrationAccount,
+  OrchestrationFlow,
+  Orchestrator,
+} from '@agoric/orchestration';
+import { parseAccountId } from '@agoric/orchestration/src/utils/address.js';
+import { assertAllDefined, makeTracer } from '@agoric/internal';
+import type { StatusManager } from './exos/status-manager.ts';
+
+const FORWARD_TIMEOUT = {
+  sec: 10n * 60n,
+  p: '10m',
+} as const;
+harden(FORWARD_TIMEOUT);
+
+export interface Context {
+  /** e.g., `agoric-3` */
+  currentChainReference: string;
+  supportsCctp: (destination: AccountId) => boolean;
+  log?: Console['log'];
+  statusManager: StatusManager;
+  getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
+  settlementAccount: Promise<OrchestrationAccount<{ chainId: 'agoric-any' }>>;
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: Yeah, I share the concern. What I could do is refactor it also to a promise but I'm not sure it's better,
```js
   const nobleIcaPK = makePromiseKit();
   if (baggage.has(NOBLE_ICA_BAGGAGE_KEY))
       nobleIcaPK.resolve(baggage.get(NOBLE_ICA_BAGGAGE_KEY));
```

And then whenever it is set, also resolve it.

Maybe there's some "durably stored promise" kit abstraction we could make, but still I don't think it's worth it.

What I will do is at a comment justifying the disharmony.
created_at: 2025-04-22T00:40:59Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,100 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  }
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: Good catch! I'll try to figure out why no tests failed. Maybe they did both `send()` and `transfer()` and ended up with the same final state. 
created_at: 2025-04-22T00:42:24Z
author_association: MEMBER
diff_hunk: @@ -16,6 +16,8 @@ export const TxStatus = /** @type {const} */ ({
   AdvanceSkipped: 'ADVANCE_SKIPPED',
   /** settlement for matching advance received and funds disbursed */
   Disbursed: 'DISBURSED',
+  /** No route, forward not attempted */
+  ForwardedSkipped: 'FORWARD_SKIPPED',
path: packages/fast-usdc/src/constants.js
user: login: turadg
body: you could think of a virtual "terminal" state that it's skipping to. I had that in an iteration of the state diagram but it got messy.

I can imagine other conditions that cause a ForwardSkipped so prefer to keep it general describing what behavior the contract took rather than the cause
created_at: 2025-04-22T00:44:31Z
author_association: MEMBER
diff_hunk: @@ -95,4 +95,5 @@ stateDiagram-v2
   AdvanceFailed --> Forwarding : Mint deposited
   AdvancingChoice --> AdvanceFailed : advancer's transferHandler detects failure
   Forwarding --> ForwardFailed : settler.forward() fails
+  ForwardFailed --> Forwarding : transfer to destination chain succeeds (retry)
path: packages/fast-usdc-contract/README.md
user: login: dckc
body: ok.
created_at: 2025-04-22T00:52:26Z
author_association: MEMBER
diff_hunk: @@ -427,8 +427,13 @@ export const prepareSettler = (
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwardFailed(txHash);
+              log(
+                '🚨 forward not attempted!',
+                'unresolvable destination',
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: dckc
body: yes; the words look ok
created_at: 2025-04-22T00:52:52Z
author_association: MEMBER
diff_hunk: @@ -148,9 +142,9 @@ export const stateShape = harden({
 export const prepareSettler = (
   zone: Zone,
   {
-    currentChainReference,
-    chainHub,
+    chainHub, // TODO POLA
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: dckc
body: perhaps: XXX POLA: Settler needs only read access to chainHub
or ... needs only resolveAccountId
created_at: 2025-04-22T00:54:09Z
author_association: MEMBER
diff_hunk: @@ -133,16 +135,43 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  const supportsCctp = makeSupportsCctp(chainHub);
+
+  const { makeLocalAccount, makeNobleAccount } = orchestrateAll(
+    {
+      // TODO flow picker for orchestrateAll with different contexts or ordering
+      makeLocalAccount: flows.makeLocalAccount,
+      makeNobleAccount: flows.makeNobleAccount,
+    },
+    {},
+  );
+
+  const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount());
+  const settleAccountV = zone.makeOnce('SettleAccount', () =>
+    makeLocalAccount(),
+  );
+  const { forwardFunds } = orchestrateAll(
+    // @ts-expect-error flow membrance type debt
+    { forwardFunds: flows.forwardFunds },
+    {
+      currentChainReference: privateArgs.chainInfo.agoric.chainId,
+      getNobleICA,
path: packages/fast-usdc-contract/src/fast-usdc.contract.ts
user: login: dckc
body: ok
created_at: 2025-04-22T00:55:32Z
author_association: MEMBER
diff_hunk: @@ -148,9 +142,9 @@ export const stateShape = harden({
 export const prepareSettler = (
   zone: Zone,
   {
-    currentChainReference,
-    chainHub,
+    chainHub, // TODO POLA
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: dckc
body: or pass `(...args) => chainHub.resolveAccountId(..args)` instead
created_at: 2025-04-22T00:57:44Z
author_association: MEMBER
diff_hunk: @@ -195,11 +197,11 @@ export const prepareSettler = (
         ),
       }),
       self: M.interface('SettlerSelfI', {
-        addMintedEarly: M.call(M.string(), M.nat()).returns(),
-        disburse: M.call(EvmHashShape, M.nat(), M.string()).returns(
+        addMintedEarly: M.call(M.string(), UsdcAmountShape).returns(),
+        disburse: M.call(EvmHashShape, UsdcAmountShape, M.string()).returns(
           M.promise(),
         ),
-        forward: M.call(EvmHashShape, M.nat(), M.string()).returns(),
+        forward: M.call(EvmHashShape, UsdcAmountShape, M.string()).returns(),
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: mhofman
body: Curious, is tsc catching this breakage?
created_at: 2025-04-22T04:09:57Z
author_association: MEMBER
diff_hunk: @@ -24,17 +25,17 @@ export const prepareMockOrchAccounts = (
   },
 ) => {
   // each can only be resolved/rejected once per test
-  const poolAccountSendVK = makeVowKit<undefined>();
-  const poolAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountSendVK = makeVowKit<undefined>();
-  const intermediateAccountTransferVK = makeVowKit<undefined>();
-  const intermediateAccountDepositForBurnVK = makeVowKit<undefined>();
+  const poolAccountSendPK = makePromiseKit<void>();
+  const poolAccountTransferPK = makePromiseKit<void>();
+  const settleAccountTransferPK = makePromiseKit<void>();
+  const settleAccountSendPK = makePromiseKit<void>();
+  const intermediateAccountTransferPK = makePromiseKit<void>();
+  const intermediateAccountDepositForBurnPK = makePromiseKit<void>();
path: packages/fast-usdc-contract/test/mocks.ts
user: login: 0xpatrickdev
body: Trying to understand this change more. I think these should still be VowKit's?
created_at: 2025-04-22T14:38:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,17 +25,17 @@ export const prepareMockOrchAccounts = (
   },
 ) => {
   // each can only be resolved/rejected once per test
-  const poolAccountSendVK = makeVowKit<undefined>();
-  const poolAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountSendVK = makeVowKit<undefined>();
-  const intermediateAccountTransferVK = makeVowKit<undefined>();
-  const intermediateAccountDepositForBurnVK = makeVowKit<undefined>();
+  const poolAccountSendPK = makePromiseKit<void>();
+  const poolAccountTransferPK = makePromiseKit<void>();
+  const settleAccountTransferPK = makePromiseKit<void>();
+  const settleAccountSendPK = makePromiseKit<void>();
+  const intermediateAccountTransferPK = makePromiseKit<void>();
+  const intermediateAccountDepositForBurnPK = makePromiseKit<void>();
path: packages/fast-usdc-contract/test/mocks.ts
user: login: turadg
body: No, because the need to provide the promises that the async-flow expects. Previously it was a vow handler in Settler, which didn't go through a membrane. Now it's an async flow which when integrated goes through a membrane but in this unit test does not.

I tried to document this in the comment above. Edit suggestions welcomed.
created_at: 2025-04-22T15:27:00Z
author_association: MEMBER
diff_hunk: @@ -24,17 +25,17 @@ export const prepareMockOrchAccounts = (
   },
 ) => {
   // each can only be resolved/rejected once per test
-  const poolAccountSendVK = makeVowKit<undefined>();
-  const poolAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountTransferVK = makeVowKit<undefined>();
-  const settleAccountSendVK = makeVowKit<undefined>();
-  const intermediateAccountTransferVK = makeVowKit<undefined>();
-  const intermediateAccountDepositForBurnVK = makeVowKit<undefined>();
+  const poolAccountSendPK = makePromiseKit<void>();
+  const poolAccountTransferPK = makePromiseKit<void>();
+  const settleAccountTransferPK = makePromiseKit<void>();
+  const settleAccountSendPK = makePromiseKit<void>();
+  const intermediateAccountTransferPK = makePromiseKit<void>();
+  const intermediateAccountDepositForBurnPK = makePromiseKit<void>();
path: packages/fast-usdc-contract/test/mocks.ts
user: login: turadg
body: Oh, the comment is below,
```js
    // These each have VResolver for "vow" resolver. The mocks actually
    // deal in promises but the flow that awaits them expects that it's actually
    // awaiting a vow (made by the membrane to look like a promise).
```
created_at: 2025-04-22T15:36:44Z
author_association: MEMBER
diff_hunk: @@ -412,81 +419,47 @@ export const prepareSettler = (
          * Funds were not advanced. Forward proceeds to the payee directly.
          *
          * @param {EvmHash} txHash
-         * @param {NatValue} fullValue
+         * @param {NatAmount} fullValue
          * @param {string} EUD
          */
         forward(
           txHash: EvmHash,
-          fullValue: NatValue,
+          fullValue: NatAmount,
           EUD: AccountId | Bech32Address,
         ) {
-          const { settlementAccount } = this.state;
-          log('forwarding', fullValue, 'to', EUD, 'for', txHash);
+          log('forwarding', fullValue.value, 'to', EUD, 'for', txHash);
 
           const dest: AccountId | null = (() => {
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwarded(txHash, false);
+              log(
+                '⚠️ forward not attempted',
+                'unresolvable destination',
+                txHash,
+                EUD,
+              );
+              statusManager.forwardSkipped(txHash);
               return null;
             }
           })();
           if (!dest) return;
 
-          const { namespace, reference } = parseAccountId(dest);
-          const amt = AmountMath.make(USDC, fullValue);
-
-          const intermediateRecipient = getNobleICA().getAddress();
-
-          if (namespace === 'cosmos') {
-            const transferOrSendV =
-              reference === currentChainReference
-                ? E(settlementAccount).send(dest, amt)
-                : E(settlementAccount).transfer(dest, amt, {
-                    timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-                    forwardOpts: {
-                      intermediateRecipient,
-                      timeout: FORWARD_TIMEOUT.p,
-                    },
-                  });
-            void vowTools.watch(
-              transferOrSendV,
-              this.facets.transferHandler,
-              txHash,
-            );
-          } else if (supportsCctp(dest)) {
-            // send to Noble then call `.depositForBurn(dest, amt)`
-            void vowTools.watch(
-              E(settlementAccount).transfer(intermediateRecipient, amt, {
-                timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-              }),
-              this.facets.intermediateTransferHandler,
-              { amt, dest, txHash },
-            );
-          } else {
-            log(
-              '🚨 forward not attempted!',
-              'unsupported destination',
-              txHash,
-              dest,
-            );
-            statusManager.forwarded(txHash, false);
-          }
+          void forwardFunds({ txHash, amount: fullValue, destination: dest });
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: mhofman
body: Maybe add a comment here that `forwardFunds` does error handling so a `void` here is safe?
created_at: 2025-04-22T17:25:20Z
author_association: MEMBER
diff_hunk: @@ -412,81 +419,47 @@ export const prepareSettler = (
          * Funds were not advanced. Forward proceeds to the payee directly.
          *
          * @param {EvmHash} txHash
-         * @param {NatValue} fullValue
+         * @param {NatAmount} fullValue
          * @param {string} EUD
          */
         forward(
           txHash: EvmHash,
-          fullValue: NatValue,
+          fullValue: NatAmount,
           EUD: AccountId | Bech32Address,
         ) {
-          const { settlementAccount } = this.state;
-          log('forwarding', fullValue, 'to', EUD, 'for', txHash);
+          log('forwarding', fullValue.value, 'to', EUD, 'for', txHash);
 
           const dest: AccountId | null = (() => {
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwarded(txHash, false);
+              log(
+                '⚠️ forward not attempted',
+                'unresolvable destination',
+                txHash,
+                EUD,
+              );
+              statusManager.forwardSkipped(txHash);
               return null;
             }
           })();
           if (!dest) return;
 
-          const { namespace, reference } = parseAccountId(dest);
-          const amt = AmountMath.make(USDC, fullValue);
-
-          const intermediateRecipient = getNobleICA().getAddress();
-
-          if (namespace === 'cosmos') {
-            const transferOrSendV =
-              reference === currentChainReference
-                ? E(settlementAccount).send(dest, amt)
-                : E(settlementAccount).transfer(dest, amt, {
-                    timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-                    forwardOpts: {
-                      intermediateRecipient,
-                      timeout: FORWARD_TIMEOUT.p,
-                    },
-                  });
-            void vowTools.watch(
-              transferOrSendV,
-              this.facets.transferHandler,
-              txHash,
-            );
-          } else if (supportsCctp(dest)) {
-            // send to Noble then call `.depositForBurn(dest, amt)`
-            void vowTools.watch(
-              E(settlementAccount).transfer(intermediateRecipient, amt, {
-                timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-              }),
-              this.facets.intermediateTransferHandler,
-              { amt, dest, txHash },
-            );
-          } else {
-            log(
-              '🚨 forward not attempted!',
-              'unsupported destination',
-              txHash,
-              dest,
-            );
-            statusManager.forwarded(txHash, false);
-          }
+          void forwardFunds({ txHash, amount: fullValue, destination: dest });
         },
       },
       transferHandler: {
         onFulfilled(_result: unknown, txHash: EvmHash) {
           // update status manager, marking tx `FORWARDED` without fee split
-          statusManager.forwarded(txHash, true);
+          statusManager.forwarded(txHash);
         },
         onRejected(reason: unknown, txHash: EvmHash) {
           // funds remain in `settlementAccount` and must be recovered via a
           // contract upgrade
           log('🚨 forward transfer rejected!', reason, txHash);
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: mhofman
body: I assume this part will be updated in the follow-up ?
created_at: 2025-04-22T17:27:34Z
author_association: MEMBER
diff_hunk: @@ -133,16 +135,43 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  const supportsCctp = makeSupportsCctp(chainHub);
+
+  const { makeLocalAccount, makeNobleAccount } = orchestrateAll(
+    {
+      // TODO flow picker for orchestrateAll with different contexts or ordering
+      makeLocalAccount: flows.makeLocalAccount,
+      makeNobleAccount: flows.makeNobleAccount,
+    },
+    {},
+  );
+
+  const poolAccountV = zone.makeOnce('PoolAccount', () => makeLocalAccount());
+  const settleAccountV = zone.makeOnce('SettleAccount', () =>
+    makeLocalAccount(),
+  );
+  const { forwardFunds } = orchestrateAll(
+    // @ts-expect-error flow membrance type debt
+    { forwardFunds: flows.forwardFunds },
+    {
+      currentChainReference: privateArgs.chainInfo.agoric.chainId,
+      getNobleICA,
path: packages/fast-usdc-contract/src/fast-usdc.contract.ts
user: login: mhofman
body: There is logic in `orchestrate` to wrap function endowments. The arguments and return values must be durable compatible.
created_at: 2025-04-22T19:08:24Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: FYI a new tracer not going through the membrane will result in flow replays to produce log output again. Is that intended?
created_at: 2025-04-22T19:13:16Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,30 @@
-import type { OrchestrationFlow, Orchestrator } from '@agoric/orchestration';
+import type { NatAmount } from '@agoric/ertp';
+import type { EvmHash } from '@agoric/fast-usdc/src/types.ts';
+import type {
+  AccountId,
+  OrchestrationAccount,
+  OrchestrationFlow,
+  Orchestrator,
+} from '@agoric/orchestration';
+import { parseAccountId } from '@agoric/orchestration/src/utils/address.js';
+import { assertAllDefined, makeTracer } from '@agoric/internal';
+import type { StatusManager } from './exos/status-manager.ts';
+
+const FORWARD_TIMEOUT = {
+  sec: 10n * 60n,
+  p: '10m',
+} as const;
+harden(FORWARD_TIMEOUT);
+
+export interface Context {
+  /** e.g., `agoric-3` */
+  currentChainReference: string;
+  supportsCctp: (destination: AccountId) => boolean;
+  log?: Console['log'];
+  statusManager: StatusManager;
+  getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
+  settlementAccount: Promise<OrchestrationAccount<{ chainId: 'agoric-any' }>>;
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: > "durably stored promise" kit abstraction

That would be a VowKit, no?
created_at: 2025-04-22T19:21:11Z
author_association: MEMBER
diff_hunk: @@ -133,16 +135,59 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  /** Chain, connection, and asset info can only be registered once */
+  const firstIncarnationKey = 'firstIncarnationKey';
+  // Perform this before makeLocalAccount() is called or it will cause
+  // `agoric` chain info to be pulled from agoricNames.
+  // UNTIL https://github.com/Agoric/agoric-sdk/issues/10602
+  if (!baggage.has(firstIncarnationKey)) {
+    baggage.init(firstIncarnationKey, true);
+    registerChainsAndAssets(
+      chainHub,
+      terms.brands,
+      privateArgs.chainInfo,
+      privateArgs.assetInfo,
+    );
+  }
path: packages/fast-usdc-contract/src/fast-usdc.contract.ts
user: login: mhofman
body: I know this is a refactor / move, but why didn't we use a `zone.makeOnce` for this originally?

I actually believe that `makeOnce` would be compatible with the durable data created by this pattern, you just need a zone for the root baggage (which the `zone` above isn't as it's the nested `contract`'s one)
created_at: 2025-04-22T19:23:59Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: Is there any way this can fail ?

Previously `transferHandler` would handle any rejection, here it no longer is.
created_at: 2025-04-22T21:17:38Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: Is there any way there could be an identical reference in a non-cosmos chain? Previously the `currentChainReference` was only checked for the cosmos namespace.
created_at: 2025-04-22T21:21:37Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  } else if (namespace === 'cosmos') {
+    try {
+      await settlement.transfer(destination, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+        forwardOpts: {
+          intermediateRecipient,
+          timeout: FORWARD_TIMEOUT.p,
+        },
+      });
+      log('forward retry successful for', txHash);
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: This can be a first time forward attempt too, no ? Actually right now without retry it can only be a first time. Why "retry" ?
created_at: 2025-04-22T21:22:21Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  } else if (namespace === 'cosmos') {
+    try {
+      await settlement.transfer(destination, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+        forwardOpts: {
+          intermediateRecipient,
+          timeout: FORWARD_TIMEOUT.p,
+        },
+      });
+      log('forward retry successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs to be
+      // manual intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else if (supportsCctp(destination)) {
+    try {
+      await settlement.transfer(intermediateRecipient, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+      });
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward intermediate transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+
+    try {
+      await getNobleICA().depositForBurn(destination, amount);
+      log('forward transfer and depositForBurn successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `nobleAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward depositForBurn rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: I know this is just duplication of the code in `depositForBurnHandler.onRejected`, but this seems like a different failed state that the others where the funds are no longer in the local chain. I assume this will require a different retry mechanism?
created_at: 2025-04-22T21:30:44Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  } else if (namespace === 'cosmos') {
+    try {
+      await settlement.transfer(destination, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+        forwardOpts: {
+          intermediateRecipient,
+          timeout: FORWARD_TIMEOUT.p,
+        },
+      });
+      log('forward retry successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs to be
+      // manual intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else if (supportsCctp(destination)) {
+    try {
+      await settlement.transfer(intermediateRecipient, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+      });
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward intermediate transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+
+    try {
+      await getNobleICA().depositForBurn(destination, amount);
+      log('forward transfer and depositForBurn successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `nobleAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward depositForBurn rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else {
+    log(
+      '⚠️ forward not attempted',
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: Why is this just a warning, this feels more fatal than the failed forwards (which will soon be retried)
created_at: 2025-04-22T21:31:52Z
author_association: MEMBER
diff_hunk: @@ -102,17 +104,33 @@ const makeTestContext = async t => {
     chainId: `${cctpChainInfo.ethereum.namespace}:${cctpChainInfo.ethereum.reference}`,
   });
 
+  const forwardFunds = tx =>
+    flows.forwardFunds(
+      undefined as any,
+      {
+        log, // some tests check the log calls
+        currentChainReference: 'agoric-3',
+        supportsCctp: makeSupportsCctp(chainHub),
+        statusManager,
+        getNobleICA: () => mockAccounts.intermediate.account as any,
+        settlementAccount: Promise.resolve(
+          mockAccounts.settlement.account as any,
+        ),
+      },
+      tx,
+    );
+
path: packages/fast-usdc-contract/test/exos/settler.test.ts
user: login: mhofman
body: I don't understand why [`orchestrate`](https://github.com/Agoric/agoric-sdk/blob/33eb3c9c00816b734f69a252391c48d461bf622f/packages/orchestration/src/facade.js#L69-L92) as created by `makeOrchestrationFacade` wouldn't work and be better fidelity.

There doesn't seem to be any mock endowments incompatible with async-flow, and it would avoid impedance mismatch between real objects (e.g. statusManager), and the flow's logic.
created_at: 2025-04-22T21:46:56Z
author_association: MEMBER
diff_hunk: @@ -1622,9 +1633,13 @@ Generated by [AVA](https://avajs.dev).
         },
         LogStore_kindHandle: 'Alleged: kind',
         StateUnwrapper_kindHandle: 'Alleged: kind',
-        asyncFuncEagerWakers: [],
+        asyncFuncEagerWakers: [
+          Object @Alleged: asyncFlow flow {},
+        ],
         asyncFuncFailures: {},
-        flowForOutcomeVow: {},
+        flowForOutcomeVow: {
+          'Alleged: VowInternalsKit vowV0': 'Alleged: asyncFlow flow',
+        },
path: packages/fast-usdc-contract/test/snapshots/fast-usdc.contract.test.ts.md
user: login: mhofman
body: They are internal state for async flow.
created_at: 2025-04-22T21:49:24Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  } else if (namespace === 'cosmos') {
+    try {
+      await settlement.transfer(destination, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+        forwardOpts: {
+          intermediateRecipient,
+          timeout: FORWARD_TIMEOUT.p,
+        },
+      });
+      log('forward retry successful for', txHash);
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: From when the scope of the change was the retry feature instead of just a refactor. I'll revise
created_at: 2025-04-22T22:23:01Z
author_association: MEMBER
diff_hunk: @@ -133,16 +135,59 @@ export const contract = async (
   const getNobleICA = (): OrchestrationAccount<{ chainId: 'noble-1' }> =>
     baggage.get(NOBLE_ICA_BAGGAGE_KEY);
 
+  /** Chain, connection, and asset info can only be registered once */
+  const firstIncarnationKey = 'firstIncarnationKey';
+  // Perform this before makeLocalAccount() is called or it will cause
+  // `agoric` chain info to be pulled from agoricNames.
+  // UNTIL https://github.com/Agoric/agoric-sdk/issues/10602
+  if (!baggage.has(firstIncarnationKey)) {
+    baggage.init(firstIncarnationKey, true);
+    registerChainsAndAssets(
+      chainHub,
+      terms.brands,
+      privateArgs.chainInfo,
+      privateArgs.assetInfo,
+    );
+  }
path: packages/fast-usdc-contract/src/fast-usdc.contract.ts
user: login: turadg
body: I suppose because nothing is being made. I think you're right that it would have been better but I bet that's why it wasn't reached for. We could consider a `zone.doOnce`
created_at: 2025-04-22T22:24:55Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  } else if (namespace === 'cosmos') {
+    try {
+      await settlement.transfer(destination, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+        forwardOpts: {
+          intermediateRecipient,
+          timeout: FORWARD_TIMEOUT.p,
+        },
+      });
+      log('forward retry successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs to be
+      // manual intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else if (supportsCctp(destination)) {
+    try {
+      await settlement.transfer(intermediateRecipient, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+      });
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward intermediate transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+
+    try {
+      await getNobleICA().depositForBurn(destination, amount);
+      log('forward transfer and depositForBurn successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `nobleAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward depositForBurn rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: yes. retry is out of scope for this PR
created_at: 2025-04-22T22:25:57Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
+    statusManager.forwarded(txHash);
+  } else if (namespace === 'cosmos') {
+    try {
+      await settlement.transfer(destination, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+        forwardOpts: {
+          intermediateRecipient,
+          timeout: FORWARD_TIMEOUT.p,
+        },
+      });
+      log('forward retry successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs to be
+      // manual intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else if (supportsCctp(destination)) {
+    try {
+      await settlement.transfer(intermediateRecipient, amount, {
+        timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
+      });
+    } catch (reason) {
+      // funds remain in `settlementAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward intermediate transfer rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+
+    try {
+      await getNobleICA().depositForBurn(destination, amount);
+      log('forward transfer and depositForBurn successful for', txHash);
+      statusManager.forwarded(tx.txHash);
+    } catch (reason) {
+      // funds remain in `nobleAccount` and must be recovered via a
+      // contract upgrade
+      log('🚨 forward depositForBurn rejected!', reason, txHash);
+      // update status manager, flagging a terminal state that needs manual
+      // intervention or a code update to remediate
+      statusManager.forwardFailed(txHash);
+    }
+  } else {
+    log(
+      '⚠️ forward not attempted',
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: because the siren is for stuff that should never happen and someone sending a bad EUD is not worth waking anyone
created_at: 2025-04-22T22:26:33Z
author_association: MEMBER
diff_hunk: @@ -102,17 +104,33 @@ const makeTestContext = async t => {
     chainId: `${cctpChainInfo.ethereum.namespace}:${cctpChainInfo.ethereum.reference}`,
   });
 
+  const forwardFunds = tx =>
+    flows.forwardFunds(
+      undefined as any,
+      {
+        log, // some tests check the log calls
+        currentChainReference: 'agoric-3',
+        supportsCctp: makeSupportsCctp(chainHub),
+        statusManager,
+        getNobleICA: () => mockAccounts.intermediate.account as any,
+        settlementAccount: Promise.resolve(
+          mockAccounts.settlement.account as any,
+        ),
+      },
+      tx,
+    );
+
path: packages/fast-usdc-contract/test/exos/settler.test.ts
user: login: turadg
body: I don't think anyone is claiming it wouldn't work or be higher fidelity. Those aren't the only two criteria.

I'm trying to ship features to users. This is a test. It's testing what I need it to.
created_at: 2025-04-22T22:29:24Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: Helpful catch, thanks! Perhaps we should avoid default args in flow params?
created_at: 2025-04-22T22:35:47Z
author_association: MEMBER
diff_hunk: @@ -412,81 +419,47 @@ export const prepareSettler = (
          * Funds were not advanced. Forward proceeds to the payee directly.
          *
          * @param {EvmHash} txHash
-         * @param {NatValue} fullValue
+         * @param {NatAmount} fullValue
          * @param {string} EUD
          */
         forward(
           txHash: EvmHash,
-          fullValue: NatValue,
+          fullValue: NatAmount,
           EUD: AccountId | Bech32Address,
         ) {
-          const { settlementAccount } = this.state;
-          log('forwarding', fullValue, 'to', EUD, 'for', txHash);
+          log('forwarding', fullValue.value, 'to', EUD, 'for', txHash);
 
           const dest: AccountId | null = (() => {
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwarded(txHash, false);
+              log(
+                '⚠️ forward not attempted',
+                'unresolvable destination',
+                txHash,
+                EUD,
+              );
+              statusManager.forwardSkipped(txHash);
               return null;
             }
           })();
           if (!dest) return;
 
-          const { namespace, reference } = parseAccountId(dest);
-          const amt = AmountMath.make(USDC, fullValue);
-
-          const intermediateRecipient = getNobleICA().getAddress();
-
-          if (namespace === 'cosmos') {
-            const transferOrSendV =
-              reference === currentChainReference
-                ? E(settlementAccount).send(dest, amt)
-                : E(settlementAccount).transfer(dest, amt, {
-                    timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-                    forwardOpts: {
-                      intermediateRecipient,
-                      timeout: FORWARD_TIMEOUT.p,
-                    },
-                  });
-            void vowTools.watch(
-              transferOrSendV,
-              this.facets.transferHandler,
-              txHash,
-            );
-          } else if (supportsCctp(dest)) {
-            // send to Noble then call `.depositForBurn(dest, amt)`
-            void vowTools.watch(
-              E(settlementAccount).transfer(intermediateRecipient, amt, {
-                timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-              }),
-              this.facets.intermediateTransferHandler,
-              { amt, dest, txHash },
-            );
-          } else {
-            log(
-              '🚨 forward not attempted!',
-              'unsupported destination',
-              txHash,
-              dest,
-            );
-            statusManager.forwarded(txHash, false);
-          }
+          void forwardFunds({ txHash, amount: fullValue, destination: dest });
         },
       },
       transferHandler: {
         onFulfilled(_result: unknown, txHash: EvmHash) {
           // update status manager, marking tx `FORWARDED` without fee split
-          statusManager.forwarded(txHash, true);
+          statusManager.forwarded(txHash);
         },
         onRejected(reason: unknown, txHash: EvmHash) {
           // funds remain in `settlementAccount` and must be recovered via a
           // contract upgrade
           log('🚨 forward transfer rejected!', reason, txHash);
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: turadg
body: yes, the PR that implements retry
created_at: 2025-04-22T22:43:21Z
author_association: MEMBER
diff_hunk: @@ -412,81 +419,47 @@ export const prepareSettler = (
          * Funds were not advanced. Forward proceeds to the payee directly.
          *
          * @param {EvmHash} txHash
-         * @param {NatValue} fullValue
+         * @param {NatAmount} fullValue
          * @param {string} EUD
          */
         forward(
           txHash: EvmHash,
-          fullValue: NatValue,
+          fullValue: NatAmount,
           EUD: AccountId | Bech32Address,
         ) {
-          const { settlementAccount } = this.state;
-          log('forwarding', fullValue, 'to', EUD, 'for', txHash);
+          log('forwarding', fullValue.value, 'to', EUD, 'for', txHash);
 
           const dest: AccountId | null = (() => {
             try {
               return chainHub.resolveAccountId(EUD);
             } catch (e) {
-              log('⚠️ forward transfer failed!', e, txHash);
-              statusManager.forwarded(txHash, false);
+              log(
+                '⚠️ forward not attempted',
+                'unresolvable destination',
+                txHash,
+                EUD,
+              );
+              statusManager.forwardSkipped(txHash);
               return null;
             }
           })();
           if (!dest) return;
 
-          const { namespace, reference } = parseAccountId(dest);
-          const amt = AmountMath.make(USDC, fullValue);
-
-          const intermediateRecipient = getNobleICA().getAddress();
-
-          if (namespace === 'cosmos') {
-            const transferOrSendV =
-              reference === currentChainReference
-                ? E(settlementAccount).send(dest, amt)
-                : E(settlementAccount).transfer(dest, amt, {
-                    timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-                    forwardOpts: {
-                      intermediateRecipient,
-                      timeout: FORWARD_TIMEOUT.p,
-                    },
-                  });
-            void vowTools.watch(
-              transferOrSendV,
-              this.facets.transferHandler,
-              txHash,
-            );
-          } else if (supportsCctp(dest)) {
-            // send to Noble then call `.depositForBurn(dest, amt)`
-            void vowTools.watch(
-              E(settlementAccount).transfer(intermediateRecipient, amt, {
-                timeoutRelativeSeconds: FORWARD_TIMEOUT.sec,
-              }),
-              this.facets.intermediateTransferHandler,
-              { amt, dest, txHash },
-            );
-          } else {
-            log(
-              '🚨 forward not attempted!',
-              'unsupported destination',
-              txHash,
-              dest,
-            );
-            statusManager.forwarded(txHash, false);
-          }
+          void forwardFunds({ txHash, amount: fullValue, destination: dest });
path: packages/fast-usdc-contract/src/exos/settler.ts
user: login: turadg
body: Good call. That's generally what `void` implies but it's worth being explicit, especially since it returns a vow and not a promise
created_at: 2025-04-22T22:43:47Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,30 @@
-import type { OrchestrationFlow, Orchestrator } from '@agoric/orchestration';
+import type { NatAmount } from '@agoric/ertp';
+import type { EvmHash } from '@agoric/fast-usdc/src/types.ts';
+import type {
+  AccountId,
+  OrchestrationAccount,
+  OrchestrationFlow,
+  Orchestrator,
+} from '@agoric/orchestration';
+import { parseAccountId } from '@agoric/orchestration/src/utils/address.js';
+import { assertAllDefined, makeTracer } from '@agoric/internal';
+import type { StatusManager } from './exos/status-manager.ts';
+
+const FORWARD_TIMEOUT = {
+  sec: 10n * 60n,
+  p: '10m',
+} as const;
+harden(FORWARD_TIMEOUT);
+
+export interface Context {
+  /** e.g., `agoric-3` */
+  currentChainReference: string;
+  supportsCctp: (destination: AccountId) => boolean;
+  log?: Console['log'];
+  statusManager: StatusManager;
+  getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
+  settlementAccount: Promise<OrchestrationAccount<{ chainId: 'agoric-any' }>>;
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: I meant a regular promise that gets durably stored, like is happening here. "durably stored promise result kit" may have been clearer.
created_at: 2025-04-22T22:51:44Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
+    await settlement.send(destination, amount);
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: great catch, thanks
created_at: 2025-04-22T22:52:46Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: Default args made from other args would be fine. I think the tell-tale sign here is that `makeTracer` is in the surrounding scope and is not a pure function. Logging in general can be considered exempt from the closed over rules, but in this case I don't think it should.
created_at: 2025-04-22T22:53:57Z
author_association: MEMBER
diff_hunk: @@ -11,3 +37,97 @@ export const makeNobleAccount = (async (orch: Orchestrator) => {
   return nobleChain.makeAccount();
 }) satisfies OrchestrationFlow;
 harden(makeNobleAccount);
+
+export const forwardFunds = async (
+  orch: Orchestrator,
+  {
+    currentChainReference,
+    supportsCctp,
+    log = makeTracer('FlowForwardFunds'),
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+  }: Context,
+  tx: {
+    txHash: EvmHash;
+    amount: NatAmount;
+    destination: AccountId;
+  },
+) => {
+  await null;
+  assertAllDefined({
+    currentChainReference,
+    supportsCctp,
+    log,
+    getNobleICA,
+    settlementAccount,
+    statusManager,
+    tx,
+  });
+  const { amount, destination, txHash } = tx;
+  log('trying forward for', amount, 'to', destination, 'for', txHash);
+
+  const { namespace, reference } = parseAccountId(destination);
+
+  const settlement = await settlementAccount;
+  const intermediateRecipient = getNobleICA().getAddress();
+
+  if (reference === currentChainReference) {
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: turadg
body: Good thinking. It's exceeding unlikely that any other ecosystem would have `agoric-3` but it's not an assumption we have to make. I'll revise
created_at: 2025-04-22T22:55:30Z
author_association: MEMBER
diff_hunk: @@ -1,4 +1,30 @@
-import type { OrchestrationFlow, Orchestrator } from '@agoric/orchestration';
+import type { NatAmount } from '@agoric/ertp';
+import type { EvmHash } from '@agoric/fast-usdc/src/types.ts';
+import type {
+  AccountId,
+  OrchestrationAccount,
+  OrchestrationFlow,
+  Orchestrator,
+} from '@agoric/orchestration';
+import { parseAccountId } from '@agoric/orchestration/src/utils/address.js';
+import { assertAllDefined, makeTracer } from '@agoric/internal';
+import type { StatusManager } from './exos/status-manager.ts';
+
+const FORWARD_TIMEOUT = {
+  sec: 10n * 60n,
+  p: '10m',
+} as const;
+harden(FORWARD_TIMEOUT);
+
+export interface Context {
+  /** e.g., `agoric-3` */
+  currentChainReference: string;
+  supportsCctp: (destination: AccountId) => boolean;
+  log?: Console['log'];
+  statusManager: StatusManager;
+  getNobleICA: () => OrchestrationAccount<{ chainId: 'noble-1' }>;
+  settlementAccount: Promise<OrchestrationAccount<{ chainId: 'agoric-any' }>>;
path: packages/fast-usdc-contract/src/fast-usdc.flows.ts
user: login: mhofman
body: native promises cannot be durably stored. The design for durable promises puts them more in line with vows than promises.

I think I still don't understand what "durably stored promise result kit" means.
created_at: 2025-04-22T22:57:19Z
author_association: MEMBER

PR comments:
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-04-21T23:25:29Z
body: ## Deploying agoric-sdk with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>bcecfdb</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://d0d6af8c.agoric-sdk.pages.dev'>https://d0d6af8c.agoric-sdk.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://309-forwardskipped.agoric-sdk.pages.dev'>https://309-forwardskipped.agoric-sdk.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/0c4635effffcd7f36d1b9f0425a4367a/pages/view/agoric-sdk/d0d6af8c-a587-4a53-affc-2859fb07a739)

author_association: NONE
user: login: turadg
created_at: 2025-04-22T12:36:32Z
body: [This failure](https://github.com/Agoric/agoric-sdk/actions/runs/14585176379/job/40909336761?pr=11307) is one I haven't seen before:
```log
2025-04-22T02:30:32.858Z SwingSet: vat: v44: chainHub: registering chains [ 'agoric', 'ethereum', 'noble', 'osmosis', 'unreachableChain' ]
2025-04-22T02:30:32.864Z SwingSet: ls: v44: Logging sent error stack (Error#1)
2025-04-22T02:30:32.864Z SwingSet: ls: v44: Error#1: key agoric already registered in collection chainInfos
```

This doesn't have any changes I can see causing that so I'll run again and see if it's a rare flake.
author_association: MEMBER
user: login: 0xpatrickdev
created_at: 2025-04-22T14:18:04Z
body: > [This failure](https://github.com/Agoric/agoric-sdk/actions/runs/14585176379/job/40909336761?pr=11307) is one I haven't seen before:
> 
> ```
> 2025-04-22T02:30:32.858Z SwingSet: vat: v44: chainHub: registering chains [ 'agoric', 'ethereum', 'noble', 'osmosis', 'unreachableChain' ]
> 2025-04-22T02:30:32.864Z SwingSet: ls: v44: Logging sent error stack (Error#1)
> 2025-04-22T02:30:32.864Z SwingSet: ls: v44: Error#1: key agoric already registered in collection chainInfos
> ```

I think we need to lift the call to `registerChainsAndAssets(...)`. `chainHub.getChainInfo('agoric')` gets called during  `makeLocalAccount()` and will populate the `"agoric"` entry using vstorage if nothing is present. 

Current HEAD:
https://github.com/Agoric/agoric-sdk/blob/f80bb26afd3941d14a34ff01ecdfa29913ee4e47/packages/fast-usdc-contract/src/fast-usdc.contract.ts#L297-L313

author_association: CONTRIBUTOR
user: login: turadg
created_at: 2025-04-22T15:27:28Z
body: > will populate the `"agoric"` entry using vstorage if nothing is present

Thanks for the diagnosis. I'll try your work-around.

Should we schedule this?
- https://github.com/Agoric/agoric-sdk/issues/10602
author_association: MEMBER
user: login: 0xpatrickdev
created_at: 2025-04-22T15:58:07Z
body: > > will populate the `"agoric"` entry using vstorage if nothing is present
> 
> Thanks for the diagnosis. I'll try your work-around.
> 
> Should we schedule this?
> 
> * [`ChainHub` methods should be sync #10602](https://github.com/Agoric/agoric-sdk/issues/10602)

No problem but I wouldn't call it a workaround - we must register info in `ChainHub` before we try to use it.

And yes we might consider prioritizing #10602. I also just created https://github.com/Agoric/agoric-sdk/issues/11310 which would help surface this issue.  Instead of seeing **_Error: key agoric already registered in collection chainInfos_,** we'd see **_Error: chain not found: agoric_**
author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-04-22T19:20:35Z
body: This pull request has been removed from the queue for the following reason: <code>checks failed</code>.

The merge conditions cannot be satisfied due to failing checks:
- ❌ [integration-test-result](https://github.com/Agoric/agoric-sdk/runs/40963249825)
- ☑️ [linear-history](https://github.com/Agoric/agoric-sdk/actions/runs/14602333550/job/40963241892)


You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a `@mergifyio requeue` comment.

author_association: CONTRIBUTOR
user: login: mergify[bot]
created_at: 2025-04-22T23:30:18Z
body: This pull request has been removed from the queue for the following reason: <code>checks failed</code>.

The merge conditions cannot be satisfied due to failing checks:
- ❌ [no-fixup-commits](https://github.com/Agoric/agoric-sdk/actions/runs/14606546137/job/40976619830)
- ☑️ [linear-history](https://github.com/Agoric/agoric-sdk/actions/runs/14606546137/job/40976619150)


You may have to fix your CI before adding the pull request to the queue again.
If you update this pull request, to fix the CI, it will automatically be requeued once the queue conditions match again.
If you think this was a flaky issue instead, you can requeue the pull request, without updating it, by posting a `@mergifyio requeue` comment.

author_association: CONTRIBUTOR