Skip to content

Commit a61c7fe

Browse files
authored
fix(portfolio-contract): Start a flow when openPortfolio includes an initial deposit (#12115)
Fixes #12114 ## Description If `openPortfolio` is called with a Deposit, use `executePlan` rather than `rebalance` to coördinate with the planner. ### Security Considerations None known. This effectively tacks on a "deposit" flow to the existing "open portfolio" flow. ### Scaling Considerations None; the planner is already expected to respond to deposits. ### Documentation Considerations n/a ### Testing Considerations Fixes for existing tests are in progress. ### Upgrade Considerations The planner is already expecting and prepared to respond to deposit flows. This should follow the usual `ymaxControl.upgrade(...)` pattern.
2 parents fba5204 + 253325c commit a61c7fe

File tree

6 files changed

+141
-69
lines changed

6 files changed

+141
-69
lines changed

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,13 +447,14 @@ export const preparePortfolioKit = (
447447
this.facets.reporter.publishStatus();
448448
}
449449
},
450-
startFlow(detail: FlowDetail) {
451-
const { nextFlowId, flowsRunning } = this.state;
452-
this.state.nextFlowId = nextFlowId + 1;
450+
startFlow(detail: FlowDetail, steps?: MovementDesc[]) {
451+
const { nextFlowId: flowId, flowsRunning } = this.state;
452+
this.state.nextFlowId = flowId + 1;
453453
const sync: VowKit<MovementDesc[]> = vowTools.makeVowKit();
454-
flowsRunning.init(nextFlowId, harden({ sync, ...detail }));
454+
if (steps) sync.resolver.resolve(steps);
455+
flowsRunning.init(flowId, harden({ sync, ...detail }));
455456
this.facets.reporter.publishStatus();
456-
return { stepsP: sync.vow, flowId: nextFlowId };
457+
return { stepsP: sync.vow, flowId };
457458
},
458459
providePosition(
459460
poolKey: PoolKey,

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

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -865,20 +865,21 @@ export const rebalance = (async (
865865
const traceP = makeTracer('rebalance').sub(`portfolio${id}`);
866866
const proposal = seat.getProposal() as ProposalType['rebalance'];
867867
traceP('proposal', proposal.give, proposal.want, offerArgs);
868+
const { flow, targetAllocation } = offerArgs;
868869

869870
await null;
870871
let flowId: number | undefined;
871872
try {
872-
if (offerArgs.targetAllocation) {
873-
kit.manager.setTargetAllocation(offerArgs.targetAllocation);
874-
} else if ((offerArgs.flow || []).some(step => step.dest === '+agoric')) {
875-
// steps include a deposit that the planner should respond to
873+
if (targetAllocation) {
874+
kit.manager.setTargetAllocation(targetAllocation);
875+
} else if (flow?.some(step => step.dest === '+agoric')) {
876+
// flow includes a deposit that the planner should respond to
876877
kit.manager.incrPolicyVersion();
877878
}
878879

879-
if (offerArgs.flow) {
880-
({ flowId } = kit.manager.startFlow({ type: 'rebalance' }));
881-
await stepFlow(orch, ctx, seat, offerArgs.flow, kit, traceP, flowId);
880+
if (flow) {
881+
({ flowId } = kit.manager.startFlow({ type: 'rebalance' }, flow));
882+
await stepFlow(orch, ctx, seat, flow, kit, traceP, flowId);
882883
}
883884

884885
if (!seat.hasExited()) {
@@ -1113,10 +1114,22 @@ export const openPortfolio = (async (
11131114
await registerNobleForwardingAccount(sender, dest, forwarding, traceP);
11141115
}
11151116

1117+
const { give } = seat.getProposal() as ProposalType['openPortfolio'];
11161118
try {
1117-
await rebalance(orch, ctxI, seat, offerArgs, kit);
1119+
if (offerArgs.flow) {
1120+
// XXX only for testing recovery?
1121+
await rebalance(orch, ctxI, seat, offerArgs, kit);
1122+
} else if (offerArgs.targetAllocation) {
1123+
kit.manager.setTargetAllocation(offerArgs.targetAllocation);
1124+
if (give.Deposit) {
1125+
await executePlan(orch, ctxI, seat, offerArgs, kit, {
1126+
type: 'deposit',
1127+
amount: give.Deposit,
1128+
});
1129+
}
1130+
}
11181131
} catch (err) {
1119-
traceP('⚠️ rebalance failed', err);
1132+
traceP('⚠️ initial flow failed', err);
11201133
if (!seat.hasExited()) seat.fail(err);
11211134
}
11221135

@@ -1153,17 +1166,16 @@ export const executePlan = (async (
11531166
orch: Orchestrator,
11541167
ctx: PortfolioInstanceContext,
11551168
seat: ZCFSeat,
1156-
_offerArgs: unknown,
1169+
offerArgs: { flow?: MovementDesc[] },
11571170
pKit: GuestInterface<PortfolioKit>,
11581171
flowDetail: FlowDetail,
11591172
): Promise<`flow${number}`> => {
11601173
const pId = pKit.reader.getPortfolioId();
11611174
const traceP = makeTracer(flowDetail.type).sub(`portfolio${pId}`);
11621175

1163-
// XXX enhancement: let caller supply steps
1164-
const { stepsP, flowId } = pKit.manager.startFlow(flowDetail);
1176+
const { stepsP, flowId } = pKit.manager.startFlow(flowDetail, offerArgs.flow);
11651177
const traceFlow = traceP.sub(`flow${flowId}`);
1166-
traceFlow('waiting for steps from planner');
1178+
if (!offerArgs.flow) traceFlow('waiting for steps from planner');
11671179
// idea: race with seat.getSubscriber()
11681180
const steps = await (stepsP as unknown as Promise<MovementDesc[]>); // XXX Guest/Host types UNTIL #9822
11691181
try {

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

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,7 @@ const setupPlanner = async t => {
884884
const planner1 = plannerClientMock(walletPlanner, started.instance, () =>
885885
readPublished(`wallet.agoric1planner`),
886886
);
887-
return { common, zoe, started, trader1, planner1 };
887+
return { common, zoe, started, trader1, planner1, readPublished };
888888
};
889889

890890
test('redeem, use planner invitation', async t => {
@@ -1227,3 +1227,66 @@ test('simple rebalance using planner', async t => {
12271227
]);
12281228
t.is(833332500n, (3333330000n * 25n) / 100n);
12291229
});
1230+
1231+
test('create+deposit using planner', async t => {
1232+
const { common, trader1, planner1, readPublished } = await setupPlanner(t);
1233+
const { usdc } = common.brands;
1234+
1235+
await planner1.redeem();
1236+
1237+
const traderP = (async () => {
1238+
const Deposit = usdc.units(1_000);
1239+
await Promise.all([
1240+
trader1.openPortfolio(t, { Deposit }, { targetAllocation: { USDN: 1n } }),
1241+
ackNFA(common.utils),
1242+
]);
1243+
t.log('trader created with deposit', Deposit);
1244+
})();
1245+
1246+
const plannerP = (async () => {
1247+
const getStatus = async pId => {
1248+
// NOTE: readPublished uses eventLoopIteration() to let vstorage writes settle
1249+
const x = await readPublished(`portfolios.portfolio${pId}`);
1250+
return x as unknown as StatusFor['portfolio'];
1251+
};
1252+
1253+
const pId = 0;
1254+
const {
1255+
flowsRunning = {},
1256+
policyVersion,
1257+
rebalanceCount,
1258+
} = await getStatus(pId);
1259+
t.is(keys(flowsRunning).length, 1);
1260+
const [[flowId, detail]] = Object.entries(flowsRunning);
1261+
const fId = Number(flowId.replace('flow', ''));
1262+
1263+
// narrow the type
1264+
if (detail.type !== 'deposit') throw t.fail(detail.type);
1265+
1266+
// XXX brand from vstorage isn't suitable for use in call to kit
1267+
const amount = AmountMath.make(usdc.brand, detail.amount.value);
1268+
1269+
const plan: MovementDesc[] = [
1270+
{ src: '<Deposit>', dest: '@agoric', amount },
1271+
];
1272+
await E(planner1.stub).resolvePlan(
1273+
pId,
1274+
fId,
1275+
plan,
1276+
policyVersion,
1277+
rebalanceCount,
1278+
);
1279+
t.log('planner resolved plan');
1280+
})();
1281+
1282+
await Promise.all([traderP, plannerP]);
1283+
1284+
const bankTraffic = common.utils.inspectBankBridge();
1285+
const { accountIdByChain } = await trader1.getPortfolioStatus();
1286+
const [_ns, _ref, addr] = accountIdByChain.agoric!.split(':');
1287+
const myVBankIO = bankTraffic.filter(obj =>
1288+
[obj.sender, obj.recipient].includes(addr),
1289+
);
1290+
t.log('bankBridge for', addr, myVBankIO);
1291+
t.like(myVBankIO, [{ type: 'VBANK_GIVE', amount: '1000000000' }]);
1292+
});

packages/portfolio-contract/test/target-allocation.test.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,13 @@ const ackNFA = (utils, ix = 0) =>
1414

1515
test('openPortfolio stores and publishes target allocation', async t => {
1616
const { trader1, common } = await setupTrader(t);
17-
const { usdc } = common.brands;
1817

1918
// target: 60% USDN, 40% Aave on Arbitrum
2019
const targetAllocation = { USDN: 6000n, Aave_Arbitrum: 4000n };
2120

2221
// Open portfolio with target allocation
2322
await Promise.all([
24-
trader1.openPortfolio(
25-
t,
26-
{ Deposit: usdc.units(1_000) },
27-
{ targetAllocation },
28-
),
23+
trader1.openPortfolio(t, {}, { targetAllocation }),
2924
ackNFA(common.utils),
3025
]);
3126

@@ -112,20 +107,12 @@ test('multiple portfolios have independent allocations', async t => {
112107

113108
// Open portfolios with different allocations
114109
await Promise.all([
115-
trader1.openPortfolio(
116-
t,
117-
{ Deposit: usdc.units(5_000) },
118-
{ targetAllocation: allocation1 },
119-
),
110+
trader1.openPortfolio(t, {}, { targetAllocation: allocation1 }),
120111
ackNFA(common.utils, 0),
121112
]);
122113

123114
await Promise.all([
124-
trader2.openPortfolio(
125-
t,
126-
{ Deposit: usdc.units(7_000) },
127-
{ targetAllocation: allocation2 },
128-
),
115+
trader2.openPortfolio(t, {}, { targetAllocation: allocation2 }),
129116
ackNFA(common.utils, -1),
130117
]);
131118

packages/portfolio-contract/test/ymax-scenario.test.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// prepare-test-env has to go 1st; use a blank line to separate it
55
import { test } from '@agoric/zoe/tools/prepare-test-env-ava.js';
66

7+
import { inspect } from 'node:util';
78
import { eventLoopIteration } from '@agoric/internal/src/testing-utils.js';
89
import { AxelarChain } from '@agoric/portfolio-api/src/constants.js';
910
import { Fail } from '@endo/errors';
@@ -53,9 +54,9 @@ const rebalanceScenarioMacro = test.macro({
5354
async exec(t, description: string) {
5455
const { trader1, common, started, zoe } = await setupTrader(t);
5556
const scenarios = await scenariosP;
56-
const scenario = scenarios[description];
57-
if (!scenario) return t.fail(`Scenario "${description}" not found`);
58-
t.log('start', description);
57+
const rawScenario = scenarios[description];
58+
if (!rawScenario) return t.fail(`Scenario "${description}" not found`);
59+
t.log('start', description, inspect(rawScenario, { depth: 5 }));
5960

6061
const { ibcBridge } = common.mocks;
6162
for (const money of [
@@ -70,8 +71,8 @@ const rebalanceScenarioMacro = test.macro({
7071
}
7172

7273
const { usdc } = common.brands;
73-
const sceneB = withBrand(scenario, usdc.brand);
74-
const sceneBP = scenario.previous
74+
const scenario = withBrand(rawScenario, usdc.brand);
75+
const previous = scenario.previous
7576
? withBrand(scenarios[scenario.previous], usdc.brand)
7677
: undefined;
7778

@@ -140,21 +141,21 @@ const rebalanceScenarioMacro = test.macro({
140141
return { result, payouts };
141142
};
142143

143-
const openOnly = Object.keys(sceneB.before).length === 0;
144-
openOnly || sceneBP || Fail`no previous scenario for ${description}`;
144+
const openOnly = Object.keys(scenario.before).length === 0;
145+
openOnly || previous || Fail`no previous scenario for ${description}`;
145146
const openResult = await (openOnly
146-
? openPortfolioAndAck(sceneB.proposal.give, sceneB.offerArgs)
147-
: openPortfolioAndAck(sceneBP!.proposal.give, sceneBP!.offerArgs));
147+
? openPortfolioAndAck(scenario.proposal.give, scenario.offerArgs)
148+
: openPortfolioAndAck(previous!.proposal.give, previous!.offerArgs));
148149

149150
const { payouts } = await (async () => {
150151
if (openOnly) return openResult;
151152

152153
const rebalanceP = trader1.rebalance(
153154
t,
154-
sceneB.proposal,
155-
sceneB.offerArgs,
155+
scenario.proposal,
156+
scenario.offerArgs,
156157
);
157-
await ackSteps(sceneB.offerArgs);
158+
await ackSteps(scenario.offerArgs);
158159
const result = await rebalanceP;
159160
return result;
160161
})();
@@ -170,11 +171,11 @@ const rebalanceScenarioMacro = test.macro({
170171

171172
const txfrs = await trader1.netTransfersByPosition();
172173
t.log('net transfers by position', txfrs);
173-
t.deepEqual(txfrs, sceneB.after, 'net transfers should match After row');
174+
t.deepEqual(txfrs, scenario.after, 'net transfers should match After row');
174175

175176
t.log('payouts', payouts);
176177
const { Access: _, ...skipAssets } = payouts;
177-
t.deepEqual(skipAssets, sceneB.payouts, 'payouts');
178+
t.deepEqual(skipAssets, scenario.payouts, 'payouts');
178179

179180
// XXX: inspect bridge for netTransfersByPosition chains?
180181
},

packages/portfolio-contract/tools/rebalance-grok.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,25 @@ export const numeral = (amt: Dollars) => amt.replace(/[$,]/g, '');
2828

2929
type Empty = Record<never, never>;
3030

31-
export type RebalanceScenario = {
31+
type MovementDescTemplate<V extends NatAmount | Dollars> = V extends NatAmount
32+
? MovementDesc
33+
: V extends Dollars
34+
? Omit<MovementDesc, 'amount'> & { amount: Dollars }
35+
: never;
36+
37+
export type RebalanceScenario<V extends NatAmount | Dollars = Dollars> = {
3238
description: string;
33-
before: Partial<Record<YieldProtocol, Dollars>>;
39+
before: Partial<Record<YieldProtocol, V>>;
3440
previous: string;
3541
proposal:
3642
| { give: Empty; want: Empty }
37-
| { give: { Deposit: Dollars }; want: Empty }
38-
| { want: { Cash: Dollars }; give: Empty };
39-
offerArgs?: { flow: (Omit<MovementDesc, 'amount'> & { amount: Dollars })[] };
40-
after: Partial<Record<YieldProtocol, Dollars>>;
41-
payouts: { Deposit?: Dollars; Cash?: Dollars };
43+
| { give: { Deposit: V }; want: Empty }
44+
| { want: { Cash: V }; give: Empty };
45+
offerArgs: V extends NatAmount
46+
? { flow?: MovementDesc[] }
47+
: { flow: MovementDescTemplate<V>[] } | undefined;
48+
after: Partial<Record<YieldProtocol, V>>;
49+
payouts: { Deposit?: V; Cash?: V };
4250
positionsNet: Dollars;
4351
offerNet: Dollars;
4452
operationNet: Dollars;
@@ -169,7 +177,7 @@ export const grokRebalanceScenarios = (data: Array<string[]>) => {
169177
const dest = asPlaceRef(hd[destCol]);
170178

171179
const amount = T2B as Dollars;
172-
assert(amount && amount.startsWith('$'), `bad amount in row ${rownum}`);
180+
amount?.startsWith('$') || assert.fail(`bad amount in row ${rownum}`);
173181

174182
flow.push({ src, dest, amount });
175183
break;
@@ -208,34 +216,34 @@ export const grokRebalanceScenarios = (data: Array<string[]>) => {
208216
};
209217

210218
export const withBrand = (
211-
scenario: RebalanceScenario,
219+
scenario: RebalanceScenario<Dollars>,
212220
brand: Brand<'nat'>,
213221
feeBrand = brand,
214-
) => {
222+
): RebalanceScenario<NatAmount> => {
215223
const { make } = AmountMath;
216224
const unit = make(brand, 1_000_000n);
217225
const $ = (amt: Dollars): NatAmount =>
218226
multiplyBy(unit, parseRatio(numeral(amt), brand));
219227
const $$ = <C extends Record<string, Dollars>>(dollarCells: C) =>
220228
objectMap(dollarCells, a => $(a!));
221229

230+
const needsFee = m =>
231+
m.dest === '@Arbitrum' ||
232+
['Compound', 'Aave'].some(p => m.dest.startsWith(p) || m.src.startsWith(p));
222233
const withFee = m =>
223-
['Compound', 'Aave'].some(
224-
p => m.dest.startsWith(p) || m.src.startsWith(p),
225-
) || m.dest === '@Arbitrum'
226-
? { ...m, fee: { brand: feeBrand, value: 100n } }
227-
: m;
228-
229-
const flow = scenario.offerArgs?.flow;
230-
const offerArgs = flow
231-
? harden({ flow: flow.map(m => withFee({ ...m, amount: $(m.amount) })) })
232-
: harden({});
234+
needsFee(m) ? { ...m, fee: { brand: feeBrand, value: 100n } } : m;
233235

236+
const flow = scenario.offerArgs?.flow?.map(
237+
m => withFee({ ...m, amount: $(m.amount) }) as MovementDesc,
238+
);
239+
const offerArgs = flow ? harden({ flow }) : harden({});
234240
mustMatch(offerArgs, makeOfferArgsShapes(brand).rebalance);
235241

236242
const proposal = objectMap(scenario.proposal, $$);
237243
mustMatch(proposal, makeProposalShapes(brand).rebalance);
244+
238245
return harden({
246+
...scenario,
239247
before: $$(scenario.before),
240248
proposal,
241249
offerArgs,

0 commit comments

Comments
 (0)