Skip to content

Commit e017df7

Browse files
sirtimidclaude
andcommitted
fix(evm-wallet-experiment): check relay path before resolving chain ID / SDK calldata
Move the peer relay check in `submitDelegationUserOp` and `submitBatchDelegationUserOp` before `resolveChainId()` and `buildSdkRedeemCallData()`. The relay path forwards raw delegations and execution to the home wallet and does not need local chain ID or SDK calldata — computing them first blocks the relay when the provider is temporarily unreachable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9d6b09a commit e017df7

2 files changed

Lines changed: 140 additions & 44 deletions

File tree

packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3204,7 +3204,74 @@ describe('coordinator-vat', () => {
32043204
},
32053205
delegationId: delegation.id,
32063206
}),
3207-
).rejects.toThrow('Failed to relay delegation redemption to home wallet');
3207+
).rejects.toThrow(
3208+
'Failed to relay delegation redemption to home wallet: CapTP connection lost',
3209+
);
3210+
});
3211+
3212+
it('propagates batch peer relay errors to the caller', async () => {
3213+
const peerAddress =
3214+
'0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address;
3215+
const mockPeerWallet = {
3216+
getAccounts: vi.fn().mockResolvedValue([peerAddress]),
3217+
getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }),
3218+
handleSigningRequest: vi.fn(),
3219+
handleRedemptionRequest: vi
3220+
.fn()
3221+
.mockRejectedValue(new Error('peer disconnected')),
3222+
registerAwayWallet: vi.fn().mockResolvedValue(undefined),
3223+
registerDelegateAddress: vi.fn().mockResolvedValue(undefined),
3224+
};
3225+
3226+
const freshBaggage = makeMockBaggage();
3227+
freshBaggage.init('keyringVat', keyringVat);
3228+
freshBaggage.init('providerVat', providerVat);
3229+
freshBaggage.init('delegationVat', delegationVat);
3230+
freshBaggage.init('peerWallet', mockPeerWallet);
3231+
3232+
const coord = buildRootObject(
3233+
{},
3234+
undefined,
3235+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3236+
freshBaggage as any,
3237+
);
3238+
3239+
await coord.bootstrap(
3240+
{
3241+
keyring: keyringVat,
3242+
provider: providerVat,
3243+
delegation: delegationVat,
3244+
},
3245+
{},
3246+
);
3247+
3248+
await coord.initializeKeyring({
3249+
type: 'srp',
3250+
mnemonic: TEST_MNEMONIC,
3251+
});
3252+
3253+
const accounts = await coord.getAccounts();
3254+
const delegator = accounts[0] as Address;
3255+
3256+
await coord.createDelegation({
3257+
delegate: delegator,
3258+
caveats: [
3259+
makeCaveat({
3260+
type: 'allowedTargets',
3261+
terms: encodeAllowedTargets([TARGET]),
3262+
}),
3263+
],
3264+
chainId: 1,
3265+
});
3266+
3267+
await expect(
3268+
coord.sendBatchTransaction([
3269+
{ from: delegator, to: TARGET, value: '0x1' as Hex },
3270+
{ from: delegator, to: TARGET, value: '0x2' as Hex },
3271+
]),
3272+
).rejects.toThrow(
3273+
'Failed to relay batch delegation redemption to home wallet: peer disconnected',
3274+
);
32083275
});
32093276

32103277
it('throws for non-delegation batch when peer is set but no bundler', async () => {

packages/evm-wallet-experiment/src/vats/coordinator-vat.ts

Lines changed: 72 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,34 @@ export function buildRootObject(
10501050
maxFeePerGas?: Hex | undefined;
10511051
maxPriorityFeePerGas?: Hex | undefined;
10521052
}): Promise<Hex> {
1053+
// Check the relay path first — it forwards raw delegations/execution to
1054+
// the home wallet and does not need local chain ID or SDK calldata.
1055+
if (!bundlerConfig && !smartAccountConfig) {
1056+
if (peerWallet) {
1057+
try {
1058+
return await E(peerWallet).handleRedemptionRequest({
1059+
type: 'single',
1060+
delegations: options.delegations,
1061+
execution: options.execution,
1062+
maxFeePerGas: options.maxFeePerGas,
1063+
maxPriorityFeePerGas: options.maxPriorityFeePerGas,
1064+
});
1065+
} catch (relayError) {
1066+
const detail =
1067+
relayError instanceof Error
1068+
? relayError.message
1069+
: String(relayError);
1070+
throw new Error(
1071+
`Failed to relay delegation redemption to home wallet: ${detail}`,
1072+
{ cause: relayError },
1073+
);
1074+
}
1075+
}
1076+
throw new Error(
1077+
'Bundler not configured and no peer wallet available for relay',
1078+
);
1079+
}
1080+
10531081
const sender =
10541082
smartAccountConfig?.address ?? options.delegations[0].delegate;
10551083

@@ -1070,25 +1098,8 @@ export function buildRootObject(
10701098
}
10711099

10721100
if (!bundlerConfig) {
1073-
if (peerWallet) {
1074-
try {
1075-
return await E(peerWallet).handleRedemptionRequest({
1076-
type: 'single',
1077-
delegations: options.delegations,
1078-
execution: options.execution,
1079-
maxFeePerGas: options.maxFeePerGas,
1080-
maxPriorityFeePerGas: options.maxPriorityFeePerGas,
1081-
});
1082-
} catch (relayError) {
1083-
throw new Error(
1084-
'Failed to relay delegation redemption to home wallet — ' +
1085-
'ensure the home device is online and try again',
1086-
{ cause: relayError },
1087-
);
1088-
}
1089-
}
10901101
throw new Error(
1091-
'Bundler not configured and no peer wallet available for relay',
1102+
'Bundler not configured (required for hybrid smart account redemption)',
10921103
);
10931104
}
10941105

@@ -1122,6 +1133,32 @@ export function buildRootObject(
11221133
delegations: Delegation[];
11231134
executions: Execution[];
11241135
}): Promise<Hex> {
1136+
// Check the relay path first — it forwards raw delegations/executions to
1137+
// the home wallet and does not need local chain ID or SDK calldata.
1138+
if (!bundlerConfig && !smartAccountConfig) {
1139+
if (peerWallet) {
1140+
try {
1141+
return await E(peerWallet).handleRedemptionRequest({
1142+
type: 'batch',
1143+
delegations: options.delegations,
1144+
executions: options.executions,
1145+
});
1146+
} catch (relayError) {
1147+
const detail =
1148+
relayError instanceof Error
1149+
? relayError.message
1150+
: String(relayError);
1151+
throw new Error(
1152+
`Failed to relay batch delegation redemption to home wallet: ${detail}`,
1153+
{ cause: relayError },
1154+
);
1155+
}
1156+
}
1157+
throw new Error(
1158+
'Bundler not configured and no peer wallet available for relay',
1159+
);
1160+
}
1161+
11251162
const sender =
11261163
smartAccountConfig?.address ?? options.delegations[0]?.delegate;
11271164
if (!sender) {
@@ -1140,23 +1177,8 @@ export function buildRootObject(
11401177
}
11411178

11421179
if (!bundlerConfig) {
1143-
if (peerWallet) {
1144-
try {
1145-
return await E(peerWallet).handleRedemptionRequest({
1146-
type: 'batch',
1147-
delegations: options.delegations,
1148-
executions: options.executions,
1149-
});
1150-
} catch (relayError) {
1151-
throw new Error(
1152-
'Failed to relay batch delegation redemption to home wallet — ' +
1153-
'ensure the home device is online and try again',
1154-
{ cause: relayError },
1155-
);
1156-
}
1157-
}
11581180
throw new Error(
1159-
'Bundler not configured and no peer wallet available for relay',
1181+
'Bundler not configured (required for hybrid smart account batch redemption)',
11601182
);
11611183
}
11621184

@@ -2767,12 +2789,15 @@ export function buildRootObject(
27672789
throw new Error('Missing or empty delegations in redemption request');
27682790
}
27692791

2770-
// Guard against infinite relay loops: if this wallet also has no
2771-
// bundler and no direct 7702, it cannot fulfill the request and
2772-
// must not relay it back to its own peer.
2773-
const sender =
2774-
smartAccountConfig?.address ?? request.delegations[0].delegate;
2775-
if (!bundlerConfig && !(await useDirect7702Tx(sender))) {
2792+
// Guard against infinite relay loops: if this wallet cannot fulfill
2793+
// the request locally, it must not relay it back to its own peer.
2794+
// Uses the same config-based check as the relay entry condition in
2795+
// submitDelegationUserOp/submitBatchDelegationUserOp — keep in sync.
2796+
const canFulfillLocally =
2797+
bundlerConfig !== undefined ||
2798+
(smartAccountConfig?.implementation === 'stateless7702' &&
2799+
providerVat !== undefined);
2800+
if (!canFulfillLocally) {
27762801
throw new Error(
27772802
'Cannot fulfill relayed redemption: no bundler or direct 7702 configured',
27782803
);
@@ -2838,7 +2863,7 @@ export function buildRootObject(
28382863
cachedPeerSigningMode = signingMode;
28392864
persistBaggage('cachedPeerSigningMode', cachedPeerSigningMode);
28402865
} catch (error) {
2841-
logger.warn('peer getCapabilities timed out, using cache', error);
2866+
logger.warn('peer getCapabilities failed, using cache', error);
28422867
signingMode = cachedPeerSigningMode ?? 'peer:unknown';
28432868
}
28442869
} else if (externalSigner) {
@@ -2867,8 +2892,12 @@ export function buildRootObject(
28672892
let autonomy: string;
28682893
const canRedeemLocally =
28692894
bundlerConfig !== undefined ||
2870-
smartAccountConfig?.implementation === 'stateless7702';
2871-
const canRedeemViaRelay = !canRedeemLocally && peerWallet !== undefined;
2895+
(smartAccountConfig?.implementation === 'stateless7702' &&
2896+
providerVat !== undefined);
2897+
// Relay requires no smartAccountConfig — mirrors the entry condition
2898+
// in submitDelegationUserOp/submitBatchDelegationUserOp.
2899+
const canRedeemViaRelay =
2900+
!canRedeemLocally && !smartAccountConfig && peerWallet !== undefined;
28722901
const canRedeemDelegationsOnChain =
28732902
activeDelegations.length > 0 && (canRedeemLocally || canRedeemViaRelay);
28742903
if (canRedeemDelegationsOnChain) {

0 commit comments

Comments
 (0)