From 6f34c5def3dbec4991c352fc23aa9a771fc6bbfb Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 6 Mar 2026 13:12:14 +1300 Subject: [PATCH 1/2] Update test names --- packages/wallet/core/test/session-manager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet/core/test/session-manager.test.ts b/packages/wallet/core/test/session-manager.test.ts index 93a69ed89d..646872739d 100644 --- a/packages/wallet/core/test/session-manager.test.ts +++ b/packages/wallet/core/test/session-manager.test.ts @@ -731,7 +731,7 @@ for (const extension of ALL_EXTENSIONS) { ) it( - 'signs a payload using an explicit session', + 'signs a payload using an explicit session with allowAll and value limit', async () => { const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) const chainId = Number(await provider.request({ method: 'eth_chainId' })) @@ -804,7 +804,7 @@ for (const extension of ALL_EXTENSIONS) { ) it( - 'signs a payload using an explicit session', + 'signs using explicit session with onlyOnce, consumes usage and rejects second call', async () => { const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) const chainId = Number(await provider.request({ method: 'eth_chainId' })) From a41a71d155ebd1b77ae521305f29f2ef7ed05a3b Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 6 Mar 2026 13:14:16 +1300 Subject: [PATCH 2/2] Fix handling for multiple explicit signers --- .../core/src/signers/session-manager.ts | 107 +++++-- .../core/src/signers/session/explicit.ts | 8 +- .../core/src/signers/session/session.ts | 12 +- packages/wallet/core/test/constants.ts | 2 + .../wallet/core/test/session-manager.test.ts | 290 +++++++++++++++++- 5 files changed, 385 insertions(+), 34 deletions(-) diff --git a/packages/wallet/core/src/signers/session-manager.ts b/packages/wallet/core/src/signers/session-manager.ts index ef3d81b3a3..6b23cb7a6c 100644 --- a/packages/wallet/core/src/signers/session-manager.ts +++ b/packages/wallet/core/src/signers/session-manager.ts @@ -18,6 +18,7 @@ import { SessionSigner, SessionSignerInvalidReason, isImplicitSessionSigner, + isIncrementCall, UsageLimit, } from './session/index.js' @@ -130,21 +131,16 @@ export class SessionManager implements SapientSigner { })) } - async findSignersForCalls(wallet: Address.Address, chainId: number, calls: Payload.Call[]): Promise { - // Only use signers that match the topology - const topology = await this.topology - const identitySigners = SessionConfig.getIdentitySigners(topology) - if (identitySigners.length === 0) { - throw new Error('Identity signers not found') - } - - // Prioritize implicit signers - const availableSigners = [...this._implicitSigners, ...this._explicitSigners] - if (availableSigners.length === 0) { - throw new Error('No signers match the topology') - } - - // Find supported signers for each call + /** + * Find one signer per call from the given candidate list (first that supports each call). + */ + private async findSignersForCallsWithCandidates( + wallet: Address.Address, + chainId: number, + calls: Payload.Call[], + topology: SessionConfig.SessionsTopology, + availableSigners: SessionSigner[], + ): Promise { const signers: SessionSigner[] = [] for (const call of calls) { let supported = false @@ -173,9 +169,67 @@ export class SessionManager implements SapientSigner { if (expiredSupportedSigner) { throw new Error(`Signer supporting call is expired: ${expiredSupportedSigner.address}`) } - throw new Error( - `No signer supported for call. ` + `Call: to=${call.to}, data=${call.data}, value=${call.value}, `, - ) + throw new Error(`No signer supported for call. Call: to=${call.to}, data=${call.data}, value=${call.value}, `) + } + } + return signers + } + + async findSignersForCalls(wallet: Address.Address, chainId: number, calls: Payload.Call[]): Promise { + const topology = await this.topology + const identitySigners = SessionConfig.getIdentitySigners(topology) + if (identitySigners.length === 0) { + throw new Error('Identity signers not found') + } + + const availableSigners = [...this._implicitSigners, ...this._explicitSigners] + if (availableSigners.length === 0) { + throw new Error('No signers match the topology') + } + + const nonIncrementCalls: Payload.Call[] = [] + const incrementCalls: Payload.Call[] = [] + for (const call of calls) { + if (isIncrementCall(call, this.address)) { + incrementCalls.push(call) + } else { + nonIncrementCalls.push(call) + } + } + + // Find signers for non-increment calls + const nonIncrementSigners = + nonIncrementCalls.length > 0 + ? await this.findSignersForCallsWithCandidates(wallet, chainId, nonIncrementCalls, topology, availableSigners) + : [] + + let incrementSigners: SessionSigner[] = [] + if (incrementCalls.length > 0) { + // Find signers for increment calls, preferring signers that signed non-increment calls + const incrementCandidates = [ + ...nonIncrementSigners, + ...availableSigners.filter((s) => !nonIncrementSigners.includes(s)), + ] + incrementSigners = await this.findSignersForCallsWithCandidates( + wallet, + chainId, + incrementCalls, + topology, + incrementCandidates, + ) + } + + // Merge back in original call order + const signers: SessionSigner[] = [] + let nonIncrementIndex = 0 + let incrementIndex = 0 + for (const call of calls) { + if (isIncrementCall(call, this.address)) { + signers.push(incrementSigners[incrementIndex]!) + incrementIndex++ + } else { + signers.push(nonIncrementSigners[nonIncrementIndex]!) + nonIncrementIndex++ } } return signers @@ -191,20 +245,23 @@ export class SessionManager implements SapientSigner { } const signers = await this.findSignersForCalls(wallet, chainId, calls) - // Create a map of signers to their associated calls - const signerToCalls = new Map() + // Map each signer to only their non-increment calls + const signerToNonIncrementCalls = new Map() signers.forEach((signer, index) => { const call = calls[index]! - const existingCalls = signerToCalls.get(signer) || [] - signerToCalls.set(signer, [...existingCalls, call]) + if (isIncrementCall(call, this.address)) { + return + } + const existing = signerToNonIncrementCalls.get(signer) || [] + signerToNonIncrementCalls.set(signer, [...existing, call]) }) - // Prepare increments for each explicit signer with their associated calls + // Prepare increments for each explicit signer from their non-increment calls only const increments: UsageLimit[] = ( await Promise.all( - Array.from(signerToCalls.entries()).map(async ([signer, associatedCalls]) => { + Array.from(signerToNonIncrementCalls.entries()).map(async ([signer, nonIncrementCalls]) => { if (isExplicitSessionSigner(signer)) { - return signer.prepareIncrements(wallet, chainId, associatedCalls, this.address, this._provider!) + return signer.prepareIncrements(wallet, chainId, nonIncrementCalls, this.address, this._provider!) } return [] }), diff --git a/packages/wallet/core/src/signers/session/explicit.ts b/packages/wallet/core/src/signers/session/explicit.ts index b7ad087b4f..86395fe701 100644 --- a/packages/wallet/core/src/signers/session/explicit.ts +++ b/packages/wallet/core/src/signers/session/explicit.ts @@ -1,7 +1,7 @@ import { Constants, Payload, Permission, SessionConfig, SessionSignature } from '@0xsequence/wallet-primitives' import { AbiFunction, AbiParameters, Address, Bytes, Hash, Hex, Provider } from 'ox' import { MemoryPkStore, PkStore } from '../pk/index.js' -import { ExplicitSessionSigner, SessionSignerValidity, UsageLimit } from './session.js' +import { ExplicitSessionSigner, isIncrementCall, SessionSignerValidity, UsageLimit } from './session.js' export type ExplicitParams = Omit @@ -208,11 +208,7 @@ export class Explicit implements ExplicitSessionSigner { sessionManagerAddress: Address.Address, provider?: Provider.Provider, ): Promise { - if ( - Address.isEqual(call.to, sessionManagerAddress) && - Hex.size(call.data) > 4 && - Hex.isEqual(Hex.slice(call.data, 0, 4), AbiFunction.getSelector(Constants.INCREMENT_USAGE_LIMIT)) - ) { + if (isIncrementCall(call, sessionManagerAddress)) { // Can sign increment usage calls return true } diff --git a/packages/wallet/core/src/signers/session/session.ts b/packages/wallet/core/src/signers/session/session.ts index 4bcc5bb771..08b2ade814 100644 --- a/packages/wallet/core/src/signers/session/session.ts +++ b/packages/wallet/core/src/signers/session/session.ts @@ -1,5 +1,5 @@ -import { Payload, SessionConfig, SessionSignature } from '@0xsequence/wallet-primitives' -import { Address, Hex, Provider } from 'ox' +import { Constants, Payload, SessionConfig, SessionSignature } from '@0xsequence/wallet-primitives' +import { AbiFunction, Address, Hex, Provider } from 'ox' export type SessionSignerInvalidReason = | 'Expired' @@ -68,3 +68,11 @@ export function isExplicitSessionSigner(signer: SessionSigner): signer is Explic export function isImplicitSessionSigner(signer: SessionSigner): signer is ImplicitSessionSigner { return 'identitySigner' in signer } + +export function isIncrementCall(call: Payload.Call, sessionManagerAddress: Address.Address): boolean { + return ( + Address.isEqual(call.to, sessionManagerAddress) && + Hex.size(call.data) >= 4 && + Hex.isEqual(Hex.slice(call.data, 0, 4), AbiFunction.getSelector(Constants.INCREMENT_USAGE_LIMIT)) + ) +} diff --git a/packages/wallet/core/test/constants.ts b/packages/wallet/core/test/constants.ts index 63e862f380..42b51d49b7 100644 --- a/packages/wallet/core/test/constants.ts +++ b/packages/wallet/core/test/constants.ts @@ -5,6 +5,8 @@ import { Abi, AbiEvent, Address } from 'ox' const envFile = process.env.CI ? '.env.test' : '.env.test.local' dotenvConfig({ path: envFile }) +// Contracts are deployed on Arbitrum + // Requires https://example.com redirectUrl export const EMITTER_ADDRESS1: Address.Address = '0xad90eB52BC180Bd9f66f50981E196f3E996278D3' // Requires https://another-example.com redirectUrl diff --git a/packages/wallet/core/test/session-manager.test.ts b/packages/wallet/core/test/session-manager.test.ts index 646872739d..adf36fb89a 100644 --- a/packages/wallet/core/test/session-manager.test.ts +++ b/packages/wallet/core/test/session-manager.test.ts @@ -1,4 +1,4 @@ -import { Extensions } from '@0xsequence/wallet-primitives' +import { Constants, Extensions } from '@0xsequence/wallet-primitives' import { AbiEvent, AbiFunction, Address, Bytes, Hex, Provider, RpcTransport, Secp256k1 } from 'ox' import { describe, expect, it } from 'vitest' import { Attestation, GenericTree, Payload, Permission, SessionConfig } from '../../primitives/src/index.js' @@ -1357,5 +1357,293 @@ for (const extension of ALL_EXTENSIONS) { }, timeout, ) + + it( + 'two explicit sessions with same value limit: exhaust first then second send uses second session (increment from non-increment calls only)', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = Number(await provider.request({ method: 'eth_chainId' })) + + const identityPrivateKey = Secp256k1.randomPrivateKey() + const identityAddress = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: identityPrivateKey })) + const stateProvider = new State.Local.Provider() + + const targetAddress = randomAddress() + const valueLimit = 500000000000000000n // 0.5 ETH per session + + const sessionPermission: ExplicitSessionConfig = { + chainId, + valueLimit, + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), + permissions: [PermissionBuilder.for(targetAddress).allowAll().build()], + } + + const explicitPrivateKey1 = Secp256k1.randomPrivateKey() + const explicitSigner1 = new Signers.Session.Explicit(explicitPrivateKey1, { + ...sessionPermission, + }) + + const explicitPrivateKey2 = Secp256k1.randomPrivateKey() + const explicitSigner2 = new Signers.Session.Explicit(explicitPrivateKey2, { + ...sessionPermission, + }) + + let sessionTopology = SessionConfig.addExplicitSession(SessionConfig.emptySessionsTopology(identityAddress), { + ...sessionPermission, + signer: explicitSigner1.address, + }) + sessionTopology = SessionConfig.addExplicitSession(sessionTopology, { + ...sessionPermission, + signer: explicitSigner2.address, + }) + await stateProvider.saveTree(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + const imageHash = GenericTree.hash(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [{ type: 'sapient-signer', address: extension.sessions, weight: 1n, imageHash }, Hex.random(32)], + }, + { stateProvider }, + ) + // Fund wallet with 2 ETH so we can send 0.5 ETH twice (each tx needs value + gas) + await provider.request({ + method: 'anvil_setBalance', + params: [wallet.address, Hex.fromNumber(2n * 1000000000000000000n)], + }) + + const sessionManager = new Signers.SessionManager(wallet, { + provider, + sessionManagerAddress: extension.sessions, + explicitSigners: [explicitSigner1, explicitSigner2], + }) + + const call: Payload.Call = { + to: targetAddress, + value: valueLimit, // one full limit + data: '0x' as Hex.Hex, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + // First send: uses first session (exhausts it) + const increment1 = await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + expect(increment1).not.toBeNull() + const calls1 = includeIncrement([call], increment1!, extension.sessions) + const tx1 = await buildAndSignCall(wallet, sessionManager, calls1, provider, chainId) + await simulateTransaction(provider, tx1) + + // Second send: same call. First session is exhausted so findSignersForCalls picks second session. + // Payload is [call, increment] (or [increment, call]). signSapient must derive expectedIncrement + // from non-increment calls only so it matches the increment we built for the second session. + const increment2 = await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + expect(increment2).not.toBeNull() + const calls2 = includeIncrement([call], increment2!, extension.sessions) + const tx2 = await buildAndSignCall(wallet, sessionManager, calls2, provider, chainId) + await simulateTransaction(provider, tx2) + }, + timeout, + ) + + describe('increment built from non-increment calls only', () => { + it( + 'prepareIncrement returns null when calls contain only an increment call (no non-increment calls)', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = Number(await provider.request({ method: 'eth_chainId' })) + + const identityPrivateKey = Secp256k1.randomPrivateKey() + const identityAddress = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: identityPrivateKey })) + const stateProvider = new State.Local.Provider() + + const sessionPermission: ExplicitSessionConfig = { + chainId, + valueLimit: 0n, + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), + permissions: [PermissionBuilder.for(EMITTER_ADDRESS1).allowAll().build()], + } + const explicitSigner = new Signers.Session.Explicit(Secp256k1.randomPrivateKey(), sessionPermission) + const sessionTopology = SessionConfig.addExplicitSession( + SessionConfig.emptySessionsTopology(identityAddress), + { + ...sessionPermission, + signer: explicitSigner.address, + }, + ) + await stateProvider.saveTree(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + const imageHash = GenericTree.hash(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [ + { type: 'sapient-signer', address: extension.sessions, weight: 1n, imageHash }, + Hex.random(32), + ], + }, + { stateProvider }, + ) + const sessionManager = new Signers.SessionManager(wallet, { + provider, + sessionManagerAddress: extension.sessions, + explicitSigners: [explicitSigner], + }) + + // Only an increment call (no non-increment calls). Contract would reject payload with only self-call; we return null. + const incrementOnlyCall: Payload.Call = { + to: extension.sessions, + data: AbiFunction.encodeData(Constants.INCREMENT_USAGE_LIMIT, [[]]), + value: 0n, + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + const result = await sessionManager.prepareIncrement(wallet.address, chainId, [incrementOnlyCall]) + expect(result).toBeNull() + }, + timeout, + ) + + it( + 'prepareIncrement([increment, nonIncrementCall]) produces same increment data as prepareIncrement([nonIncrementCall])', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = Number(await provider.request({ method: 'eth_chainId' })) + + const identityPrivateKey = Secp256k1.randomPrivateKey() + const identityAddress = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: identityPrivateKey })) + const stateProvider = new State.Local.Provider() + + const sessionPermission: ExplicitSessionConfig = { + chainId, + valueLimit: 0n, + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), + permissions: [PermissionBuilder.for(EMITTER_ADDRESS1).forFunction(EMITTER_FUNCTIONS[0]).onlyOnce().build()], + } + const explicitSigner = new Signers.Session.Explicit(Secp256k1.randomPrivateKey(), sessionPermission) + const sessionTopology = SessionConfig.addExplicitSession( + SessionConfig.emptySessionsTopology(identityAddress), + { + ...sessionPermission, + signer: explicitSigner.address, + }, + ) + await stateProvider.saveTree(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + const imageHash = GenericTree.hash(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [ + { type: 'sapient-signer', address: extension.sessions, weight: 1n, imageHash }, + Hex.random(32), + ], + }, + { stateProvider }, + ) + const sessionManager = new Signers.SessionManager(wallet, { + provider, + sessionManagerAddress: extension.sessions, + explicitSigners: [explicitSigner], + }) + + const nonIncrementCall: Payload.Call = { + to: EMITTER_ADDRESS1, + value: 0n, + data: AbiFunction.encodeData(EMITTER_FUNCTIONS[0]), + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + const fromNonIncrementOnly = await sessionManager.prepareIncrement(wallet.address, chainId, [ + nonIncrementCall, + ]) + expect(fromNonIncrementOnly).not.toBeNull() + + // Pass [staleIncrement, nonIncrementCall] — increment is ignored when building; result should match + const withStaleIncrement = await sessionManager.prepareIncrement(wallet.address, chainId, [ + fromNonIncrementOnly!, + nonIncrementCall, + ]) + expect(withStaleIncrement).not.toBeNull() + expect(withStaleIncrement!.data).toEqual(fromNonIncrementOnly!.data) + }, + timeout, + ) + + it( + 'payload with implicit and explicit: increment built only from explicit non-increment call', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = Number(await provider.request({ method: 'eth_chainId' })) + + const identityPrivateKey = Secp256k1.randomPrivateKey() + const identityAddress = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: identityPrivateKey })) + const stateProvider = new State.Local.Provider() + + const redirectUrl = 'https://example.com' + const implicitSigner = await createImplicitSigner(redirectUrl, identityPrivateKey) + const sessionPermission: ExplicitSessionConfig = { + chainId, + valueLimit: 0n, + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), + permissions: [PermissionBuilder.for(EMITTER_ADDRESS1).forFunction(EMITTER_FUNCTIONS[0]).onlyOnce().build()], + } + const explicitSigner = new Signers.Session.Explicit(Secp256k1.randomPrivateKey(), sessionPermission) + + // Topology: identity + blacklist (implicit) + explicit session + let sessionTopology = SessionConfig.emptySessionsTopology(identityAddress) + sessionTopology = SessionConfig.addExplicitSession(sessionTopology, { + ...sessionPermission, + signer: explicitSigner.address, + }) + await stateProvider.saveTree(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + const imageHash = GenericTree.hash(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [ + { type: 'sapient-signer', address: extension.sessions, weight: 1n, imageHash }, + Hex.random(32), + ], + }, + { stateProvider }, + ) + const sessionManager = new Signers.SessionManager(wallet, { + provider, + sessionManagerAddress: extension.sessions, + implicitSigners: [implicitSigner], + explicitSigners: [explicitSigner], + }) + + // Explicit call only (onlyOnce produces an increment; implicit does not match this target) + const call: Payload.Call = { + to: EMITTER_ADDRESS1, + value: 0n, + data: AbiFunction.encodeData(EMITTER_FUNCTIONS[0]), + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + const increment = await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + expect(increment).not.toBeNull() + const calls = includeIncrement([call], increment!, extension.sessions) + const transaction = await buildAndSignCall(wallet, sessionManager, calls, provider, chainId) + await simulateTransaction(provider, transaction, EMITTER_EVENT_TOPICS[0]) + }, + timeout, + ) + }) }) }