From 22597f33d56357b5926926d603d0334fe7be2c9d Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Wed, 20 Aug 2025 14:11:07 -0600 Subject: [PATCH 1/6] refactor: improve declarative delegation interface --- .../src/caveatBuilder/resolveCaveats.ts | 38 +- .../scope/nativeTokenPeriodicScope.ts | 41 ++- .../scope/nativeTokenStreamingScope.ts | 44 ++- .../scope/nativeTokenTransferScope.ts | 31 +- packages/delegation-toolkit/src/delegation.ts | 3 +- .../delegationManagement.test.ts | 3 - .../scope/nativeTokenTransferScope.test.ts | 68 +++- .../test/delegation.test.ts | 36 +- .../contracts/src/PayableReceiver.sol | 50 +++ .../infuraBundlerClient.test.ts | 1 - .../test/actions/infuraBundlerClient.test.ts | 1 - .../test/caveats/allowedMethods.test.ts | 2 - .../test/caveats/caveatUtils.test.ts | 17 - .../test/caveats/erc20PeriodTransfer.test.ts | 2 - .../test/caveats/erc20Streaming.test.ts | 2 - .../test/caveats/erc20TransferAmount.test.ts | 2 - .../test/caveats/erc721Transfer.test.ts | 2 - .../caveats/nativeTokenPeriodTransfer.test.ts | 323 ++++++++++++++++- .../test/caveats/nativeTokenStreaming.test.ts | 331 +++++++++++++++++- .../caveats/nativeTokenTransferAmount.test.ts | 303 +++++++++++++++- .../test/caveats/ownershipTransfer.test.ts | 4 - .../test/delegateAndRedeem.test.ts | 3 - .../test/delegationManagement.test.ts | 6 - packages/delegator-e2e/test/utils/helpers.ts | 74 ++++ 24 files changed, 1293 insertions(+), 94 deletions(-) create mode 100644 packages/delegator-e2e/contracts/src/PayableReceiver.sol diff --git a/packages/delegation-toolkit/src/caveatBuilder/resolveCaveats.ts b/packages/delegation-toolkit/src/caveatBuilder/resolveCaveats.ts index fb70c103..0ef74364 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/resolveCaveats.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/resolveCaveats.ts @@ -10,7 +10,7 @@ export type Caveats = CaveatBuilder | (Caveat | CoreCaveatConfiguration)[]; * @param config - The configuration for the caveat builder. * @param config.environment - The environment to be used for the caveat builder. * @param config.scope - The scope to be used for the caveat builder. - * @param config.caveats - The caveats to be resolved, which can be either a CaveatBuilder or an array of Caveat or CaveatConfiguration. + * @param config.caveats - The caveats to be resolved, which can be either a CaveatBuilder or an array of Caveat or CaveatConfiguration. Optional - if not provided, only scope caveats will be used. * @returns The resolved array of caveats. */ export const resolveCaveats = ({ @@ -20,27 +20,29 @@ export const resolveCaveats = ({ }: { environment: DeleGatorEnvironment; scope: ScopeConfig; - caveats: Caveats; + caveats?: Caveats; }) => { const scopeCaveatBuilder = createCaveatBuilderFromScope(environment, scope); - if ('build' in caveats && typeof caveats.build === 'function') { - (caveats as CaveatBuilder).build().forEach((caveat) => { - scopeCaveatBuilder.addCaveat(caveat); - }); - } else if (Array.isArray(caveats)) { - caveats.forEach((caveat) => { - try { - if ('type' in caveat) { - const { type, ...config } = caveat; - scopeCaveatBuilder.addCaveat(type, config); - } else { - scopeCaveatBuilder.addCaveat(caveat); + if (caveats) { + if ('build' in caveats && typeof caveats.build === 'function') { + (caveats as CaveatBuilder).build().forEach((caveat) => { + scopeCaveatBuilder.addCaveat(caveat); + }); + } else if (Array.isArray(caveats)) { + caveats.forEach((caveat) => { + try { + if ('type' in caveat) { + const { type, ...config } = caveat; + scopeCaveatBuilder.addCaveat(type, config); + } else { + scopeCaveatBuilder.addCaveat(caveat); + } + } catch (error) { + throw new Error(`Invalid caveat: ${(error as Error).message}`); } - } catch (error) { - throw new Error(`Invalid caveat: ${(error as Error).message}`); - } - }); + }); + } } return scopeCaveatBuilder.build(); diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts index 8c557b39..c0d0b315 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts @@ -1,6 +1,8 @@ import type { DeleGatorEnvironment } from '../../types'; +import type { AllowedCalldataBuilderConfig } from '../allowedCalldataBuilder'; import { createCaveatBuilder } from '../coreCaveatBuilder'; import type { CoreCaveatBuilder } from '../coreCaveatBuilder'; +import type { ExactCalldataBuilderConfig } from '../exactCalldataBuilder'; import type { nativeTokenPeriodTransfer, NativeTokenPeriodTransferBuilderConfig, @@ -8,6 +10,8 @@ import type { export type NativeTokenPeriodicScopeConfig = { type: typeof nativeTokenPeriodTransfer; + allowedCalldata?: AllowedCalldataBuilderConfig[]; + exactCalldata?: ExactCalldataBuilderConfig; } & NativeTokenPeriodTransferBuilderConfig; /** @@ -23,13 +27,36 @@ export function createNativeTokenPeriodicCaveatBuilder( environment: DeleGatorEnvironment, config: NativeTokenPeriodicScopeConfig, ): CoreCaveatBuilder { - return createCaveatBuilder(environment) - .addCaveat('exactCalldata', { + const { + periodAmount, + periodDuration, + startDate, + allowedCalldata, + exactCalldata, + } = config; + + const caveatBuilder = createCaveatBuilder(environment); + + // Add calldata restrictions + if (allowedCalldata) { + allowedCalldata.forEach((calldataConfig) => { + caveatBuilder.addCaveat('allowedCalldata', calldataConfig); + }); + } else if (exactCalldata) { + caveatBuilder.addCaveat('exactCalldata', exactCalldata); + } else { + // Default behavior: only allow empty calldata + caveatBuilder.addCaveat('exactCalldata', { calldata: '0x', - }) - .addCaveat('nativeTokenPeriodTransfer', { - periodAmount: config.periodAmount, - periodDuration: config.periodDuration, - startDate: config.startDate, }); + } + + // Add native token period transfer restriction + caveatBuilder.addCaveat('nativeTokenPeriodTransfer', { + periodAmount, + periodDuration, + startDate, + }); + + return caveatBuilder; } diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts index 0fe16527..550a9496 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts @@ -1,6 +1,8 @@ import type { DeleGatorEnvironment } from '../../types'; +import type { AllowedCalldataBuilderConfig } from '../allowedCalldataBuilder'; import { createCaveatBuilder } from '../coreCaveatBuilder'; import type { CoreCaveatBuilder } from '../coreCaveatBuilder'; +import type { ExactCalldataBuilderConfig } from '../exactCalldataBuilder'; import type { nativeTokenStreaming, NativeTokenStreamingBuilderConfig, @@ -8,6 +10,8 @@ import type { export type NativeTokenStreamingScopeConfig = { type: typeof nativeTokenStreaming; + allowedCalldata?: AllowedCalldataBuilderConfig[]; + exactCalldata?: ExactCalldataBuilderConfig; } & NativeTokenStreamingBuilderConfig; /** @@ -23,14 +27,38 @@ export function createNativeTokenStreamingCaveatBuilder( environment: DeleGatorEnvironment, config: NativeTokenStreamingScopeConfig, ): CoreCaveatBuilder { - return createCaveatBuilder(environment) - .addCaveat('exactCalldata', { + const { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + allowedCalldata, + exactCalldata, + } = config; + + const caveatBuilder = createCaveatBuilder(environment); + + // Add calldata restrictions + if (allowedCalldata) { + allowedCalldata.forEach((calldataConfig) => { + caveatBuilder.addCaveat('allowedCalldata', calldataConfig); + }); + } else if (exactCalldata) { + caveatBuilder.addCaveat('exactCalldata', exactCalldata); + } else { + // Default behavior: only allow empty calldata + caveatBuilder.addCaveat('exactCalldata', { calldata: '0x', - }) - .addCaveat('nativeTokenStreaming', { - initialAmount: config.initialAmount, - maxAmount: config.maxAmount, - amountPerSecond: config.amountPerSecond, - startTime: config.startTime, }); + } + + // Add native token streaming restriction + caveatBuilder.addCaveat('nativeTokenStreaming', { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }); + + return caveatBuilder; } diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts index a242f5e3..a31e63a1 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts @@ -1,6 +1,8 @@ import type { DeleGatorEnvironment } from '../../types'; +import type { AllowedCalldataBuilderConfig } from '../allowedCalldataBuilder'; import { createCaveatBuilder } from '../coreCaveatBuilder'; import type { CoreCaveatBuilder } from '../coreCaveatBuilder'; +import type { ExactCalldataBuilderConfig } from '../exactCalldataBuilder'; import type { nativeTokenTransferAmount, NativeTokenTransferAmountBuilderConfig, @@ -8,6 +10,8 @@ import type { export type NativeTokenTransferScopeConfig = { type: typeof nativeTokenTransferAmount; + allowedCalldata?: AllowedCalldataBuilderConfig[]; + exactCalldata?: ExactCalldataBuilderConfig; } & NativeTokenTransferAmountBuilderConfig; /** @@ -23,11 +27,28 @@ export function createNativeTokenTransferCaveatBuilder( environment: DeleGatorEnvironment, config: NativeTokenTransferScopeConfig, ): CoreCaveatBuilder { - return createCaveatBuilder(environment) - .addCaveat('exactCalldata', { + const { maxAmount, allowedCalldata, exactCalldata } = config; + + const caveatBuilder = createCaveatBuilder(environment); + + // Add calldata restrictions + if (allowedCalldata) { + allowedCalldata.forEach((calldataConfig) => { + caveatBuilder.addCaveat('allowedCalldata', calldataConfig); + }); + } else if (exactCalldata) { + caveatBuilder.addCaveat('exactCalldata', exactCalldata); + } else { + // Default behavior: only allow empty calldata + caveatBuilder.addCaveat('exactCalldata', { calldata: '0x', - }) - .addCaveat('nativeTokenTransferAmount', { - maxAmount: config.maxAmount, }); + } + + // Add native token transfer amount restriction + caveatBuilder.addCaveat('nativeTokenTransferAmount', { + maxAmount, + }); + + return caveatBuilder; } diff --git a/packages/delegation-toolkit/src/delegation.ts b/packages/delegation-toolkit/src/delegation.ts index 033ec574..77f92320 100644 --- a/packages/delegation-toolkit/src/delegation.ts +++ b/packages/delegation-toolkit/src/delegation.ts @@ -193,10 +193,9 @@ type BaseCreateDelegationOptions = { environment: DeleGatorEnvironment; scope: ScopeConfig; from: Hex; - caveats: Caveats; + caveats?: Caveats; parentDelegation?: Delegation | Hex; salt?: Hex; - allowInsecureUnrestrictedDelegation?: boolean; }; /** diff --git a/packages/delegation-toolkit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts b/packages/delegation-toolkit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts index eef80a8b..49eda3de 100644 --- a/packages/delegation-toolkit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts +++ b/packages/delegation-toolkit/test/DelegationFramework/DelegationManager/delegationManagement.test.ts @@ -91,7 +91,6 @@ describe('DelegationManager - Delegation Management', () => { targets: [alice.address], selectors: ['0x00000000'], }, - caveats: [], }); const encodedData = DelegationManager.encode.disableDelegation({ @@ -117,7 +116,6 @@ describe('DelegationManager - Delegation Management', () => { targets: [alice.address], selectors: ['0x00000000'], }, - caveats: [], }); const encodedData = DelegationManager.encode.enableDelegation({ @@ -143,7 +141,6 @@ describe('DelegationManager - Delegation Management', () => { targets: [alice.address], selectors: ['0x00000000'], }, - caveats: [], }); const execution = createExecution({ diff --git a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts index fea49143..c7929306 100644 --- a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts +++ b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts @@ -10,11 +10,12 @@ describe('createNativeTokenTransferCaveatBuilder', () => { const environment = { caveatEnforcers: { ExactCalldataEnforcer: randomAddress(), + AllowedCalldataEnforcer: randomAddress(), NativeTokenTransferAmountEnforcer: randomAddress(), }, } as unknown as DeleGatorEnvironment; - it('creates a native token transfer CaveatBuilder', () => { + it('creates a native token transfer CaveatBuilder with default empty calldata', () => { const config: NativeTokenTransferScopeConfig = { type: 'nativeTokenTransferAmount', maxAmount: 1000n, @@ -40,4 +41,69 @@ describe('createNativeTokenTransferCaveatBuilder', () => { }, ]); }); + + it('creates a native token transfer CaveatBuilder with exact calldata', () => { + const config: NativeTokenTransferScopeConfig = { + type: 'nativeTokenTransferAmount', + maxAmount: 1000n, + exactCalldata: { + calldata: '0x1234abcd', + }, + }; + + const caveatBuilder = createNativeTokenTransferCaveatBuilder( + environment, + config, + ); + + const caveats = caveatBuilder.build(); + + expect(caveats).to.deep.equal([ + { + enforcer: environment.caveatEnforcers.ExactCalldataEnforcer, + args: '0x', + terms: '0x1234abcd', + }, + { + enforcer: environment.caveatEnforcers.NativeTokenTransferAmountEnforcer, + args: '0x', + terms: toHex(config.maxAmount, { size: 32 }), + }, + ]); + }); + + it('creates a native token transfer CaveatBuilder with allowed calldata', () => { + const config: NativeTokenTransferScopeConfig = { + type: 'nativeTokenTransferAmount', + maxAmount: 1000n, + allowedCalldata: [ + { + startIndex: 4, + value: '0x1234', + }, + { + startIndex: 8, + value: '0xabcd', + }, + ], + }; + + const caveatBuilder = createNativeTokenTransferCaveatBuilder( + environment, + config, + ); + + const caveats = caveatBuilder.build(); + + expect(caveats).to.have.lengthOf(3); + expect(caveats[0]?.enforcer).to.equal( + environment.caveatEnforcers.AllowedCalldataEnforcer, + ); + expect(caveats[1]?.enforcer).to.equal( + environment.caveatEnforcers.AllowedCalldataEnforcer, + ); + expect(caveats[2]?.enforcer).to.equal( + environment.caveatEnforcers.NativeTokenTransferAmountEnforcer, + ); + }); }); diff --git a/packages/delegation-toolkit/test/delegation.test.ts b/packages/delegation-toolkit/test/delegation.test.ts index 667bdc49..37fea610 100644 --- a/packages/delegation-toolkit/test/delegation.test.ts +++ b/packages/delegation-toolkit/test/delegation.test.ts @@ -242,7 +242,6 @@ describe('createDelegation', () => { to: mockDelegate, from: mockDelegator, scope: erc20Scope, - caveats: [], }); expect(result).to.deep.equal({ @@ -274,6 +273,24 @@ describe('createDelegation', () => { signature: '0x', }); }); + + it('should create a delegation with scope-only caveats when caveats parameter is omitted', () => { + const result = createDelegation({ + environment: delegatorEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + }); + + expect(result).to.deep.equal({ + delegate: mockDelegate, + delegator: mockDelegator, + authority: ROOT_AUTHORITY, + caveats: [...erc20ScopeCaveats], + salt: '0x', + signature: '0x', + }); + }); }); describe('createOpenDelegation', () => { @@ -367,6 +384,23 @@ describe('createOpenDelegation', () => { signature: '0x', }); }); + + it('should create an open delegation with scope-only caveats when caveats parameter is omitted', () => { + const result = createOpenDelegation({ + environment: delegatorEnvironment, + scope: erc20Scope, + from: mockDelegator, + }); + + expect(result).to.deep.equal({ + delegate: '0x0000000000000000000000000000000000000a11', + delegator: mockDelegator, + authority: ROOT_AUTHORITY, + caveats: [...erc20ScopeCaveats], + salt: '0x', + signature: '0x', + }); + }); }); describe('encodeDelegations', () => { diff --git a/packages/delegator-e2e/contracts/src/PayableReceiver.sol b/packages/delegator-e2e/contracts/src/PayableReceiver.sol new file mode 100644 index 00000000..469b78e2 --- /dev/null +++ b/packages/delegator-e2e/contracts/src/PayableReceiver.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title PayableReceiver + * @dev A simple contract for testing calldata restrictions with payable functions. + * Used in e2e tests to validate that delegated calls with specific calldata work correctly. + */ +contract PayableReceiver { + uint256 public totalReceived; + uint256 public callCount; + + event EthReceived(address indexed sender, uint256 amount, uint256 totalReceived); + + /** + * @dev Payable function that accepts ETH with no parameters + * Perfect for testing exactCalldata and allowedCalldata restrictions + */ + function receiveEth() external payable { + totalReceived += msg.value; + callCount += 1; + + emit EthReceived(msg.sender, msg.value, totalReceived); + } + + /** + * @dev Another payable function with different calldata for testing allowedCalldata patterns + */ + function receiveEthAlternative() external payable { + totalReceived += msg.value; + callCount += 1; + + emit EthReceived(msg.sender, msg.value, totalReceived); + } + + /** + * @dev Get the contract's current ETH balance + */ + function getBalance() external view returns (uint256) { + return address(this).balance; + } + + /** + * @dev Reset counters for testing (only for testing purposes) + */ + function reset() external { + totalReceived = 0; + callCount = 0; + } +} diff --git a/packages/delegator-e2e/external-integration-tests/infuraBundlerClient.test.ts b/packages/delegator-e2e/external-integration-tests/infuraBundlerClient.test.ts index 01ce15b5..1d695679 100644 --- a/packages/delegator-e2e/external-integration-tests/infuraBundlerClient.test.ts +++ b/packages/delegator-e2e/external-integration-tests/infuraBundlerClient.test.ts @@ -316,7 +316,6 @@ test('complete delegation workflow with dynamic gas pricing', async () => { type: 'nativeTokenTransferAmount', maxAmount: 0n, }, - caveats: [], }); // Step 4: Sign delegation diff --git a/packages/delegator-e2e/test/actions/infuraBundlerClient.test.ts b/packages/delegator-e2e/test/actions/infuraBundlerClient.test.ts index 7d04b2bd..68026f91 100644 --- a/packages/delegator-e2e/test/actions/infuraBundlerClient.test.ts +++ b/packages/delegator-e2e/test/actions/infuraBundlerClient.test.ts @@ -97,7 +97,6 @@ test('infuraBundlerClient should work for delegation redemption', async () => { type: 'nativeTokenTransferAmount', maxAmount: 0n, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/allowedMethods.test.ts b/packages/delegator-e2e/test/caveats/allowedMethods.test.ts index 416281c4..718e5bdd 100644 --- a/packages/delegator-e2e/test/caveats/allowedMethods.test.ts +++ b/packages/delegator-e2e/test/caveats/allowedMethods.test.ts @@ -297,7 +297,6 @@ const runScopeTest_expectSuccess = async ( targets, selectors, }, - caveats: [], }); const signedDelegation = { @@ -366,7 +365,6 @@ const runScopeTest_expectFailure = async ( targets, selectors, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/caveatUtils.test.ts b/packages/delegator-e2e/test/caveats/caveatUtils.test.ts index 360d4e82..c2074ecf 100644 --- a/packages/delegator-e2e/test/caveats/caveatUtils.test.ts +++ b/packages/delegator-e2e/test/caveats/caveatUtils.test.ts @@ -124,7 +124,6 @@ describe('ERC20PeriodTransferEnforcer', () => { periodDuration, startDate: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -204,7 +203,6 @@ describe('ERC20PeriodTransferEnforcer', () => { periodDuration, startDate: futureStartDate, }, - caveats: [], }); const signedDelegation = { @@ -281,7 +279,6 @@ describe('ERC20PeriodTransferEnforcer', () => { periodDuration, startDate, }, - caveats: [], }); const signedDelegation = { @@ -366,7 +363,6 @@ describe('ERC20StreamingEnforcer', () => { amountPerSecond, startTime: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -462,7 +458,6 @@ describe('ERC20StreamingEnforcer', () => { amountPerSecond, startTime: futureStartTime, }, - caveats: [], }); const signedDelegation = { @@ -540,7 +535,6 @@ describe('ERC20StreamingEnforcer', () => { amountPerSecond, startTime: pastStartTime, }, - caveats: [], }); const signedDelegation = { @@ -907,7 +901,6 @@ describe('NativeTokenPeriodTransferEnforcer', () => { periodDuration, startDate: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -994,7 +987,6 @@ describe('NativeTokenPeriodTransferEnforcer', () => { periodDuration, startDate: futureStartDate, }, - caveats: [], }); const signedDelegation = { @@ -1066,7 +1058,6 @@ describe('NativeTokenPeriodTransferEnforcer', () => { periodDuration, startDate, }, - caveats: [], }); const signedDelegation = { @@ -1146,7 +1137,6 @@ describe('NativeTokenStreamingEnforcer', () => { amountPerSecond, startTime: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -1236,7 +1226,6 @@ describe('NativeTokenStreamingEnforcer', () => { amountPerSecond, startTime: futureStartTime, }, - caveats: [], }); const signedDelegation = { @@ -1308,7 +1297,6 @@ describe('NativeTokenStreamingEnforcer', () => { amountPerSecond, startTime: pastStartTime, }, - caveats: [], }); const signedDelegation = { @@ -1397,7 +1385,6 @@ describe('Generic caveat utils functionality', () => { periodDuration, startDate: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -1436,7 +1423,6 @@ describe('Individual action functions vs client extension methods', () => { periodDuration, startDate: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -1480,7 +1466,6 @@ describe('Individual action functions vs client extension methods', () => { amountPerSecond, startTime: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -1568,7 +1553,6 @@ describe('Individual action functions vs client extension methods', () => { periodDuration, startDate: currentTime, }, - caveats: [], }); const signedDelegation = { @@ -1611,7 +1595,6 @@ describe('Individual action functions vs client extension methods', () => { amountPerSecond, startTime: currentTime, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/erc20PeriodTransfer.test.ts b/packages/delegator-e2e/test/caveats/erc20PeriodTransfer.test.ts index d7e37067..347258a6 100644 --- a/packages/delegator-e2e/test/caveats/erc20PeriodTransfer.test.ts +++ b/packages/delegator-e2e/test/caveats/erc20PeriodTransfer.test.ts @@ -968,7 +968,6 @@ const runScopeTest_expectSuccess = async ( periodDuration, startDate, }, - caveats: [], }); const signedDelegation = { @@ -1064,7 +1063,6 @@ const runScopeTest_expectFailure = async ( periodDuration, startDate, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/erc20Streaming.test.ts b/packages/delegator-e2e/test/caveats/erc20Streaming.test.ts index e0ad5ef0..1402e6e8 100644 --- a/packages/delegator-e2e/test/caveats/erc20Streaming.test.ts +++ b/packages/delegator-e2e/test/caveats/erc20Streaming.test.ts @@ -854,7 +854,6 @@ const runScopeTest_expectSuccess = async ( amountPerSecond, startTime, }, - caveats: [], }); const signedDelegation = { @@ -952,7 +951,6 @@ const runScopeTest_expectFailure = async ( amountPerSecond, startTime, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/erc20TransferAmount.test.ts b/packages/delegator-e2e/test/caveats/erc20TransferAmount.test.ts index 6b14a3cd..fca78195 100644 --- a/packages/delegator-e2e/test/caveats/erc20TransferAmount.test.ts +++ b/packages/delegator-e2e/test/caveats/erc20TransferAmount.test.ts @@ -328,7 +328,6 @@ const runScopeTest_expectSuccess = async ( tokenAddress: erc20TokenAddress, maxAmount, }, - caveats: [], }); const signedDelegation = { @@ -420,7 +419,6 @@ const runScopeTest_expectFailure = async ( tokenAddress: erc20TokenAddress, maxAmount, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/erc721Transfer.test.ts b/packages/delegator-e2e/test/caveats/erc721Transfer.test.ts index 39924d79..0d995020 100644 --- a/packages/delegator-e2e/test/caveats/erc721Transfer.test.ts +++ b/packages/delegator-e2e/test/caveats/erc721Transfer.test.ts @@ -343,7 +343,6 @@ const runScopeTest_expectSuccess = async ( tokenAddress: erc721TokenAddress, tokenId, }, - caveats: [], }); const signedDelegation = { @@ -442,7 +441,6 @@ const runScopeTest_expectFailure = async ( tokenAddress: erc721TokenAddress, tokenId: allowedTokenId, }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/caveats/nativeTokenPeriodTransfer.test.ts b/packages/delegator-e2e/test/caveats/nativeTokenPeriodTransfer.test.ts index 4322fd92..d451fed7 100644 --- a/packages/delegator-e2e/test/caveats/nativeTokenPeriodTransfer.test.ts +++ b/packages/delegator-e2e/test/caveats/nativeTokenPeriodTransfer.test.ts @@ -25,6 +25,11 @@ import { publicClient, randomAddress, stringToUnprefixedHex, + deployPayableReceiver, + getPayableReceiverBalance, + getPayableReceiverTotalReceived, + encodeReceiveEthCalldata, + encodeReceiveEthAlternativeCalldata, } from '../utils/helpers'; import { encodeFunctionData, type Hex, parseEther, concat } from 'viem'; import { expectUserOperationToSucceed } from '../utils/assertions'; @@ -33,6 +38,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; let aliceSmartAccount: MetaMaskSmartAccount; let bobSmartAccount: MetaMaskSmartAccount; let currentTime: number; +let payableReceiverAddress: Hex; /** * These tests verify the native token period transfer caveat functionality. * @@ -69,6 +75,8 @@ beforeEach(async () => { const { timestamp } = await publicClient.getBlock({ blockTag: 'latest' }); currentTime = Number(timestamp); + + payableReceiverAddress = await deployPayableReceiver(); }); const runTest_expectSuccess = async ( @@ -665,7 +673,6 @@ const runScopeTest_expectSuccess = async ( periodDuration, startDate, }, - caveats: [], }); const signedDelegation = { @@ -738,7 +745,6 @@ const runScopeTest_expectFailure = async ( periodDuration, startDate, }, - caveats: [], }); const signedDelegation = { @@ -776,3 +782,316 @@ const runScopeTest_expectFailure = async ( }), ).rejects.toThrow(stringToUnprefixedHex(expectedError)); }; + +test('Caveat with exactCalldata: Bob successfully redeems with exact calldata match', async () => { + const periodAmount = parseEther('1'); + const periodDuration = 86400; // 1 day in seconds + const startDate = currentTime; + const transferAmount = parseEther('0.5'); + const exactCalldata = encodeReceiveEthCalldata(); + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenPeriodTransfer', { + periodAmount, + periodDuration, + startDate, + }) + .addCaveat('exactCalldata', { calldata: exactCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: exactCalldata, // Exact match + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + const contractBalanceBefore = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedBefore = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + const userOpHash = await sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }); + + const receipt = await sponsoredBundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }); + + expectUserOperationToSucceed(receipt); + + const contractBalanceAfter = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedAfter = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + expect( + contractBalanceAfter - contractBalanceBefore, + 'Expected contract balance to increase by transfer amount', + ).toEqual(transferAmount); + + expect( + totalReceivedAfter - totalReceivedBefore, + 'Expected totalReceived to increase by transfer amount', + ).toEqual(transferAmount); +}); + +test('Caveat with exactCalldata: Bob fails to redeem with wrong calldata', async () => { + const periodAmount = parseEther('1'); + const periodDuration = 86400; // 1 day in seconds + const startDate = currentTime; + const transferAmount = parseEther('0.5'); + const exactCalldata = encodeReceiveEthCalldata(); + const wrongCalldata = encodeReceiveEthAlternativeCalldata(); // Different function + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenPeriodTransfer', { + periodAmount, + periodDuration, + startDate, + }) + .addCaveat('exactCalldata', { calldata: exactCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: wrongCalldata, // Wrong calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + await expect( + sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }), + ).rejects.toThrow( + stringToUnprefixedHex('ExactCalldataEnforcer:invalid-calldata'), + ); +}); + +test('Caveat with allowedCalldata: Bob successfully redeems with allowed calldata pattern', async () => { + const periodAmount = parseEther('1'); + const periodDuration = 86400; // 1 day in seconds + const startDate = currentTime; + const transferAmount = parseEther('0.5'); + const allowedCalldata = { startIndex: 0, value: encodeReceiveEthCalldata() }; // Allow specific calldata + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenPeriodTransfer', { + periodAmount, + periodDuration, + startDate, + }) + .addCaveat('allowedCalldata', allowedCalldata) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: encodeReceiveEthCalldata(), // Matches allowed calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + const contractBalanceBefore = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedBefore = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + const userOpHash = await sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }); + + const receipt = await sponsoredBundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }); + + expectUserOperationToSucceed(receipt); + + const contractBalanceAfter = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedAfter = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + expect( + contractBalanceAfter - contractBalanceBefore, + 'Expected contract balance to increase by transfer amount', + ).toEqual(transferAmount); + + expect( + totalReceivedAfter - totalReceivedBefore, + 'Expected totalReceived to increase by transfer amount', + ).toEqual(transferAmount); +}); + +test('Caveat with allowedCalldata: Bob fails to redeem with disallowed calldata pattern', async () => { + const periodAmount = parseEther('1'); + const periodDuration = 86400; // 1 day in seconds + const startDate = currentTime; + const transferAmount = parseEther('0.5'); + const allowedCalldata = { startIndex: 0, value: encodeReceiveEthCalldata() }; // Only allow specific calldata + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenPeriodTransfer', { + periodAmount, + periodDuration, + startDate, + }) + .addCaveat('allowedCalldata', allowedCalldata) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: encodeReceiveEthAlternativeCalldata(), // Different from allowed calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + await expect( + sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }), + ).rejects.toThrow( + stringToUnprefixedHex('AllowedCalldataEnforcer:invalid-calldata'), + ); +}); diff --git a/packages/delegator-e2e/test/caveats/nativeTokenStreaming.test.ts b/packages/delegator-e2e/test/caveats/nativeTokenStreaming.test.ts index 8c2eb638..69a8ffb3 100644 --- a/packages/delegator-e2e/test/caveats/nativeTokenStreaming.test.ts +++ b/packages/delegator-e2e/test/caveats/nativeTokenStreaming.test.ts @@ -25,6 +25,11 @@ import { fundAddress, randomAddress, stringToUnprefixedHex, + deployPayableReceiver, + getPayableReceiverBalance, + getPayableReceiverTotalReceived, + encodeReceiveEthCalldata, + encodeReceiveEthAlternativeCalldata, } from '../utils/helpers'; import { encodeFunctionData, Hex, parseEther } from 'viem'; import { expectUserOperationToSucceed } from '../utils/assertions'; @@ -34,6 +39,7 @@ import { concat } from 'viem'; let aliceSmartAccount: MetaMaskSmartAccount; let bobSmartAccount: MetaMaskSmartAccount; let currentTime: number; +let payableReceiverAddress: Hex; /** * These tests verify the native token streaming caveat functionality. @@ -77,6 +83,8 @@ beforeEach(async () => { const { timestamp } = await publicClient.getBlock({ blockTag: 'latest' }); currentTime = Number(timestamp); + + payableReceiverAddress = await deployPayableReceiver(); }); test('maincase: Bob redeems the delegation with initial amount available', async () => { @@ -739,7 +747,6 @@ const runScopeTest_expectSuccess = async ( amountPerSecond, startTime, }, - caveats: [], }); const signedDelegation = { @@ -818,7 +825,6 @@ const runScopeTest_expectFailure = async ( amountPerSecond, startTime, }, - caveats: [], }); const signedDelegation = { @@ -868,3 +874,324 @@ const runScopeTest_expectFailure = async ( 'Expected recipient balance to remain unchanged', ).toEqual(recipientBalanceBefore); }; + +test('Caveat with exactCalldata: Bob successfully redeems with exact calldata match', async () => { + const initialAmount = parseEther('0.5'); + const maxAmount = parseEther('2'); + const amountPerSecond = parseEther('0.1'); + const startTime = currentTime; + const transferAmount = parseEther('0.5'); + const exactCalldata = encodeReceiveEthCalldata(); + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenStreaming', { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }) + .addCaveat('exactCalldata', { calldata: exactCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: exactCalldata, // Exact match + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + const contractBalanceBefore = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedBefore = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + const userOpHash = await sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }); + + const receipt = await sponsoredBundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }); + + expectUserOperationToSucceed(receipt); + + const contractBalanceAfter = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedAfter = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + expect( + contractBalanceAfter - contractBalanceBefore, + 'Expected contract balance to increase by transfer amount', + ).toEqual(transferAmount); + + expect( + totalReceivedAfter - totalReceivedBefore, + 'Expected totalReceived to increase by transfer amount', + ).toEqual(transferAmount); +}); + +test('Caveat with exactCalldata: Bob fails to redeem with wrong calldata', async () => { + const initialAmount = parseEther('0.5'); + const maxAmount = parseEther('2'); + const amountPerSecond = parseEther('0.1'); + const startTime = currentTime; + const transferAmount = parseEther('0.5'); + const exactCalldata = encodeReceiveEthCalldata(); + const wrongCalldata = encodeReceiveEthAlternativeCalldata(); // Different function + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenStreaming', { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }) + .addCaveat('exactCalldata', { calldata: exactCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: wrongCalldata, // Wrong calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + await expect( + sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }), + ).rejects.toThrow( + stringToUnprefixedHex('ExactCalldataEnforcer:invalid-calldata'), + ); +}); + +test('Caveat with allowedCalldata: Bob successfully redeems with allowed calldata pattern', async () => { + const initialAmount = parseEther('0.5'); + const maxAmount = parseEther('2'); + const amountPerSecond = parseEther('0.1'); + const startTime = currentTime; + const transferAmount = parseEther('0.5'); + const allowedCalldata = { startIndex: 0, value: encodeReceiveEthCalldata() }; + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenStreaming', { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }) + .addCaveat('allowedCalldata', allowedCalldata) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: encodeReceiveEthCalldata(), // Matches allowed calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + const contractBalanceBefore = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedBefore = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + const userOpHash = await sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }); + + const receipt = await sponsoredBundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }); + + expectUserOperationToSucceed(receipt); + + const contractBalanceAfter = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedAfter = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + expect( + contractBalanceAfter - contractBalanceBefore, + 'Expected contract balance to increase by transfer amount', + ).toEqual(transferAmount); + + expect( + totalReceivedAfter - totalReceivedBefore, + 'Expected totalReceived to increase by transfer amount', + ).toEqual(transferAmount); +}); + +test('Caveat with allowedCalldata: Bob fails to redeem with disallowed calldata pattern', async () => { + const initialAmount = parseEther('0.5'); + const maxAmount = parseEther('2'); + const amountPerSecond = parseEther('0.1'); + const startTime = currentTime; + const transferAmount = parseEther('0.5'); + const allowedCalldata = { startIndex: 0, value: encodeReceiveEthCalldata() }; // Only allow specific calldata + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenStreaming', { + initialAmount, + maxAmount, + amountPerSecond, + startTime, + }) + .addCaveat('allowedCalldata', allowedCalldata) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, + value: transferAmount, + callData: encodeReceiveEthAlternativeCalldata(), // Different from allowed calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + await expect( + sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }), + ).rejects.toThrow( + stringToUnprefixedHex('AllowedCalldataEnforcer:invalid-calldata'), + ); +}); diff --git a/packages/delegator-e2e/test/caveats/nativeTokenTransferAmount.test.ts b/packages/delegator-e2e/test/caveats/nativeTokenTransferAmount.test.ts index c4878704..d9da8d23 100644 --- a/packages/delegator-e2e/test/caveats/nativeTokenTransferAmount.test.ts +++ b/packages/delegator-e2e/test/caveats/nativeTokenTransferAmount.test.ts @@ -23,13 +23,19 @@ import { publicClient, fundAddress, stringToUnprefixedHex, + deployPayableReceiver, + getPayableReceiverBalance, + getPayableReceiverTotalReceived, + encodeReceiveEthCalldata, + encodeReceiveEthAlternativeCalldata, } from '../utils/helpers'; -import { encodeFunctionData, parseEther } from 'viem'; +import { encodeFunctionData, parseEther, type Hex } from 'viem'; import { expectUserOperationToSucceed } from '../utils/assertions'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; let aliceSmartAccount: MetaMaskSmartAccount; let bobSmartAccount: MetaMaskSmartAccount; +let payableReceiverAddress: Hex; beforeEach(async () => { const alice = privateKeyToAccount(generatePrivateKey()); @@ -53,6 +59,9 @@ beforeEach(async () => { deploySalt: '0x1', signatory: { account: bob }, }); + + // Deploy PayableReceiver contract for calldata testing + payableReceiverAddress = await deployPayableReceiver(); }); test('maincase: Bob redeems the delegation with an allowed amount', async () => { @@ -250,7 +259,6 @@ const runScopeTest_expectSuccess = async ( type: 'nativeTokenTransferAmount', maxAmount: allowance, }, - caveats: [], }); const signedDelegation = { @@ -322,7 +330,6 @@ const runScopeTest_expectFailure = async ( type: 'nativeTokenTransferAmount', maxAmount: allowance, }, - caveats: [], }); const signedDelegation = { @@ -371,3 +378,293 @@ const runScopeTest_expectFailure = async ( balanceBefore, ); }; + +test('Caveat with exactCalldata: Bob successfully redeems with exact calldata match', async () => { + const allowance = parseEther('1'); + const transferAmount = parseEther('0.5'); + const exactCalldata = encodeReceiveEthCalldata(); // Use real contract function calldata + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenTransferAmount', { maxAmount: allowance }) + .addCaveat('exactCalldata', { calldata: exactCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, // Call the PayableReceiver contract + value: transferAmount, + callData: exactCalldata, // Exact match + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + const contractBalanceBefore = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedBefore = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + const userOpHash = await sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }); + + const receipt = await sponsoredBundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }); + + expectUserOperationToSucceed(receipt); + + const contractBalanceAfter = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedAfter = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + expect( + contractBalanceAfter - contractBalanceBefore, + 'Expected contract balance to increase by transfer amount', + ).toEqual(transferAmount); + + expect( + totalReceivedAfter - totalReceivedBefore, + 'Expected totalReceived to increase by transfer amount', + ).toEqual(transferAmount); +}); + +test('Caveat with exactCalldata: Bob fails to redeem with wrong calldata', async () => { + const allowance = parseEther('1'); + const transferAmount = parseEther('0.5'); + const exactCalldata = encodeReceiveEthCalldata(); // Expect receiveEth() calldata + const wrongCalldata = encodeReceiveEthAlternativeCalldata(); // Use receiveEthAlternative() calldata + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenTransferAmount', { maxAmount: allowance }) + .addCaveat('exactCalldata', { calldata: exactCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, // Call the PayableReceiver contract + value: transferAmount, + callData: wrongCalldata, // Wrong calldata - different function + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + await expect( + sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }), + ).rejects.toThrow( + stringToUnprefixedHex('ExactCalldataEnforcer:invalid-calldata'), + ); +}); + +test('Caveat with allowedCalldata: Bob successfully redeems with allowed calldata pattern', async () => { + const allowance = parseEther('1'); + const transferAmount = parseEther('0.5'); + const allowedCalldata = encodeReceiveEthCalldata(); + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenTransferAmount', { maxAmount: allowance }) + .addCaveat('allowedCalldata', { startIndex: 0, value: allowedCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, // Call the PayableReceiver contract + value: transferAmount, + callData: allowedCalldata, // Matches allowed calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + const contractBalanceBefore = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedBefore = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + const userOpHash = await sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }); + + const receipt = await sponsoredBundlerClient.waitForUserOperationReceipt({ + hash: userOpHash, + }); + + expectUserOperationToSucceed(receipt); + + const contractBalanceAfter = await getPayableReceiverBalance( + payableReceiverAddress, + ); + const totalReceivedAfter = await getPayableReceiverTotalReceived( + payableReceiverAddress, + ); + + expect( + contractBalanceAfter - contractBalanceBefore, + 'Expected contract balance to increase by transfer amount', + ).toEqual(transferAmount); + + expect( + totalReceivedAfter - totalReceivedBefore, + 'Expected totalReceived to increase by transfer amount', + ).toEqual(transferAmount); +}); + +test('Caveat with allowedCalldata: Bob fails to redeem with disallowed calldata pattern', async () => { + const allowance = parseEther('1'); + const transferAmount = parseEther('0.5'); + const allowedCalldata = encodeReceiveEthCalldata(); // Only allow receiveEth() calldata + const disallowedCalldata = encodeReceiveEthAlternativeCalldata(); // Try receiveEthAlternative() calldata + + const bobAddress = bobSmartAccount.address; + const aliceAddress = aliceSmartAccount.address; + + const delegation: Delegation = { + delegate: bobAddress, + delegator: aliceAddress, + authority: ROOT_AUTHORITY, + salt: '0x0', + caveats: createCaveatBuilder(aliceSmartAccount.environment) + .addCaveat('nativeTokenTransferAmount', { maxAmount: allowance }) + .addCaveat('allowedCalldata', { startIndex: 0, value: allowedCalldata }) + .build(), + signature: '0x', + }; + + const signedDelegation = { + ...delegation, + signature: await aliceSmartAccount.signDelegation({ + delegation, + }), + }; + + const execution = createExecution({ + target: payableReceiverAddress, // Call the PayableReceiver contract + value: transferAmount, + callData: disallowedCalldata, // Different function from allowed calldata + }); + + const redeemData = encodeFunctionData({ + abi: bobSmartAccount.abi, + functionName: 'redeemDelegations', + args: [ + encodePermissionContexts([[signedDelegation]]), + [ExecutionMode.SingleDefault], + encodeExecutionCalldatas([[execution]]), + ], + }); + + await expect( + sponsoredBundlerClient.sendUserOperation({ + account: bobSmartAccount, + calls: [ + { + to: bobSmartAccount.address, + data: redeemData, + }, + ], + ...gasPrice, + }), + ).rejects.toThrow( + stringToUnprefixedHex('AllowedCalldataEnforcer:invalid-calldata'), + ); +}); diff --git a/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts b/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts index d2dd25d3..d6ef0de9 100644 --- a/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts +++ b/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts @@ -102,7 +102,6 @@ describe('Ownership Transfer Caveat', () => { type: 'ownershipTransfer', contractAddress, }, - caveats: [], }); // Sign the delegation @@ -166,7 +165,6 @@ describe('Ownership Transfer Caveat', () => { type: 'ownershipTransfer', contractAddress, // Only allows this specific contract }, - caveats: [], }); // Sign the delegation @@ -250,7 +248,6 @@ describe('Ownership Transfer Caveat', () => { type: 'ownershipTransfer', contractAddress, }, - caveats: [], }); const signedDelegation = { @@ -308,7 +305,6 @@ describe('Ownership Transfer Caveat', () => { type: 'ownershipTransfer', contractAddress, // Only allows this specific contract }, - caveats: [], }); const signedDelegation = { diff --git a/packages/delegator-e2e/test/delegateAndRedeem.test.ts b/packages/delegator-e2e/test/delegateAndRedeem.test.ts index fc2d61a9..4af16839 100644 --- a/packages/delegator-e2e/test/delegateAndRedeem.test.ts +++ b/packages/delegator-e2e/test/delegateAndRedeem.test.ts @@ -102,7 +102,6 @@ test('maincase: Bob increments the counter with a delegation from Alice', async targets: [aliceCounterContractAddress], selectors: ['increment()'], }, - caveats: [], }); const signedDelegation = { @@ -212,7 +211,6 @@ test("Bob attempts to increment the counter with a delegation from Alice that do targets: [aliceCounterContractAddress], selectors: ['notTheRightFunction()'], }, - caveats: [], }); const signedDelegation = { @@ -294,7 +292,6 @@ test('Bob increments the counter with a delegation from a multisig account', asy targets: [counterContract.address], selectors: ['increment()'], }, - caveats: [], }); // Get signatures from each signer diff --git a/packages/delegator-e2e/test/delegationManagement.test.ts b/packages/delegator-e2e/test/delegationManagement.test.ts index c2c376cc..8b5d7f10 100644 --- a/packages/delegator-e2e/test/delegationManagement.test.ts +++ b/packages/delegator-e2e/test/delegationManagement.test.ts @@ -86,7 +86,6 @@ test('delegation management lifecycle: create, disable, enable, and check status targets: [aliceCounter.address], selectors: ['increment()'], }, - caveats: [], }); const signedDelegation = { @@ -256,7 +255,6 @@ test('only delegator can disable their own delegation', async () => { targets: [aliceCounter.address], selectors: ['increment()'], }, - caveats: [], }); // Bob attempts to disable Alice's delegation (should fail) @@ -290,7 +288,6 @@ test('only delegator can enable their own delegation', async () => { targets: [aliceCounter.address], selectors: ['increment()'], }, - caveats: [], }); // Alice disables the delegation first @@ -347,7 +344,6 @@ test('disabling non-existent delegation should succeed silently', async () => { targets: [aliceCounter.address], selectors: ['increment()'], }, - caveats: [], }); // Alice disables the delegation even though it was never used @@ -395,7 +391,6 @@ test('can check delegation status using disabledDelegations', async () => { targets: [aliceCounter.address], selectors: ['increment()'], }, - caveats: [], }); const delegation2 = createDelegation({ @@ -407,7 +402,6 @@ test('can check delegation status using disabledDelegations', async () => { targets: [aliceCounter.address], selectors: ['decrement()'], }, - caveats: [], }); const delegationHash1 = getDelegationHashOffchain(delegation1); diff --git a/packages/delegator-e2e/test/utils/helpers.ts b/packages/delegator-e2e/test/utils/helpers.ts index 3295d4cf..b3fea776 100644 --- a/packages/delegator-e2e/test/utils/helpers.ts +++ b/packages/delegator-e2e/test/utils/helpers.ts @@ -27,6 +27,7 @@ import { import CounterMetadata from '../utils/counter/metadata.json'; import * as ERC20Token from '../../contracts/out/ERC20Token.sol/ERC20Token.json'; import * as ERC721Token from '../../contracts/out/ERC721Token.sol/ERC721Token.json'; +import * as PayableReceiver from '../../contracts/out/PayableReceiver.sol/PayableReceiver.json'; import { chain, nodeUrl, @@ -50,6 +51,11 @@ const { bytecode: { object: erc721TokenBytecode }, } = ERC721Token; +const { + abi: payableReceiverAbi, + bytecode: { object: payableReceiverBytecode }, +} = PayableReceiver; + export const transport = http(nodeUrl); const deployerAccount = privateKeyToAccount(deployPk); @@ -397,3 +403,71 @@ export const transferContractOwnership = (newOwner: Hex) => { args: [newOwner], }); }; + +export const deployPayableReceiver = async () => { + // Deploy the PayableReceiver contract + const hash = await deployerClient.deployContract({ + abi: payableReceiverAbi as Abi, + bytecode: payableReceiverBytecode as Hex, + }); + + // Wait for the transaction receipt to get the deployed contract address + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + + if (!receipt.contractAddress) { + throw new Error( + 'Failed to deploy PayableReceiver contract - no contract address in receipt', + ); + } + + return receipt.contractAddress; +}; + +export const getPayableReceiverBalance = async ( + contractAddress: Hex, +): Promise => { + return publicClient.readContract({ + address: contractAddress, + abi: payableReceiverAbi as Abi, + functionName: 'getBalance', + args: [], + }) as Promise; +}; + +export const getPayableReceiverTotalReceived = async ( + contractAddress: Hex, +): Promise => { + return publicClient.readContract({ + address: contractAddress, + abi: payableReceiverAbi as Abi, + functionName: 'totalReceived', + args: [], + }) as Promise; +}; + +export const getPayableReceiverCallCount = async ( + contractAddress: Hex, +): Promise => { + return publicClient.readContract({ + address: contractAddress, + abi: payableReceiverAbi as Abi, + functionName: 'callCount', + args: [], + }) as Promise; +}; + +export const encodeReceiveEthCalldata = () => { + return encodeFunctionData({ + abi: payableReceiverAbi as Abi, + functionName: 'receiveEth', + args: [], + }); +}; + +export const encodeReceiveEthAlternativeCalldata = () => { + return encodeFunctionData({ + abi: payableReceiverAbi as Abi, + functionName: 'receiveEthAlternative', + args: [], + }); +}; From 677075edb31d8c0dcfed265fd643ab86775b4d10 Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Wed, 20 Aug 2025 14:37:18 -0600 Subject: [PATCH 2/6] fix: empty calldata array bypasses security checks --- .../scope/nativeTokenPeriodicScope.ts | 2 +- .../scope/nativeTokenStreamingScope.ts | 2 +- .../scope/nativeTokenTransferScope.ts | 2 +- .../scope/nativeTokenPeriodicScope.test.ts | 35 ++++++++++ .../scope/nativeTokenStreamingScope.test.ts | 70 ++++++++++++++++++- .../scope/nativeTokenTransferScope.test.ts | 28 ++++++++ 6 files changed, 135 insertions(+), 4 deletions(-) diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts index c0d0b315..014a1e02 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts @@ -38,7 +38,7 @@ export function createNativeTokenPeriodicCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if (allowedCalldata) { + if (allowedCalldata && allowedCalldata.length > 0) { allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts index 550a9496..d662718b 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts @@ -39,7 +39,7 @@ export function createNativeTokenStreamingCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if (allowedCalldata) { + if (allowedCalldata && allowedCalldata.length > 0) { allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts index a31e63a1..ab9e7cab 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts @@ -32,7 +32,7 @@ export function createNativeTokenTransferCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if (allowedCalldata) { + if (allowedCalldata && allowedCalldata.length > 0) { allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); diff --git a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenPeriodicScope.test.ts b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenPeriodicScope.test.ts index 3590b10e..6a7ce884 100644 --- a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenPeriodicScope.test.ts +++ b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenPeriodicScope.test.ts @@ -10,6 +10,7 @@ describe('createNativeTokenPeriodicCaveatBuilder', () => { const environment = { caveatEnforcers: { ExactCalldataEnforcer: randomAddress(), + AllowedCalldataEnforcer: randomAddress(), NativeTokenPeriodTransferEnforcer: randomAddress(), }, } as unknown as DeleGatorEnvironment; @@ -46,4 +47,38 @@ describe('createNativeTokenPeriodicCaveatBuilder', () => { }, ]); }); + + it('creates a native token periodic transfer CaveatBuilder with empty allowedCalldata array (should fall back to default)', () => { + const config: NativeTokenPeriodicScopeConfig = { + type: 'nativeTokenPeriodTransfer', + periodAmount: 1000n, + periodDuration: 1000, + startDate: Math.floor(Date.now() / 1000), + allowedCalldata: [], // Empty array should trigger fallback to default exactCalldata + }; + + const caveatBuilder = createNativeTokenPeriodicCaveatBuilder( + environment, + config, + ); + + const caveats = caveatBuilder.build(); + + expect(caveats).to.deep.equal([ + { + enforcer: environment.caveatEnforcers.ExactCalldataEnforcer, + args: '0x', + terms: '0x', + }, + { + enforcer: environment.caveatEnforcers.NativeTokenPeriodTransferEnforcer, + args: '0x', + terms: concat([ + toHex(config.periodAmount, { size: 32 }), + toHex(config.periodDuration, { size: 32 }), + toHex(config.startDate, { size: 32 }), + ]), + }, + ]); + }); }); diff --git a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenStreamingScope.test.ts b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenStreamingScope.test.ts index f9a4cf3c..48e6caca 100644 --- a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenStreamingScope.test.ts +++ b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenStreamingScope.test.ts @@ -10,11 +10,12 @@ describe('createNativeTokenStreamingCaveatBuilder', () => { const environment = { caveatEnforcers: { ExactCalldataEnforcer: randomAddress(), + AllowedCalldataEnforcer: randomAddress(), NativeTokenStreamingEnforcer: randomAddress(), }, } as unknown as DeleGatorEnvironment; - it('creates a native token streaming CaveatBuilder', () => { + it('creates a native token streaming CaveatBuilder with default empty calldata', () => { const config: NativeTokenStreamingScopeConfig = { type: 'nativeTokenStreaming', initialAmount: 1000n, @@ -48,4 +49,71 @@ describe('createNativeTokenStreamingCaveatBuilder', () => { }, ]); }); + + it('creates a native token streaming CaveatBuilder with empty allowedCalldata array (should fall back to default)', () => { + const config: NativeTokenStreamingScopeConfig = { + type: 'nativeTokenStreaming', + initialAmount: 1000n, + maxAmount: 10000n, + amountPerSecond: 1n, + startTime: Math.floor(Date.now() / 1000), + allowedCalldata: [], // Empty array should trigger fallback to default exactCalldata + }; + + const caveatBuilder = createNativeTokenStreamingCaveatBuilder( + environment, + config, + ); + + const caveats = caveatBuilder.build(); + + expect(caveats).to.deep.equal([ + { + enforcer: environment.caveatEnforcers.ExactCalldataEnforcer, + args: '0x', + terms: '0x', + }, + { + enforcer: environment.caveatEnforcers.NativeTokenStreamingEnforcer, + args: '0x', + terms: concat([ + toHex(config.initialAmount, { size: 32 }), + toHex(config.maxAmount, { size: 32 }), + toHex(config.amountPerSecond, { size: 32 }), + toHex(config.startTime, { size: 32 }), + ]), + }, + ]); + }); + + it('creates a native token streaming CaveatBuilder with allowedCalldata', () => { + const config: NativeTokenStreamingScopeConfig = { + type: 'nativeTokenStreaming', + initialAmount: 1000n, + maxAmount: 10000n, + amountPerSecond: 1n, + startTime: Math.floor(Date.now() / 1000), + allowedCalldata: [ + { + startIndex: 4, + value: '0x1234', + }, + ], + }; + + const caveatBuilder = createNativeTokenStreamingCaveatBuilder( + environment, + config, + ); + + const caveats = caveatBuilder.build(); + + expect(caveats).to.have.lengthOf(2); + expect(caveats[0]?.enforcer).to.equal( + environment.caveatEnforcers.AllowedCalldataEnforcer, + ); + expect(caveats[1]?.enforcer).to.equal( + environment.caveatEnforcers.NativeTokenStreamingEnforcer, + ); + }); }); diff --git a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts index c7929306..1715d2e3 100644 --- a/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts +++ b/packages/delegation-toolkit/test/caveatBuilder/scope/nativeTokenTransferScope.test.ts @@ -72,6 +72,34 @@ describe('createNativeTokenTransferCaveatBuilder', () => { ]); }); + it('creates a native token transfer CaveatBuilder with empty allowedCalldata array (should fall back to default)', () => { + const config: NativeTokenTransferScopeConfig = { + type: 'nativeTokenTransferAmount', + maxAmount: 1000n, + allowedCalldata: [], // Empty array should trigger fallback to default exactCalldata + }; + + const caveatBuilder = createNativeTokenTransferCaveatBuilder( + environment, + config, + ); + + const caveats = caveatBuilder.build(); + + expect(caveats).to.deep.equal([ + { + enforcer: environment.caveatEnforcers.ExactCalldataEnforcer, + args: '0x', + terms: '0x', + }, + { + enforcer: environment.caveatEnforcers.NativeTokenTransferAmountEnforcer, + args: '0x', + terms: toHex(config.maxAmount, { size: 32 }), + }, + ]); + }); + it('creates a native token transfer CaveatBuilder with allowed calldata', () => { const config: NativeTokenTransferScopeConfig = { type: 'nativeTokenTransferAmount', From 11441dec4fd800c1d6b98e6135d85052e8496bf8 Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Wed, 27 Aug 2025 14:29:12 -0600 Subject: [PATCH 3/6] chore: prevent doble calldata --- .../caveatBuilder/scope/functionCallScope.ts | 12 ++- .../scope/nativeTokenPeriodicScope.ts | 7 ++ .../scope/nativeTokenStreamingScope.ts | 7 ++ .../scope/nativeTokenTransferScope.ts | 7 ++ .../test/delegation.test.ts | 74 +++++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts index adeb1052..9e44d488 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts @@ -31,6 +31,7 @@ const isFunctionCallConfig = ( * @param config - Configuration object containing allowed targets, methods, and optionally calldata. * @returns A configured caveat builder with the specified caveats. * @throws Error if any of the required parameters are invalid. + * @throws Error if both allowedCalldata and exactCalldata are provided simultaneously. */ export function createFunctionCallCaveatBuilder( environment: DeleGatorEnvironment, @@ -42,16 +43,21 @@ export function createFunctionCallCaveatBuilder( throw new Error('Invalid Function Call configuration'); } + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + throw new Error( + 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', + ); + } + const caveatBuilder = createCaveatBuilder(environment) .addCaveat('allowedTargets', { targets }) .addCaveat('allowedMethods', { selectors }); - if (allowedCalldata) { + if (allowedCalldata && allowedCalldata.length > 0) { allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); - } - if (exactCalldata) { + } else if (exactCalldata) { caveatBuilder.addCaveat('exactCalldata', exactCalldata); } diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts index 014a1e02..5dec1438 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts @@ -21,6 +21,7 @@ export type NativeTokenPeriodicScopeConfig = { * @param config - Configuration object containing native token periodic transfer parameters. * @returns A configured caveat builder with native token period transfer and exact calldata caveats. * @throws Error if any of the native token periodic transfer parameters are invalid. + * @throws Error if both allowedCalldata and exactCalldata are provided simultaneously. * @throws Error if the environment is not properly configured. */ export function createNativeTokenPeriodicCaveatBuilder( @@ -35,6 +36,12 @@ export function createNativeTokenPeriodicCaveatBuilder( exactCalldata, } = config; + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + throw new Error( + 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', + ); + } + const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts index d662718b..e4f7c1c1 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts @@ -21,6 +21,7 @@ export type NativeTokenStreamingScopeConfig = { * @param config - Configuration object containing native token streaming parameters. * @returns A configured caveat builder with native token streaming and exact calldata caveats. * @throws Error if any of the native token streaming parameters are invalid. + * @throws Error if both allowedCalldata and exactCalldata are provided simultaneously. * @throws Error if the environment is not properly configured. */ export function createNativeTokenStreamingCaveatBuilder( @@ -36,6 +37,12 @@ export function createNativeTokenStreamingCaveatBuilder( exactCalldata, } = config; + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + throw new Error( + 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', + ); + } + const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts index ab9e7cab..0c6ce9d4 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts @@ -21,6 +21,7 @@ export type NativeTokenTransferScopeConfig = { * @param config - Configuration object containing native token transfer parameters. * @returns A configured caveat builder with native token transfer amount and exact calldata caveats. * @throws Error if any of the native token transfer parameters are invalid. + * @throws Error if both allowedCalldata and exactCalldata are provided simultaneously. * @throws Error if the environment is not properly configured. */ export function createNativeTokenTransferCaveatBuilder( @@ -29,6 +30,12 @@ export function createNativeTokenTransferCaveatBuilder( ): CoreCaveatBuilder { const { maxAmount, allowedCalldata, exactCalldata } = config; + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + throw new Error( + 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', + ); + } + const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions diff --git a/packages/delegation-toolkit/test/delegation.test.ts b/packages/delegation-toolkit/test/delegation.test.ts index 37fea610..64a14306 100644 --- a/packages/delegation-toolkit/test/delegation.test.ts +++ b/packages/delegation-toolkit/test/delegation.test.ts @@ -291,6 +291,44 @@ describe('createDelegation', () => { signature: '0x', }); }); + + it('should create a delegation with scope-only caveats when caveats parameter is undefined', () => { + const result = createDelegation({ + environment: delegatorEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + caveats: undefined, + }); + + expect(result).to.deep.equal({ + delegate: mockDelegate, + delegator: mockDelegator, + authority: ROOT_AUTHORITY, + caveats: [...erc20ScopeCaveats], + salt: '0x', + signature: '0x', + }); + }); + + it('should create a delegation with scope-only caveats when caveats parameter is null', () => { + const result = createDelegation({ + environment: delegatorEnvironment, + scope: erc20Scope, + to: mockDelegate, + from: mockDelegator, + caveats: null as any, + }); + + expect(result).to.deep.equal({ + delegate: mockDelegate, + delegator: mockDelegator, + authority: ROOT_AUTHORITY, + caveats: [...erc20ScopeCaveats], + salt: '0x', + signature: '0x', + }); + }); }); describe('createOpenDelegation', () => { @@ -401,6 +439,42 @@ describe('createOpenDelegation', () => { signature: '0x', }); }); + + it('should create an open delegation with scope-only caveats when caveats parameter is undefined', () => { + const result = createOpenDelegation({ + environment: delegatorEnvironment, + scope: erc20Scope, + from: mockDelegator, + caveats: undefined, + }); + + expect(result).to.deep.equal({ + delegate: '0x0000000000000000000000000000000000000a11', + delegator: mockDelegator, + authority: ROOT_AUTHORITY, + caveats: [...erc20ScopeCaveats], + salt: '0x', + signature: '0x', + }); + }); + + it('should create an open delegation with scope-only caveats when caveats parameter is null', () => { + const result = createOpenDelegation({ + environment: delegatorEnvironment, + scope: erc20Scope, + from: mockDelegator, + caveats: null as any, + }); + + expect(result).to.deep.equal({ + delegate: '0x0000000000000000000000000000000000000a11', + delegator: mockDelegator, + authority: ROOT_AUTHORITY, + caveats: [...erc20ScopeCaveats], + salt: '0x', + signature: '0x', + }); + }); }); describe('encodeDelegations', () => { From 3a8ab8b24390d9b5ad9ff9b079d18101f8a59955 Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Thu, 4 Sep 2025 10:27:35 -0600 Subject: [PATCH 4/6] refactor: modernize allowedCalldata checks with optional chaining --- .../src/caveatBuilder/scope/functionCallScope.ts | 6 +++--- .../src/caveatBuilder/scope/nativeTokenPeriodicScope.ts | 6 +++--- .../src/caveatBuilder/scope/nativeTokenStreamingScope.ts | 6 +++--- .../src/caveatBuilder/scope/nativeTokenTransferScope.ts | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts index 9e44d488..eda64bd4 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts @@ -43,7 +43,7 @@ export function createFunctionCallCaveatBuilder( throw new Error('Invalid Function Call configuration'); } - if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -53,8 +53,8 @@ export function createFunctionCallCaveatBuilder( .addCaveat('allowedTargets', { targets }) .addCaveat('allowedMethods', { selectors }); - if (allowedCalldata && allowedCalldata.length > 0) { - allowedCalldata.forEach((calldataConfig) => { + if ((allowedCalldata?.length ?? 0) > 0) { + allowedCalldata!.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts index 5dec1438..f366c426 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts @@ -36,7 +36,7 @@ export function createNativeTokenPeriodicCaveatBuilder( exactCalldata, } = config; - if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -45,8 +45,8 @@ export function createNativeTokenPeriodicCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if (allowedCalldata && allowedCalldata.length > 0) { - allowedCalldata.forEach((calldataConfig) => { + if ((allowedCalldata?.length ?? 0) > 0) { + allowedCalldata!.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts index e4f7c1c1..bcbdfef6 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts @@ -37,7 +37,7 @@ export function createNativeTokenStreamingCaveatBuilder( exactCalldata, } = config; - if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -46,8 +46,8 @@ export function createNativeTokenStreamingCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if (allowedCalldata && allowedCalldata.length > 0) { - allowedCalldata.forEach((calldataConfig) => { + if ((allowedCalldata?.length ?? 0) > 0) { + allowedCalldata!.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts index 0c6ce9d4..2544c32f 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts @@ -30,7 +30,7 @@ export function createNativeTokenTransferCaveatBuilder( ): CoreCaveatBuilder { const { maxAmount, allowedCalldata, exactCalldata } = config; - if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { + if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -39,8 +39,8 @@ export function createNativeTokenTransferCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if (allowedCalldata && allowedCalldata.length > 0) { - allowedCalldata.forEach((calldataConfig) => { + if ((allowedCalldata?.length ?? 0) > 0) { + allowedCalldata!.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { From 967410bb6e5e06bc4cad25b862b623762f290023 Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Fri, 5 Sep 2025 13:23:51 -0600 Subject: [PATCH 5/6] chore: reverted validation changes --- .../src/caveatBuilder/scope/functionCallScope.ts | 6 +++--- .../src/caveatBuilder/scope/nativeTokenPeriodicScope.ts | 6 +++--- .../src/caveatBuilder/scope/nativeTokenStreamingScope.ts | 6 +++--- .../src/caveatBuilder/scope/nativeTokenTransferScope.ts | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts index eda64bd4..9e44d488 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/functionCallScope.ts @@ -43,7 +43,7 @@ export function createFunctionCallCaveatBuilder( throw new Error('Invalid Function Call configuration'); } - if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -53,8 +53,8 @@ export function createFunctionCallCaveatBuilder( .addCaveat('allowedTargets', { targets }) .addCaveat('allowedMethods', { selectors }); - if ((allowedCalldata?.length ?? 0) > 0) { - allowedCalldata!.forEach((calldataConfig) => { + if (allowedCalldata && allowedCalldata.length > 0) { + allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts index f366c426..5dec1438 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenPeriodicScope.ts @@ -36,7 +36,7 @@ export function createNativeTokenPeriodicCaveatBuilder( exactCalldata, } = config; - if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -45,8 +45,8 @@ export function createNativeTokenPeriodicCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if ((allowedCalldata?.length ?? 0) > 0) { - allowedCalldata!.forEach((calldataConfig) => { + if (allowedCalldata && allowedCalldata.length > 0) { + allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts index bcbdfef6..e4f7c1c1 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenStreamingScope.ts @@ -37,7 +37,7 @@ export function createNativeTokenStreamingCaveatBuilder( exactCalldata, } = config; - if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -46,8 +46,8 @@ export function createNativeTokenStreamingCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if ((allowedCalldata?.length ?? 0) > 0) { - allowedCalldata!.forEach((calldataConfig) => { + if (allowedCalldata && allowedCalldata.length > 0) { + allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { diff --git a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts index 2544c32f..0c6ce9d4 100644 --- a/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts +++ b/packages/delegation-toolkit/src/caveatBuilder/scope/nativeTokenTransferScope.ts @@ -30,7 +30,7 @@ export function createNativeTokenTransferCaveatBuilder( ): CoreCaveatBuilder { const { maxAmount, allowedCalldata, exactCalldata } = config; - if ((allowedCalldata?.length ?? 0) > 0 && exactCalldata) { + if (allowedCalldata && allowedCalldata.length > 0 && exactCalldata) { throw new Error( 'Cannot specify both allowedCalldata and exactCalldata. Please use only one calldata restriction type.', ); @@ -39,8 +39,8 @@ export function createNativeTokenTransferCaveatBuilder( const caveatBuilder = createCaveatBuilder(environment); // Add calldata restrictions - if ((allowedCalldata?.length ?? 0) > 0) { - allowedCalldata!.forEach((calldataConfig) => { + if (allowedCalldata && allowedCalldata.length > 0) { + allowedCalldata.forEach((calldataConfig) => { caveatBuilder.addCaveat('allowedCalldata', calldataConfig); }); } else if (exactCalldata) { From 63c03c1419d1f2f58b85d02317ed4eafe964dafb Mon Sep 17 00:00:00 2001 From: hanzel98 Date: Sat, 6 Sep 2025 22:35:20 -0600 Subject: [PATCH 6/6] test: fix deployment in test --- packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts b/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts index d6ef0de9..028389ea 100644 --- a/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts +++ b/packages/delegator-e2e/test/caveats/ownershipTransfer.test.ts @@ -62,6 +62,7 @@ describe('Ownership Transfer Caveat', () => { deploySalt: '0x2', signatory: { account: bobAccount }, }); + await deploySmartAccount(bobSmartAccount); // Deploy an ERC721 contract that Alice will own (and can transfer ownership of) contractAddress = (await deployErc721Token(