diff --git a/packages/wallet/core/src/signers/session-manager.ts b/packages/wallet/core/src/signers/session-manager.ts index 533dffc81..7eecafdb7 100644 --- a/packages/wallet/core/src/signers/session-manager.ts +++ b/packages/wallet/core/src/signers/session-manager.ts @@ -10,7 +10,7 @@ import { AbiFunction, Address, Hex, Provider } from 'ox' import * as State from '../state/index.js' import { Wallet } from '../wallet.js' import { SapientSigner } from './index.js' -import { Explicit, Implicit } from './session/index.js' +import { Explicit, Implicit, isExplicitSessionSigner, SessionSigner, UsageLimit } from './session/index.js' export type SessionManagerOptions = { sessionManagerAddress: Address.Address @@ -108,6 +108,111 @@ export class SessionManager implements SapientSigner { }) } + async findSignersForCalls(wallet: Address.Address, chainId: bigint, calls: Payload.Call[]): Promise { + // Only use signers that match the topology + const topology = await this.topology + const identitySigner = SessionConfig.getIdentitySigner(topology) + if (!identitySigner) { + throw new Error('Identity signer not found') + } + const blacklist = SessionConfig.getImplicitBlacklist(topology) + const validImplicitSigners = this._implicitSigners.filter( + (signer) => + Address.isEqual(signer.identitySigner, identitySigner) && + // Blacklist must exist for implicit signers to be used + blacklist && + !blacklist.some((b) => Address.isEqual(b, signer.address)), + ) + const topologyExplicitSigners = SessionConfig.getExplicitSigners(topology) + const validExplicitSigners = this._explicitSigners.filter((signer) => + topologyExplicitSigners.some((s) => Address.isEqual(s, signer.address)), + ) + + // Prioritize implicit signers + const availableSigners = [...validImplicitSigners, ...validExplicitSigners] + if (availableSigners.length === 0) { + throw new Error('No signers match the topology') + } + + // Find supported signers for each call + const signers: SessionSigner[] = [] + for (const call of calls) { + let supported = false + for (const signer of availableSigners) { + try { + supported = await signer.supportedCall(wallet, chainId, call, this.address, this._provider) + } catch (error) { + console.error('findSignersForCalls error', error) + continue + } + if (supported) { + signers.push(signer) + break + } + } + if (!supported) { + console.error('No signer supported for call', call) + throw new Error('No signer supported for call') + } + } + return signers + } + + async prepareIncrement( + wallet: Address.Address, + chainId: bigint, + calls: Payload.Call[], + ): Promise { + if (calls.length === 0) { + throw new Error('No calls provided') + } + const signers = await this.findSignersForCalls(wallet, chainId, calls) + + // Create a map of signers to their associated calls + const signerToCalls = new Map() + signers.forEach((signer, index) => { + const call = calls[index]! + const existingCalls = signerToCalls.get(signer) || [] + signerToCalls.set(signer, [...existingCalls, call]) + }) + + // Prepare increments for each explicit signer with their associated calls + const increments: UsageLimit[] = ( + await Promise.all( + Array.from(signerToCalls.entries()).map(async ([signer, associatedCalls]) => { + if (isExplicitSessionSigner(signer)) { + return signer.prepareIncrements(wallet, chainId, associatedCalls, this.address, this._provider!) + } + return [] + }), + ) + ).flat() + + if (increments.length === 0) { + return null + } + + // Error if there are repeated usage hashes + const uniqueIncrements = increments.filter( + (increment, index, self) => index === self.findIndex((t) => t.usageHash === increment.usageHash), + ) + if (uniqueIncrements.length !== increments.length) { + throw new Error('Repeated usage hashes') + } + + const data = AbiFunction.encodeData(Constants.INCREMENT_USAGE_LIMIT, [uniqueIncrements]) + + return { + to: this.address, + data, + value: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + gasLimit: 0n, + } + } + async signSapient( wallet: Address.Address, chainId: bigint, @@ -129,69 +234,59 @@ export class SessionManager implements SapientSigner { // throw new Error(`Provider chain id mismatch, expected ${Hex.fromNumber(chainId)} but got ${providerChainId}`) // } // } - if (!Payload.isCalls(payload)) { + if (!Payload.isCalls(payload) || payload.calls.length === 0) { throw new Error('Only calls are supported') } - // Only use signers that match the topology - const topology = await this.topology - const identitySigner = SessionConfig.getIdentitySigner(topology) - if (!identitySigner) { - throw new Error('Identity signer not found') + const signers = await this.findSignersForCalls(wallet, chainId, payload.calls) + if (signers.length !== payload.calls.length) { + throw new Error('No signer supported for call') } - const blacklist = SessionConfig.getImplicitBlacklist(topology) - const validImplicitSigners = this._implicitSigners.filter( - (signer) => - Address.isEqual(signer.identitySigner, identitySigner) && - // Blacklist must exist for implicit signers to be used - blacklist && - !blacklist.some((b) => Address.isEqual(b, signer.address)), - ) - const topologyExplicitSigners = SessionConfig.getExplicitSigners(topology) - const validExplicitSigners = this._explicitSigners.filter((signer) => - topologyExplicitSigners.some((s) => Address.isEqual(s, signer.address)), + const signatures = await Promise.all( + signers.map(async (signer, i) => { + const call = payload.calls[i]! + try { + return signer.signCall(wallet, chainId, call, payload, this.address, this._provider) + } catch (error) { + console.error('signSapient error', error) + throw error + } + }), ) - // Try to sign with each signer, prioritizing implicit signers - const signers = [...validImplicitSigners, ...validExplicitSigners] - if (signers.length === 0) { - throw new Error('No signers match the topology') + // Check if the last call is an increment usage call + const expectedIncrement = await this.prepareIncrement(wallet, chainId, payload.calls) + if (expectedIncrement) { + // This should equal the last call + const lastCall = payload.calls[payload.calls.length - 1]! + if (!Address.isEqual(expectedIncrement.to, lastCall.to) || !Hex.isEqual(expectedIncrement.data, lastCall.data)) { + throw new Error('Expected increment mismatch') + } } - const signatures = await Promise.all( - //FIXME Run sync to support cumulative rules within a payload - payload.calls.map(async (call) => { - for (const signer of signers) { - try { - if (await signer.supportedCall(wallet, chainId, call, this._provider)) { - const signature = await signer.signCall(wallet, chainId, call, payload, this._provider) - return { - ...signature, - signer: signer.address, - } - } - } catch (error) { - console.error('signSapient error', error) - } + // Encode the signature + const explicitSigners: Address.Address[] = [] + const implicitSigners: Address.Address[] = [] + await Promise.all( + signers.map(async (signer) => { + if (isExplicitSessionSigner(signer)) { + explicitSigners.push(await signer.address) + } else { + implicitSigners.push(await signer.address) } - throw new Error('No signer supported') }), ) - - const explicitSigners = signatures - .filter((sig) => SessionSignature.isExplicitSessionCallSignature(sig)) - .map((sig) => sig.signer) - - const implicitSigners = signatures - .filter((sig) => SessionSignature.isImplicitSessionCallSignature(sig)) - .map((sig) => sig.signer) + const encodedSignature = SessionSignature.encodeSessionCallSignatures( + signatures, + await this.topology, + explicitSigners, + implicitSigners, + ) return { type: 'sapient', address: this.address, - data: Hex.from( - SessionSignature.encodeSessionCallSignatures(signatures, topology, explicitSigners, implicitSigners), - ), + data: Hex.from(encodedSignature), } } diff --git a/packages/wallet/core/src/signers/session/explicit.ts b/packages/wallet/core/src/signers/session/explicit.ts index b113bf9d6..f71267d57 100644 --- a/packages/wallet/core/src/signers/session/explicit.ts +++ b/packages/wallet/core/src/signers/session/explicit.ts @@ -1,11 +1,13 @@ -import { Payload, Permission, SessionSignature, Utils } from '@0xsequence/wallet-primitives' -import { AbiParameters, Address, Bytes, Hash, Hex, Provider, Secp256k1 } from 'ox' -import { SessionSigner } from './session.js' +import { Payload, Permission, SessionSignature, Constants } from '@0xsequence/wallet-primitives' +import { AbiFunction, AbiParameters, Address, Bytes, Hash, Hex, Provider } from 'ox' import { MemoryPkStore, PkStore } from '../pk/index.js' +import { ExplicitSessionSigner, UsageLimit } from './session.js' export type ExplicitParams = Omit -export class Explicit implements SessionSigner { +const VALUE_TRACKING_ADDRESS: Address.Address = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + +export class Explicit implements ExplicitSessionSigner { private readonly _privateKey: PkStore public readonly address: Address.Address @@ -24,57 +26,146 @@ export class Explicit implements SessionSigner { wallet: Address.Address, _chainId: bigint, call: Payload.Call, + sessionManagerAddress: Address.Address, provider?: Provider.Provider, ): Promise { - // Wallet and signer are encoded as a prefix for the usage hash - const limitHashPrefix = Hash.keccak256( + if (call.value !== 0n) { + // Validate the value + if (!provider) { + throw new Error('Value transaction validation requires a provider') + } + const usageHash = Hash.keccak256( + AbiParameters.encode( + [ + { type: 'address', name: 'signer' }, + { type: 'address', name: 'valueTrackingAddress' }, + ], + [this.address, VALUE_TRACKING_ADDRESS], + ), + ) + const { usageAmount } = await this.readCurrentUsageLimit(wallet, sessionManagerAddress, usageHash, provider) + const value = Bytes.fromNumber(usageAmount + call.value, { size: 32 }) + if (Bytes.toBigInt(value) > this.sessionPermissions.valueLimit) { + return undefined + } + } + + for (const permission of this.sessionPermissions.permissions) { + // Validate the permission + if (await this.validatePermission(permission, call, wallet, sessionManagerAddress, provider)) { + return permission + } + } + return undefined + } + + private getPermissionUsageHash(permission: Permission.Permission, ruleIndex: number): Hex.Hex { + const encodedPermission = { + target: permission.target, + rules: permission.rules.map((rule) => ({ + cumulative: rule.cumulative, + operation: rule.operation, + value: Bytes.toHex(rule.value), + offset: rule.offset, + mask: Bytes.toHex(rule.mask), + })), + } + return Hash.keccak256( + AbiParameters.encode( + [{ type: 'address', name: 'signer' }, Permission.permissionStructAbi, { type: 'uint256', name: 'ruleIndex' }], + [this.address, encodedPermission, BigInt(ruleIndex)], + ), + ) + } + + private getValueUsageHash(): Hex.Hex { + return Hash.keccak256( AbiParameters.encode( [ - { type: 'address', name: 'wallet' }, { type: 'address', name: 'signer' }, + { type: 'address', name: 'valueTrackingAddress' }, ], - [wallet, this.address], + [this.address, VALUE_TRACKING_ADDRESS], ), ) - for (const [permissionIndex, permission] of this.sessionPermissions.permissions.entries()) { - // Encode the permission and permission index as a parameter for the usage hash - const encodeParams = [ - { type: 'bytes32', name: 'limitHashPrefix' }, - Permission.permissionStructAbi, - { type: 'uint256', name: 'permissionIndex' }, - ] as const - const usageHash = Hash.keccak256( - AbiParameters.encode(encodeParams, [ - limitHashPrefix, - { - target: permission.target, - rules: permission.rules.map((rule) => ({ - cumulative: rule.cumulative, - operation: rule.operation, - value: Bytes.toHex(Bytes.padRight(rule.value, 32)), - offset: rule.offset, - mask: Bytes.toHex(Bytes.padRight(rule.mask, 32)), - })), - }, - BigInt(permissionIndex), - ]), + } + + async validatePermission( + permission: Permission.Permission, + call: Payload.Call, + wallet: Address.Address, + sessionManagerAddress: Address.Address, + provider?: Provider.Provider, + ): Promise { + if (!Address.isEqual(permission.target, call.to)) { + return false + } + + for (const [ruleIndex, rule] of permission.rules.entries()) { + // Extract value from calldata at offset + const callDataValue = Bytes.padRight( + Bytes.fromHex(call.data).slice(Number(rule.offset), Number(rule.offset) + 32), + 32, ) - // Validate the permission - if (await validatePermission(permission, call, provider, usageHash)) { - return permission + // Apply mask + let value: Bytes.Bytes = callDataValue.map((b, i) => b & rule.mask[i]!) + if (rule.cumulative) { + if (provider) { + const { usageAmount } = await this.readCurrentUsageLimit( + wallet, + sessionManagerAddress, + this.getPermissionUsageHash(permission, ruleIndex), + provider, + ) + // Increment the value + value = Bytes.fromNumber(usageAmount + Bytes.toBigInt(value), { size: 32 }) + } else { + throw new Error('Cumulative rules require a provider') + } + } + + // Compare based on operation + if (rule.operation === Permission.ParameterOperation.EQUAL) { + if (!Bytes.isEqual(value, rule.value)) { + return false + } + } + if (rule.operation === Permission.ParameterOperation.LESS_THAN_OR_EQUAL) { + if (Bytes.toBigInt(value) > Bytes.toBigInt(rule.value)) { + return false + } + } + if (rule.operation === Permission.ParameterOperation.NOT_EQUAL) { + if (Bytes.isEqual(value, rule.value)) { + return false + } + } + if (rule.operation === Permission.ParameterOperation.GREATER_THAN_OR_EQUAL) { + if (Bytes.toBigInt(value) < Bytes.toBigInt(rule.value)) { + return false + } } } - return undefined + + return true } async supportedCall( wallet: Address.Address, chainId: bigint, call: Payload.Call, + sessionManagerAddress: Address.Address, provider?: Provider.Provider, ): Promise { - //FIXME Should this be stateful to support cumulative rules within a payload? - const permission = await this.findSupportedPermission(wallet, chainId, call, provider) + if ( + Hex.size(call.data) > 4 && + Hex.isEqual(Hex.slice(call.data, 0, 4), AbiFunction.getSelector(Constants.INCREMENT_USAGE_LIMIT)) + ) { + // Can sign increment usage calls + return true + } + + const permission = await this.findSupportedPermission(wallet, chainId, call, sessionManagerAddress, provider) if (!permission) { return false } @@ -89,19 +180,30 @@ export class Explicit implements SessionSigner { space: bigint nonce: bigint }, + sessionManagerAddress: Address.Address, provider?: Provider.Provider, ): Promise { - // Find the valid permission for this call - const permission = await this.findSupportedPermission(wallet, chainId, call, provider) - if (!permission) { - // This covers the support check - throw new Error('Invalid permission') - } - const permissionIndex = this.sessionPermissions.permissions.indexOf(permission) - if (permissionIndex === -1) { - // Unreachable - throw new Error('Invalid permission') + let permissionIndex: number + if ( + Hex.size(call.data) > 4 && + Hex.isEqual(Hex.slice(call.data, 0, 4), AbiFunction.getSelector(Constants.INCREMENT_USAGE_LIMIT)) + ) { + // Permission check not required. Use the first permission + permissionIndex = 0 + } else { + // Find the valid permission for this call + const permission = await this.findSupportedPermission(wallet, chainId, call, sessionManagerAddress, provider) + if (!permission) { + // This covers the support check + throw new Error('Invalid permission') + } + permissionIndex = this.sessionPermissions.permissions.indexOf(permission) + if (permissionIndex === -1) { + // Unreachable + throw new Error('Invalid permission') + } } + // Sign it const callHash = SessionSignature.hashCallWithReplayProtection(call, chainId, nonce.space, nonce.nonce) const sessionSignature = await this._privateKey.signDigest(Bytes.fromHex(callHash)) @@ -110,64 +212,114 @@ export class Explicit implements SessionSigner { sessionSignature, } } -} -async function validatePermission( - permission: Permission.Permission, - call: Payload.Call, - provider?: Provider.Provider, - usageHash?: Hex.Hex, -): Promise { - if (!Address.isEqual(permission.target, call.to)) { - return false + private async readCurrentUsageLimit( + wallet: Address.Address, + sessionManagerAddress: Address.Address, + usageHash: Hex.Hex, + provider: Provider.Provider, + ): Promise { + const readData = AbiFunction.encodeData(Constants.GET_LIMIT_USAGE, [wallet, usageHash]) + const getUsageLimitResult = await provider.request({ + method: 'eth_call', + params: [ + { + to: sessionManagerAddress, + data: readData, + }, + ], + }) + const usageAmount = AbiFunction.decodeResult(Constants.GET_LIMIT_USAGE, getUsageLimitResult) + return { + usageHash, + usageAmount, + } } - for (const rule of permission.rules) { - // Extract value from calldata at offset - const callDataValue = Bytes.padRight( - Bytes.fromHex(call.data).slice(Number(rule.offset), Number(rule.offset) + 32), - 32, - ) - // Apply mask - let value: Bytes.Bytes = callDataValue.map((b, i) => b & rule.mask[i]!) - - if (rule.cumulative) { - if (provider && usageHash) { - // Get the cumulative value from the contract storage - const storageSlot = Utils.getStorageSlotForMappingWithKey(Hex.toBigInt(usageHash), Hex.fromBytes(rule.value)) - const storageValue = await provider.request({ - method: 'eth_getStorageAt', - params: [permission.target, storageSlot, 'latest'], - }) - // Increment the value - value = Bytes.padLeft(Bytes.fromNumber(Hex.toBigInt(storageValue) + Bytes.toBigInt(value)), 32) - } else { - throw new Error('Cumulative rules require a provider and usage hash') - } - } + async prepareIncrements( + wallet: Address.Address, + chainId: bigint, + calls: Payload.Call[], + sessionManagerAddress: Address.Address, + provider?: Provider.Provider, + ): Promise { + const increments: { usageHash: Hex.Hex; increment: bigint }[] = [] + const usageValueHash = this.getValueUsageHash() + + for (const call of calls) { + // Find matching permission + const perm = await this.findSupportedPermission(wallet, chainId, call, sessionManagerAddress, provider) + if (!perm) continue - // Compare based on operation - if (rule.operation === Permission.ParameterOperation.EQUAL) { - if (!Bytes.isEqual(value, rule.value)) { - return false + const cumulativeRules = perm.rules.filter((r) => r.cumulative) + if (cumulativeRules.length > 0) { + for (const [ruleIndex, rule] of cumulativeRules.entries()) { + // Extract the masked value + const callDataValue = Bytes.padRight( + Bytes.fromHex(call.data).slice(Number(rule.offset), Number(rule.offset) + 32), + 32, + ) + let value: Bytes.Bytes = callDataValue.map((b, i) => b & rule.mask[i]!) + if (Bytes.toBigInt(value) === 0n) continue + + // Add to list + const usageHash = this.getPermissionUsageHash(perm, ruleIndex) + const existingIncrement = increments.find((i) => Hex.isEqual(i.usageHash, usageHash)) + if (existingIncrement) { + existingIncrement.increment += Bytes.toBigInt(value) + } else { + increments.push({ + usageHash, + increment: Bytes.toBigInt(value), + }) + } + } } - } - if (rule.operation === Permission.ParameterOperation.LESS_THAN_OR_EQUAL) { - if (Bytes.toBigInt(value) > Bytes.toBigInt(rule.value)) { - return false + + // Check the value + if (call.value !== 0n) { + const existingIncrement = increments.find((i) => Hex.isEqual(i.usageHash, usageValueHash)) + if (existingIncrement) { + existingIncrement.increment += call.value + } else { + increments.push({ + usageHash: usageValueHash, + increment: call.value, + }) + } } } - if (rule.operation === Permission.ParameterOperation.NOT_EQUAL) { - if (Bytes.isEqual(value, rule.value)) { - return false - } + + // If no increments, return early + if (increments.length === 0) { + return [] } - if (rule.operation === Permission.ParameterOperation.GREATER_THAN_OR_EQUAL) { - if (Bytes.toBigInt(value) < Bytes.toBigInt(rule.value)) { - return false - } + + // Provider is required if we have increments + if (!provider) { + throw new Error('Provider required for cumulative rules') } - } - return true + // Apply current usage limit to each increment + return Promise.all( + increments.map(async ({ usageHash, increment }) => { + if (increment === 0n) return null + + const currentUsage = await this.readCurrentUsageLimit(wallet, sessionManagerAddress, usageHash, provider) + + // For value usage hash, validate against the limit + if (Hex.isEqual(usageHash, usageValueHash)) { + const totalValue = currentUsage.usageAmount + increment + if (totalValue > this.sessionPermissions.valueLimit) { + throw new Error('Value transaction validation failed') + } + } + + return { + usageHash, + usageAmount: currentUsage.usageAmount + increment, + } + }), + ).then((results) => results.filter((r): r is UsageLimit => r !== null)) + } } diff --git a/packages/wallet/core/src/signers/session/implicit.ts b/packages/wallet/core/src/signers/session/implicit.ts index 2bb600101..e272ef909 100644 --- a/packages/wallet/core/src/signers/session/implicit.ts +++ b/packages/wallet/core/src/signers/session/implicit.ts @@ -36,6 +36,7 @@ export class Implicit implements SessionSigner { wallet: Address.Address, _chainId: bigint, call: Payload.Call, + _sessionManagerAddress: Address.Address, provider?: Provider.Provider, ): Promise { if (!provider) { @@ -73,7 +74,7 @@ export class Implicit implements SessionSigner { const expectedResult = Bytes.toHex(Attestation.generateImplicitRequestMagic(this._attestation, wallet)) return acceptImplicitRequest === expectedResult } catch (error) { - console.log('implicit signer unsupported call', call, error) + // console.log('implicit signer unsupported call', call, error) return false } } @@ -86,9 +87,10 @@ export class Implicit implements SessionSigner { space: bigint nonce: bigint }, + sessionManagerAddress: Address.Address, provider?: Provider.Provider, ): Promise { - const isSupported = await this.supportedCall(wallet, chainId, call, provider) + const isSupported = await this.supportedCall(wallet, chainId, call, sessionManagerAddress, provider) if (!isSupported) { throw new Error('Unsupported call') } diff --git a/packages/wallet/core/src/signers/session/session.ts b/packages/wallet/core/src/signers/session/session.ts index 833fd9f0a..a720f529a 100644 --- a/packages/wallet/core/src/signers/session/session.ts +++ b/packages/wallet/core/src/signers/session/session.ts @@ -1,5 +1,5 @@ -import { Address, Provider } from 'ox' import { Payload, SessionSignature } from '@0xsequence/wallet-primitives' +import { Address, Hex, Provider } from 'ox' export interface SessionSigner { address: Address.Address | Promise @@ -9,6 +9,7 @@ export interface SessionSigner { wallet: Address.Address, chainId: bigint, call: Payload.Call, + sessionManagerAddress: Address.Address, provider?: Provider.Provider, ) => Promise @@ -21,6 +22,26 @@ export interface SessionSigner { space: bigint nonce: bigint }, + sessionManagerAddress: Address.Address, provider?: Provider.Provider, ) => Promise } + +export type UsageLimit = { + usageHash: Hex.Hex + usageAmount: bigint +} + +export interface ExplicitSessionSigner extends SessionSigner { + prepareIncrements: ( + wallet: Address.Address, + chainId: bigint, + calls: Payload.Call[], + sessionManagerAddress: Address.Address, + provider: Provider.Provider, + ) => Promise +} + +export function isExplicitSessionSigner(signer: SessionSigner): signer is ExplicitSessionSigner { + return 'prepareIncrements' in signer +} diff --git a/packages/wallet/core/test/session-manager.test.ts b/packages/wallet/core/test/session-manager.test.ts index 6609d436a..b397fe5df 100644 --- a/packages/wallet/core/test/session-manager.test.ts +++ b/packages/wallet/core/test/session-manager.test.ts @@ -253,12 +253,12 @@ describe('SessionManager', () => { const buildAndSignCall = async ( wallet: Wallet, sessionManager: Signers.SessionManager, - call: Payload.Call, + calls: Payload.Call[], provider: Provider.Provider, chainId: bigint, ) => { // Prepare the transaction - const envelope = await wallet.prepareTransaction(provider, [call]) + const envelope = await wallet.prepareTransaction(provider, calls) const parentedEnvelope: Payload.Parented = { ...envelope.payload, parentWallets: [wallet.address], @@ -281,7 +281,7 @@ describe('SessionManager', () => { const simulateTransaction = async ( provider: Provider.Provider, transaction: { to: Address.Address; data: Hex.Hex }, - expectedEventTopic: Hex.Hex, + expectedEventTopic?: Hex.Hex, ) => { console.log('Simulating transaction', transaction) const txHash = await provider.request({ @@ -300,20 +300,21 @@ describe('SessionManager', () => { throw new Error('Transaction receipt not found') } - // Check for event - if (!receipt.logs) { - throw new Error('No events emitted') - } - if (!receipt.logs.some((log) => log.topics.includes(expectedEventTopic))) { - throw new Error(`Expected topic ${expectedEventTopic} not found in event`) + if (expectedEventTopic) { + // Check for event + if (!receipt.logs) { + throw new Error('No events emitted') + } + if (!receipt.logs.some((log) => log.topics.includes(expectedEventTopic))) { + throw new Error(`Expected topic ${expectedEventTopic} not found in event`) + } } return receipt } - // Submit a real transaction with a wallet that has a SessionManager using implicit session it( - 'Submits a real transaction with a wallet that has a SessionManager using implicit session', + 'signs a payload using an implicit session', async () => { // Check the contracts have been deployed const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) @@ -377,14 +378,14 @@ describe('SessionManager', () => { } // Build, sign and send the transaction - const transaction = await buildAndSignCall(wallet, sessionManager, call, provider, chainId) + const transaction = await buildAndSignCall(wallet, sessionManager, [call], provider, chainId) await simulateTransaction(provider, transaction, EMITTER_EVENT_TOPICS[1]) }, timeout, ) it( - 'Submits a real transaction with a wallet that has a SessionManager using explicit session', + 'signs a payload using an explicit session', async () => { const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) const chainId = BigInt(await provider.request({ method: 'eth_chainId' })) @@ -443,9 +444,334 @@ describe('SessionManager', () => { } // Build, sign and send the transaction - const transaction = await buildAndSignCall(wallet, sessionManager, call, provider, chainId) + const transaction = await buildAndSignCall(wallet, sessionManager, [call], provider, chainId) await simulateTransaction(provider, transaction, EMITTER_EVENT_TOPICS[0]) }, timeout, ) + + it( + 'signs a payload using an explicit session', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = BigInt(await provider.request({ method: 'eth_chainId' })) + + // Create explicit signer + const explicitPrivateKey = Secp256k1.randomPrivateKey() + const sessionPermission: Signers.Session.ExplicitParams = { + valueLimit: 0n, + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour from now + permissions: [ + { + target: EMITTER_ADDRESS, + rules: [ + { + cumulative: true, + operation: Permission.ParameterOperation.EQUAL, + value: Bytes.fromHex(AbiFunction.getSelector(EMITTER_FUNCTIONS[0]), { size: 32 }), + offset: 0n, + mask: Permission.SELECTOR_MASK, + }, + ], + }, + ], + } + const explicitSigner = new Signers.Session.Explicit(explicitPrivateKey, sessionPermission) + // Test manually building the session topology + const sessionTopology = SessionConfig.addExplicitSession(SessionConfig.emptySessionsTopology(identityAddress), { + ...sessionPermission, + signer: explicitSigner.address, + }) + await stateProvider.saveTree(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + const imageHash = GenericTree.hash(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + + // Create the wallet + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [ + // Random explicit signer will randomise the image hash + { + type: 'sapient-signer', + address: Constants.DefaultSessionManager, + weight: 1n, + imageHash, + }, + // Include a random node leaf (bytes32) to prevent image hash collision + Hex.random(32), + ], + }, + { + stateProvider, + }, + ) + // Create the session manager + const sessionManager = new Signers.SessionManager(wallet, { + provider, + explicitSigners: [explicitSigner], + }) + + const call: Payload.Call = { + to: EMITTER_ADDRESS, + value: 0n, + data: AbiFunction.encodeData(EMITTER_FUNCTIONS[0]), // Explicit emit + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + const increment = await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + expect(increment).not.toBeNull() + expect(increment).toBeDefined() + + if (!increment) { + return + } + + // Build, sign and send the transaction + const transaction = await buildAndSignCall(wallet, sessionManager, [call, increment], provider, chainId) + await simulateTransaction(provider, transaction, EMITTER_EVENT_TOPICS[0]) + + // Repeat call fails because the usage limit has been reached + try { + await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + throw new Error('Expected call as no signer supported to fail') + } catch (error) { + expect(error).toBeDefined() + expect(error.message).toContain('No signer supported') + } + }, + timeout, + ) + + it( + 'signs a payload sending value using an explicit session', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = BigInt(await provider.request({ method: 'eth_chainId' })) + + // Create explicit signer + const explicitPrivateKey = Secp256k1.randomPrivateKey() + const explicitAddress = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: explicitPrivateKey })) + const sessionPermission: Signers.Session.ExplicitParams = { + valueLimit: 1000000000000000000n, // 1 ETH + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour from now + permissions: [ + { + target: explicitAddress, + rules: [ + { + cumulative: true, + operation: Permission.ParameterOperation.EQUAL, + value: Bytes.fromHex(AbiFunction.getSelector(EMITTER_FUNCTIONS[0]), { size: 32 }), + offset: 0n, + mask: Permission.SELECTOR_MASK, + }, + ], + }, + ], + } + const explicitSigner = new Signers.Session.Explicit(explicitPrivateKey, sessionPermission) + // Test manually building the session topology + const sessionTopology = SessionConfig.addExplicitSession(SessionConfig.emptySessionsTopology(identityAddress), { + ...sessionPermission, + signer: explicitSigner.address, + }) + await stateProvider.saveTree(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + const imageHash = GenericTree.hash(SessionConfig.sessionsTopologyToConfigurationTree(sessionTopology)) + + // Create the wallet + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [ + // Random explicit signer will randomise the image hash + { + type: 'sapient-signer', + address: Constants.DefaultSessionManager, + weight: 1n, + imageHash, + }, + // Include a random node leaf (bytes32) to prevent image hash collision + Hex.random(32), + ], + }, + { + stateProvider, + }, + ) + // Force 1 ETH to the wallet + await provider.request({ + method: 'anvil_setBalance', + params: [wallet.address, Hex.fromNumber(1000000000000000000n)], + }) + // Create the session manager + const sessionManager = new Signers.SessionManager(wallet, { + provider, + explicitSigners: [explicitSigner], + }) + + const call: Payload.Call = { + to: explicitAddress, + value: 1000000000000000000n, // 1 ETH + data: AbiFunction.encodeData(EMITTER_FUNCTIONS[0]), // Explicit emit + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + const increment = await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + expect(increment).not.toBeNull() + expect(increment).toBeDefined() + + if (!increment) { + return + } + + // Build, sign and send the transaction + const transaction = await buildAndSignCall(wallet, sessionManager, [call, increment], provider, chainId) + await simulateTransaction(provider, transaction) + + // Check the balances + const walletBalance = await provider.request({ + method: 'eth_getBalance', + params: [wallet.address, 'latest'], + }) + expect(BigInt(walletBalance)).toBe(0n) + const explicitAddressBalance = await provider.request({ + method: 'eth_getBalance', + params: [explicitAddress, 'latest'], + }) + expect(BigInt(explicitAddressBalance)).toBe(1000000000000000000n) + + // Repeat call fails because the usage limit has been reached + try { + await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + throw new Error('Expected call as no signer supported to fail') + } catch (error) { + expect(error).toBeDefined() + expect(error.message).toContain('No signer supported') + } + }, + timeout, + ) + + it( + 'signs a payload sending two transactions with cumulative rules using an explicit session', + async () => { + const provider = Provider.from(RpcTransport.fromHttp(LOCAL_RPC_URL)) + const chainId = BigInt(await provider.request({ method: 'eth_chainId' })) + + // Create explicit signer + const explicitPrivateKey = Secp256k1.randomPrivateKey() + const explicitAddress = Address.fromPublicKey(Secp256k1.getPublicKey({ privateKey: explicitPrivateKey })) + const sessionPermission: Signers.Session.ExplicitParams = { + valueLimit: 1000000000000000000n, // 1 ETH + deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour from now + permissions: [ + { + target: explicitAddress, + rules: [ + // This rule is a hack. The selector "usage" will increment for testing. As we check for greater than or equal, + // we can use this to test that the usage is cumulative. + { + cumulative: true, + operation: Permission.ParameterOperation.GREATER_THAN_OR_EQUAL, + value: Bytes.fromHex(AbiFunction.getSelector(EMITTER_FUNCTIONS[0]), { size: 32 }), + offset: 0n, + mask: Permission.SELECTOR_MASK, + }, + ], + }, + ], + } + const explicitSigner = new Signers.Session.Explicit(explicitPrivateKey, 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)) + + // Create the wallet + const wallet = await Wallet.fromConfiguration( + { + threshold: 1n, + checkpoint: 0n, + topology: [ + { + type: 'sapient-signer', + address: Constants.DefaultSessionManager, + weight: 1n, + imageHash, + }, + // Include a random node leaf (bytes32) to prevent image hash collision + Hex.random(32), + ], + }, + { + stateProvider, + }, + ) + // Force 1 ETH to the wallet + await provider.request({ + method: 'anvil_setBalance', + params: [wallet.address, Hex.fromNumber(1000000000000000000n)], + }) + // Create the session manager + const sessionManager = new Signers.SessionManager(wallet, { + provider, + explicitSigners: [explicitSigner], + }) + + const call: Payload.Call = { + to: explicitAddress, + value: 500000000000000000n, // 0.5 ETH + data: AbiFunction.encodeData(EMITTER_FUNCTIONS[0]), // Explicit emit + gasLimit: 0n, + delegateCall: false, + onlyFallback: false, + behaviorOnError: 'revert', + } + + // Do it twice to test cumulative rules + const increment = await sessionManager.prepareIncrement(wallet.address, chainId, [call, call]) + expect(increment).not.toBeNull() + expect(increment).toBeDefined() + + if (!increment) { + return + } + + // Build, sign and send the transaction + const transaction = await buildAndSignCall(wallet, sessionManager, [call, call, increment], provider, chainId) + await simulateTransaction(provider, transaction) + + // Check the balances + const walletBalance = await provider.request({ + method: 'eth_getBalance', + params: [wallet.address, 'latest'], + }) + expect(BigInt(walletBalance)).toBe(0n) + const explicitAddressBalance = await provider.request({ + method: 'eth_getBalance', + params: [explicitAddress, 'latest'], + }) + expect(BigInt(explicitAddressBalance)).toBe(1000000000000000000n) + + // Repeat call fails because the usage limit has been reached + try { + await sessionManager.prepareIncrement(wallet.address, chainId, [call]) + throw new Error('Expected call as no signer supported to fail') + } catch (error) { + expect(error).toBeDefined() + expect(error.message).toContain('No signer supported') + } + }, + timeout, + ) }) diff --git a/packages/wallet/primitives/src/constants.ts b/packages/wallet/primitives/src/constants.ts index 419119ef3..1df5cbfdc 100644 --- a/packages/wallet/primitives/src/constants.ts +++ b/packages/wallet/primitives/src/constants.ts @@ -37,3 +37,27 @@ export const RECOVER_SAPIENT_SIGNATURE = Abi.from([ export const RECOVER_SAPIENT_SIGNATURE_COMPACT = Abi.from([ 'function recoverSapientSignatureCompact(bytes32 _digest, bytes calldata _signature) external view returns (bytes32)', ])[0] + +// SessionManager +export const INCREMENT_USAGE_LIMIT = Abi.from([ + { + type: 'function', + name: 'incrementUsageLimit', + inputs: [ + { + name: 'limits', + type: 'tuple[]', + internalType: 'struct UsageLimit[]', + components: [ + { name: 'usageHash', type: 'bytes32', internalType: 'bytes32' }, + { name: 'usageAmount', type: 'uint256', internalType: 'uint256' }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, +])[0] +export const GET_LIMIT_USAGE = Abi.from([ + 'function getLimitUsage(address wallet, bytes32 usageHash) public view returns (uint256)', +])[0] diff --git a/packages/wallet/primitives/src/permission.ts b/packages/wallet/primitives/src/permission.ts index 03f6497ec..deca47f61 100644 --- a/packages/wallet/primitives/src/permission.ts +++ b/packages/wallet/primitives/src/permission.ts @@ -30,6 +30,8 @@ export type SessionPermissions = { export const MAX_PERMISSIONS_COUNT = 2 ** 7 - 1 export const MAX_RULES_COUNT = 2 ** 8 - 1 +export const SELECTOR_MASK = Bytes.fromHex('0xffffffff', { size: 32 }) + // Encoding export function encodeSessionPermissions(sessionPermissions: SessionPermissions): Bytes.Bytes { diff --git a/packages/wallet/primitives/src/utils.ts b/packages/wallet/primitives/src/utils.ts index f0dfe1dd3..7a70fb087 100644 --- a/packages/wallet/primitives/src/utils.ts +++ b/packages/wallet/primitives/src/utils.ts @@ -25,11 +25,6 @@ export function unpackRSY(rsy: Bytes.Bytes): { r: bigint; s: bigint; yParity: nu return { r, s, yParity } } -export function getStorageSlotForMappingWithKey(mappingSlot: bigint, key: Hex.Hex): Hex.Hex { - const paddedKey = Hex.padLeft(key, 32) - return Hash.keccak256(AbiParameters.encode([{ type: 'bytes32' }, { type: 'uint256' }], [paddedKey, mappingSlot])) -} - /** * Creates a replacer function for JSON.stringify that handles BigInt and Uint8Array serialization * Converts BigInt values to objects with format { __bigint: "0x..." } diff --git a/packages/wallet/wdk/test/sessions.test.ts b/packages/wallet/wdk/test/sessions.test.ts index 2824da7fc..35339686e 100644 --- a/packages/wallet/wdk/test/sessions.test.ts +++ b/packages/wallet/wdk/test/sessions.test.ts @@ -177,9 +177,9 @@ describe('Sessions (via Manager)', () => { // Require the explicitEmit selector cumulative: false, operation: Permission.ParameterOperation.EQUAL, - value: Bytes.padRight(Bytes.fromHex(AbiFunction.getSelector(EMITTER_ABI[0])), 32), + value: Bytes.fromHex(AbiFunction.getSelector(EMITTER_ABI[0]), { size: 32 }), offset: 0n, - mask: Bytes.padRight(Bytes.fromHex('0xffffffff'), 32), + mask: Bytes.fromHex('0xffffffff', { size: 32 }), }, ], },