Skip to content

Commit 6945e71

Browse files
authored
Fix: Remove automatic flow unwinding (#12129)
## Description For Beta, re: automated recovery by the contract is unnecessary complexity And error in a step would trigger reverse steps for all the successful transactions. This is slow and potentially expensive (since steps may cost time and money). Instead, we should stop, and a future rebalance operation will compute from the new state. This changes the failure mode to just record the failure and exit. ### Security Considerations None. ### Scaling Considerations Better because it does less thrashing if there is for example a temporary outage. ### Documentation Considerations Users will see the failure, but will not necessarily know what to do. We should explain that a future rebalance will complete the deployment. - #12131 ### Testing Considerations This skips the old tests that verified recovery execution, since that's not longer appropriate. It is a separate task to revisit these and see whether they are interesting. ### Upgrade Considerations No change to stored state.
2 parents 7d8caaa + 6bab8f4 commit 6945e71

File tree

6 files changed

+21
-132
lines changed

6 files changed

+21
-132
lines changed

packages/portfolio-contract/src/portfolio.flows.ts

Lines changed: 15 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,16 @@ type AssetMovement = {
119119
accounts: AccountsByChain,
120120
tracer: TraceLogger,
121121
) => Promise<{ srcPos?: Position; destPos?: Position }>;
122-
recover: (accounts: AccountsByChain, tracer: TraceLogger) => Promise<void>;
123122
};
124123

125-
const moveStatus = ({ apply: _a, recover: _r, ...data }: AssetMovement) => data;
124+
const moveStatus = ({ apply: _a, ...data }: AssetMovement) => data;
126125
const errmsg = (err: any) => ('message' in err ? err.message : `${err}`);
127126

128127
export type TransportDetail<
129128
How extends string,
130129
S extends SupportedChain,
131130
D extends SupportedChain,
132131
CTX = unknown,
133-
RecoverCTX = CTX,
134132
> = {
135133
how: How;
136134
connections: { src: S; dest: D }[];
@@ -140,12 +138,6 @@ export type TransportDetail<
140138
src: AccountInfoFor[S],
141139
dest: AccountInfoFor[D],
142140
) => Promise<void>;
143-
recover: (
144-
ctx: RecoverCTX,
145-
amount: NatAmount,
146-
src: AccountInfoFor[S],
147-
dest: AccountInfoFor[D],
148-
) => Promise<void>;
149141
};
150142

151143
export type ProtocolDetail<
@@ -169,9 +161,9 @@ export type ProtocolDetail<
169161
};
170162

171163
/**
172-
* **Failure Handling**: Attempts to unwind failed operations, but recovery
173-
* itself can fail. In that case, publishes final asset location to vstorage
174-
* and gives up. Clients must manually rebalance to recover.
164+
* **Failure Handling**: Logs failures and publishes status without attempting
165+
* to unwind already-completed steps. Operators must resolve any partial
166+
* effects manually.
175167
*/
176168
const trackFlow = async (
177169
reporter: GuestInterface<PortfolioKit['reporter']>,
@@ -203,35 +195,15 @@ const trackFlow = async (
203195
reporter.publishFlowStatus(flowId, { state: 'done' });
204196
// TODO(#NNNN): delete the flow storage node
205197
} catch (err) {
206-
traceFlow('⚠️ step', step, ' failed', err);
198+
traceFlow('⚠️ step', step, 'failed', err);
207199
const failure = moves[step - 1];
208-
const errStep = step;
209-
while (step > 1) {
210-
step -= 1;
211-
const traceStep = traceFlow.sub(`step${step}`);
212-
const move = moves[step - 1];
213-
const how = `unwind: ${move.how}`;
214-
reporter.publishFlowStatus(flowId, { state: 'undo', step, how });
215-
try {
216-
await move.recover(accounts, traceStep);
217-
} catch (errInUnwind) {
218-
traceStep('⚠️ unwind failed', errInUnwind);
219-
// if a recover fails, we just give up and report `where` the assets are
220-
const { dest: where } = move;
221-
reporter.publishFlowStatus(flowId, {
222-
state: 'fail',
223-
step,
224-
how,
225-
error: errmsg(errInUnwind),
226-
where,
227-
});
228-
throw errInUnwind;
229-
}
200+
if (failure) {
201+
traceFlow('failed movement details', moveStatus(failure));
230202
}
231203
reporter.publishFlowStatus(flowId, {
232204
state: 'fail',
233-
step: errStep,
234-
how: failure.how,
205+
step,
206+
how: failure?.how ?? 'unknown',
235207
error: errmsg(err),
236208
});
237209
throw err;
@@ -517,24 +489,6 @@ const stepFlow = async (
517489
return { srcPos: pos };
518490
}
519491
},
520-
recover: async ({ [evmChain]: gInfo }) => {
521-
assert(gInfo, evmChain);
522-
await null;
523-
if ('src' in way) {
524-
assert.fail('last step. cannot recover');
525-
} else {
526-
const { lca } = agoric;
527-
const { poolKey } = way;
528-
const evmCtx = await makeEVMPoolCtx(
529-
evmChain,
530-
move,
531-
lca,
532-
poolKey,
533-
ctx.transferChannels.noble.counterPartyChannelId,
534-
);
535-
await pImpl.supply(evmCtx, amount, gInfo);
536-
}
537-
},
538492
});
539493
};
540494

@@ -544,8 +498,8 @@ const stepFlow = async (
544498
traceFlow('checking', moves.length, 'moves');
545499
for (const [i, move] of entries(moves)) {
546500
const traceMove = traceFlow.sub(`move${i}`);
547-
// @@@ traceMove('wayFromSrcToDesc?', move);
548501
const way = wayFromSrcToDesc(move);
502+
traceMove('plan', { move, way });
549503
const { amount } = move;
550504
switch (way.how) {
551505
case 'localTransfer': {
@@ -562,11 +516,6 @@ const stepFlow = async (
562516
await ctx.zoeTools.localTransfer(src.seat, account, amounts);
563517
return {};
564518
},
565-
recover: async ({ agoric }) => {
566-
const { lca, lcaIn } = agoric;
567-
const account = move.dest === '+agoric' ? lcaIn : lca;
568-
await ctx.zoeTools.withdrawToSeat(account, seat, amounts);
569-
},
570519
});
571520
break;
572521
}
@@ -582,9 +531,6 @@ const stepFlow = async (
582531
await ctx.zoeTools.withdrawToSeat(agoric.lca, seat, amounts);
583532
return {};
584533
},
585-
recover: async ({ agoric }) => {
586-
await ctx.zoeTools.localTransfer(seat, agoric.lca, amounts);
587-
},
588534
});
589535
break;
590536
}
@@ -600,9 +546,6 @@ const stepFlow = async (
600546
await lcaIn.send(lca.getAddress(), amount);
601547
return {};
602548
},
603-
recover: async () => {
604-
traceMove('recover send is noop; not sending back to deposit LCA');
605-
},
606549
});
607550
break;
608551

@@ -629,15 +572,6 @@ const stepFlow = async (
629572
}
630573
return {};
631574
},
632-
recover: async ({ agoric, noble }) => {
633-
assert(noble); // per nobleMentioned below
634-
await null;
635-
if (way.src === 'agoric') {
636-
await agoricToNoble.recover(ctxI, amount, agoric, noble);
637-
} else {
638-
await nobleToAgoric.recover(ctxI, amount, noble, agoric);
639-
}
640-
},
641575
});
642576

643577
break;
@@ -671,15 +605,6 @@ const stepFlow = async (
671605
}
672606
return {};
673607
},
674-
recover: async ({ [evmChain]: gInfo, agoric, noble }) => {
675-
assert(gInfo && noble, evmChain);
676-
await null;
677-
if (outbound) {
678-
await CCTP.recover(ctx, amount, noble, gInfo);
679-
} else {
680-
await CCTPfromEVM.recover(ctx, amount, gInfo, agoric);
681-
}
682-
},
683608
});
684609

685610
break;
@@ -709,15 +634,6 @@ const stepFlow = async (
709634
return { srcPos: pos };
710635
}
711636
},
712-
recover: async ({ noble }) => {
713-
assert(noble); // per nobleMentioned below
714-
await null;
715-
if (isSupply) {
716-
Fail`no recovery from supply (final step)`;
717-
} else {
718-
await protocolUSDN.supply(ctxU, amount, noble);
719-
}
720-
},
721637
});
722638

723639
break;
@@ -759,7 +675,7 @@ const stepFlow = async (
759675
});
760676
reporter.publishFlowSteps(
761677
flowId,
762-
todo.map(({ apply: _a, recover: _r, ...data }) => data),
678+
todo.map(({ apply: _a, ...data }) => data),
763679
);
764680

765681
const agoric = await provideCosmosAccount(orch, 'agoric', kit, traceFlow);
@@ -840,11 +756,12 @@ const stepFlow = async (
840756
* More generally: move assets as instructed by client.
841757
*
842758
* **Non-Atomic Operations**: Cross-chain flows are not atomic. If operations
843-
* fail partway through, assets may be left in intermediate accounts.
844-
* Recovery is attempted but can also fail, leaving assets "stranded".
759+
* fail partway through, assets may be left in intermediate accounts and must
760+
* be reconciled manually.
845761
*
846762
* **Client Recovery**: If rebalancing fails, check flow status in vstorage
847-
* and call rebalance() again to move assets to desired destinations.
763+
* and call rebalance() again (or another corrective flow) to move assets to
764+
* desired destinations.
848765
*
849766
* **Input Validation**: ASSUME caller validates args
850767
*

packages/portfolio-contract/src/pos-gmp.flows.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -168,16 +168,7 @@ export const CCTPfromEVM = {
168168
await Promise.all([result, contractCallP]);
169169
traceTransfer('transfer complete.');
170170
},
171-
recover: async (_ctx, _amount, _src, _dest) => {
172-
throw Error('TODO: do away with recover. see #NNNN');
173-
},
174-
} as const satisfies TransportDetail<
175-
'CCTP',
176-
AxelarChain,
177-
'agoric',
178-
EVMContext,
179-
PortfolioInstanceContext
180-
>;
171+
} as const satisfies TransportDetail<'CCTP', AxelarChain, 'agoric', EVMContext>;
181172
harden(CCTPfromEVM);
182173

183174
export const CCTP = {
@@ -205,11 +196,6 @@ export const CCTP = {
205196
]);
206197
traceTransfer('transfer complete.');
207198
},
208-
recover: async (_ctx, _amount, _src, _dest) => {
209-
// XXX evmCtx needs a GMP fee
210-
// return CCTPfromEVM.apply(evmCtx, amount, dest, src);
211-
throw Error('TODO(Luqi): how to recover from CCTP transfer?');
212-
},
213199
} as const satisfies TransportDetail<'CCTP', 'noble', AxelarChain>;
214200
harden(CCTP);
215201

packages/portfolio-contract/src/pos-usdn.flows.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,6 @@ export const agoricToNoble = {
149149
const denomAmount = { value: amount.value, denom };
150150
await src.lca.transfer(dest.ica.getAddress(), denomAmount);
151151
},
152-
recover: async (ctx, amount, src, dest) => {
153-
const nobleAmount = { value: amount.value, denom: 'uusdc' };
154-
await dest.ica.transfer(src.lca.getAddress(), nobleAmount);
155-
},
156152
} as const satisfies TransportDetail<
157153
'IBC to Noble',
158154
'agoric',
@@ -168,11 +164,6 @@ export const nobleToAgoric = {
168164
const nobleAmount = { value: amount.value, denom: 'uusdc' };
169165
await src.ica.transfer(dest.lca.getAddress(), nobleAmount);
170166
},
171-
recover: async (ctx, amount, src, dest) => {
172-
const { denom } = ctx.usdc;
173-
const denomAmount = { value: amount.value, denom };
174-
await dest.lca.transfer(src.ica.getAddress(), denomAmount);
175-
},
176167
} as const satisfies TransportDetail<
177168
'IBC from Noble',
178169
'noble',

packages/portfolio-contract/src/type-guards.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ export const makeFlowStepsPath = (parent: number, id: number) => [
333333

334334
export const FlowStatusShape: TypedPattern<StatusFor['flow']> = M.or(
335335
{ state: 'run', step: M.number(), how: M.string() },
336-
{ state: 'undo', step: M.number(), how: M.string() },
336+
{ state: 'undo', step: M.number(), how: M.string() }, // XXX Not currently used
337337
{ state: 'done' },
338338
M.splitRecord(
339339
{ state: 'fail', step: M.number(), how: M.string(), error: M.string() },

packages/portfolio-contract/test/offer-shapes.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,6 @@ test('vstorage flow type matches shape', t => {
156156
step: 1,
157157
how: 'deposit',
158158
},
159-
undoingFlow: {
160-
state: 'undo',
161-
step: 2,
162-
how: 'withdraw',
163-
},
164159
completedFlow: {
165160
state: 'done',
166161
},

packages/portfolio-contract/test/portfolio.flows.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -704,7 +704,7 @@ test('open portfolio with Compound position', async t => {
704704
await documentStorageSchema(t, storage, docOpts);
705705
});
706706

707-
test('handle failure in localTransfer from seat to local account', async t => {
707+
test.skip('handle failure in localTransfer from seat to local account', async t => {
708708
const amount = make(USDC, 100n);
709709
const { orch, ctx, offer, storage } = mocks(
710710
{ localTransfer: Error('localTransfer from seat failed') },
@@ -754,7 +754,7 @@ test.skip('handle failure in IBC transfer', async t => {
754754
await documentStorageSchema(t, storage, docOpts);
755755
});
756756

757-
test('handle failure in executeEncodedTx', async t => {
757+
test.skip('handle failure in executeEncodedTx', async t => {
758758
const { give, steps } = await makePortfolioSteps({ USDN: make(USDC, 100n) });
759759
const { orch, ctx, offer, storage } = mocks(
760760
{ executeEncodedTx: Error('exec swaplock went kerflewey') },
@@ -781,7 +781,7 @@ test('handle failure in executeEncodedTx', async t => {
781781
await documentStorageSchema(t, storage, docOpts);
782782
});
783783

784-
test('handle failure in recovery from executeEncodedTx', async t => {
784+
test.skip('handle failure in recovery from executeEncodedTx', async t => {
785785
const amount = make(USDC, 100n);
786786
const { orch, ctx, offer, storage } = mocks(
787787
{
@@ -868,7 +868,7 @@ test(
868868
],
869869
);
870870

871-
test('rebalance handles stepFlow failure correctly', async t => {
871+
test.skip('rebalance handles stepFlow failure correctly', async t => {
872872
const { orch, ctx, offer } = mocks(
873873
{
874874
// Mock a failure in IBC transfer

0 commit comments

Comments
 (0)