Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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);
}
Comment on lines +160 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Batch permits cannot execute

When a relayer submits a signed SchedulePermitBatch, this function can only return its digest; no entry point accepts the batch, verifies its signature, or transfers its legs. Consequently, the newly defined 8-byte references cannot be emitted and the existing recurring-payment path remains unchanged.

Knowledge Base Used:


function _proxyTransfer(SchedulePermit calldata p, bytes calldata paymentReference) private {
erc20FeeProxy.transferFromWithReferenceAndFee(
p.token,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -121,7 +150,7 @@ describe('ERC20RecurringPaymentProxy', () => {
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
...types,
...schedulePermitTypes,
},
primaryType: 'SchedulePermit',
domain,
Expand All @@ -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);
}
};

Expand Down Expand Up @@ -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));
});
Comment on lines +767 to +773

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Emission path remains untested

This test only compares hashes of locally constructed byte values; it never calls triggerRecurringPayment or inspects TransferWithReferenceAndFee. Padding, truncation, or forwarding the wrong reference at the actual event boundary therefore remains undetected.

Knowledge Base Used: Payment lifecycle

});
});
Loading