diff --git a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol index 394140ad3..140daa624 100644 --- a/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol +++ b/packages/smart-contracts/src/contracts/ERC20RecurringPaymentProxy.sol @@ -38,6 +38,18 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran 'uint256 nonce,uint256 deadline,bool strictOrder)' ); + bytes32 private constant _LEG_TYPEHASH = + keccak256('Leg(address recipient,uint128 amount,bytes8 paymentReference)'); + + /* Nested Leg is appended once, in EIP-712 referenced-type order. */ + bytes32 private constant _BATCH_TYPEHASH = + keccak256( + 'SchedulePermitBatch(address subscriber,address token,uint128 relayerFee,' + 'uint8 totalPayments,uint256 nonce,uint256 deadline,bool strictOrder,' + 'bytes32 scheduleId,uint32[] dueTimes,Leg[] initialLegs,Leg[] recurringLegs)' + 'Leg(address recipient,uint128 amount,bytes8 paymentReference)' + ); + /* replay defence */ mapping(bytes32 => uint256) public triggeredPaymentsBitmap; mapping(bytes32 => uint8) public lastPaymentIndex; @@ -60,6 +72,26 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran bool strictOrder; } + struct Leg { + address recipient; + uint128 amount; + bytes8 paymentReference; + } + + struct SchedulePermitBatch { + address subscriber; + address token; + uint128 relayerFee; + uint8 totalPayments; + uint256 nonce; + uint256 deadline; + bool strictOrder; + bytes32 scheduleId; + uint32[] dueTimes; + Leg[] initialLegs; + Leg[] recurringLegs; + } + constructor( address adminSafe, address relayerEOA, @@ -80,6 +112,55 @@ contract ERC20RecurringPaymentProxy is EIP712, AccessControl, Pausable, Reentran return _hashTypedDataV4(structHash); } + function hashSchedule(SchedulePermit calldata p) public view returns (bytes32) { + return _hashSchedule(p); + } + + function _hashUint32Array(uint32[] calldata values) private pure returns (bytes32) { + bytes32[] memory words = new bytes32[](values.length); + for (uint256 i = 0; i < values.length; ++i) { + words[i] = bytes32(uint256(values[i])); + } + return keccak256(abi.encodePacked(words)); + } + + function _hashLeg(Leg calldata leg) private pure returns (bytes32) { + return keccak256(abi.encode(_LEG_TYPEHASH, leg.recipient, leg.amount, leg.paymentReference)); + } + + function _hashLegs(Leg[] calldata legs) private pure returns (bytes32) { + bytes32[] memory words = new bytes32[](legs.length); + for (uint256 i = 0; i < legs.length; ++i) { + words[i] = _hashLeg(legs[i]); + } + return keccak256(abi.encodePacked(words)); + } + + function _hashScheduleBatch(SchedulePermitBatch calldata p) private view returns (bytes32) { + bytes32 structHash = keccak256( + abi.encode( + _BATCH_TYPEHASH, + p.subscriber, + p.token, + p.relayerFee, + p.totalPayments, + p.nonce, + p.deadline, + p.strictOrder, + p.scheduleId, + _hashUint32Array(p.dueTimes), + _hashLegs(p.initialLegs), + _hashLegs(p.recurringLegs) + ) + ); + + return _hashTypedDataV4(structHash); + } + + function hashScheduleBatch(SchedulePermitBatch calldata p) public view returns (bytes32) { + return _hashScheduleBatch(p); + } + function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private { erc20FeeProxy.transferFromWithReferenceAndFee( p.token, diff --git a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts index ed8ee0d85..155dc3fd0 100644 --- a/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts +++ b/packages/smart-contracts/test/contracts/ERC20RecurringPaymentProxy.test.ts @@ -81,32 +81,61 @@ describe('ERC20RecurringPaymentProxy', () => { }; }; + const schedulePermitTypes = { + SchedulePermit: [ + { name: 'subscriber', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'feeAddress', type: 'address' }, + { name: 'amount', type: 'uint128' }, + { name: 'feeAmount', type: 'uint128' }, + { name: 'relayerFee', type: 'uint128' }, + { name: 'periodSeconds', type: 'uint32' }, + { name: 'firstPayment', type: 'uint32' }, + { name: 'totalPayments', type: 'uint8' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'strictOrder', type: 'bool' }, + ], + }; + + const schedulePermitBatchTypes = { + SchedulePermitBatch: [ + { name: 'subscriber', type: 'address' }, + { name: 'token', type: 'address' }, + { name: 'relayerFee', type: 'uint128' }, + { name: 'totalPayments', type: 'uint8' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'strictOrder', type: 'bool' }, + { name: 'scheduleId', type: 'bytes32' }, + { name: 'dueTimes', type: 'uint32[]' }, + { name: 'initialLegs', type: 'Leg[]' }, + { name: 'recurringLegs', type: 'Leg[]' }, + ], + Leg: [ + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint128' }, + { name: 'paymentReference', type: 'bytes8' }, + ], + }; + + const eip712Domain = async () => ({ + name: 'ERC20RecurringPaymentProxy', + version: '1', + chainId: await subscriber.getChainId(), + verifyingContract: erc20RecurringPaymentProxy.address, + }); + + const hashPermitOffchain = async (permit: any) => + ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitTypes, permit); + + const hashBatchOffchain = async (permit: any) => + ethers.utils._TypedDataEncoder.hash(await eip712Domain(), schedulePermitBatchTypes, permit); + // Helper function to create EIP712 signature const createSignature = async (permit: any, signer: Signer) => { - const domain = { - name: 'ERC20RecurringPaymentProxy', - version: '1', - chainId: await signer.getChainId(), - verifyingContract: erc20RecurringPaymentProxy.address, - }; - - const types = { - SchedulePermit: [ - { name: 'subscriber', type: 'address' }, - { name: 'token', type: 'address' }, - { name: 'recipient', type: 'address' }, - { name: 'feeAddress', type: 'address' }, - { name: 'amount', type: 'uint128' }, - { name: 'feeAmount', type: 'uint128' }, - { name: 'relayerFee', type: 'uint128' }, - { name: 'periodSeconds', type: 'uint32' }, - { name: 'firstPayment', type: 'uint32' }, - { name: 'totalPayments', type: 'uint8' }, - { name: 'nonce', type: 'uint256' }, - { name: 'deadline', type: 'uint256' }, - { name: 'strictOrder', type: 'bool' }, - ], - }; + const domain = await eip712Domain(); // Some providers (Hardhat in-process) happily accept the string-encoded data (what // ethers' _signTypedData sends). Others (Hardhat JSON-RPC, Ganache) expect the object @@ -121,7 +150,7 @@ describe('ERC20RecurringPaymentProxy', () => { { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], - ...types, + ...schedulePermitTypes, }, primaryType: 'SchedulePermit', domain, @@ -134,7 +163,31 @@ describe('ERC20RecurringPaymentProxy', () => { return await (signer.provider as any).send('eth_signTypedData', [address, typedDataObject]); } catch (_) { // Fallback to ethers helper (works in most in-process Hardhat environments) - return await (signer as any)._signTypedData(domain, types, permit); + return await (signer as any)._signTypedData(domain, schedulePermitTypes, permit); + } + }; + + const createBatchSignature = async (permit: any, signer: Signer) => { + const domain = await eip712Domain(); + const address = await signer.getAddress(); + const typedDataObject = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + ...schedulePermitBatchTypes, + }, + primaryType: 'SchedulePermitBatch', + domain, + message: permit, + }; + try { + return await (signer.provider as any).send('eth_signTypedData', [address, typedDataObject]); + } catch (_) { + return await (signer as any)._signTypedData(domain, schedulePermitBatchTypes, permit); } }; @@ -652,4 +705,71 @@ describe('ERC20RecurringPaymentProxy', () => { ).to.be.revertedWith('Pausable: paused'); }); }); + + describe('EIP-712 digest parity', () => { + const ref = (n: number) => ethers.utils.hexZeroPad(ethers.utils.hexlify(n), 8); + + const workedExample = () => { + const t0 = Math.floor(Date.UTC(2026, 8, 1) / 1000); + const oct1 = Math.floor(Date.UTC(2026, 9, 1) / 1000); + const nov1 = Math.floor(Date.UTC(2026, 10, 1) / 1000); + const dec1 = Math.floor(Date.UTC(2026, 11, 1) / 1000); + return { + subscriber: subscriberAddress, + token: testERC20.address, + relayerFee: 1_000_000, + totalPayments: 4, + nonce: 0, + deadline: Math.floor(Date.UTC(2027, 0, 1) / 1000), + strictOrder: false, + scheduleId: '0x0101010101010101010101010101010101010101010101010101010101010101', + dueTimes: [t0, oct1, nov1, dec1], + initialLegs: [ + { recipient: recipientAddress, amount: 30_000_000, paymentReference: ref(0x0a) }, + { recipient: feeAddressString, amount: 3_000_000, paymentReference: ref(0x0b) }, + ], + recurringLegs: [ + { recipient: recipientAddress, amount: 99_000_000, paymentReference: ref(0x0c) }, + { recipient: feeAddressString, amount: 5_000_000, paymentReference: ref(0x0d) }, + { recipient: userAddress, amount: 4_000_000, paymentReference: ref(0x0e) }, + { recipient: newRelayerAddress, amount: 2_000_000, paymentReference: ref(0x0f) }, + ], + }; + }; + + it('matches ethers _TypedDataEncoder for SchedulePermit', async () => { + const permit = createSchedulePermit(); + expect(await erc20RecurringPaymentProxy.hashSchedule(permit)).to.equal( + await hashPermitOffchain(permit), + ); + }); + + it('matches ethers _TypedDataEncoder for the worked-example SchedulePermitBatch', async () => { + const permit = workedExample(); + expect(await erc20RecurringPaymentProxy.hashScheduleBatch(permit)).to.equal( + await hashBatchOffchain(permit), + ); + }); + + it('matches ethers _TypedDataEncoder when initialLegs is empty', async () => { + const permit = { ...workedExample(), initialLegs: [] }; + expect(await erc20RecurringPaymentProxy.hashScheduleBatch(permit)).to.equal( + await hashBatchOffchain(permit), + ); + }); + + it('createBatchSignature is a valid typed-data payload for the worked example', async () => { + const permit = workedExample(); + const signature = await createBatchSignature(permit, subscriber); + expect(signature).to.match(/^0x[0-9a-fA-F]{130}$/); + }); + + it('uses an 8-byte payment reference whose fee-proxy topic is not the 32-byte pad', () => { + const ref8 = ref(0x0a); + const ref32 = ethers.utils.hexZeroPad(ref8, 32); + expect(ref8).to.equal('0x000000000000000a'); + expect(ethers.utils.keccak256(ref8)).to.equal(ethers.utils.keccak256('0x000000000000000a')); + expect(ethers.utils.keccak256(ref8)).to.not.equal(ethers.utils.keccak256(ref32)); + }); + }); });