diff --git a/package-lock.json b/package-lock.json index af28189..64cf179 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", - "@trustvc/eip7702": "^1.0.0-beta.1", + "@trustvc/eip7702": "^1.1.0-beta.2", "@trustvc/w3c": "^2.4.2", "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-credential-status": "^2.4.0", @@ -6909,9 +6909,9 @@ "license": "Apache-2.0" }, "node_modules/@trustvc/eip7702": { - "version": "1.0.0-beta.1", - "resolved": "https://registry.npmjs.org/@trustvc/eip7702/-/eip7702-1.0.0-beta.1.tgz", - "integrity": "sha512-cOGuZVLHd0+Uu7rZ/Dp2iF1VuNq+L+pWWGe+tPD/u74rSvCAzIc17dIk5HOwRBiC5awh1rXG46vLG8IRc5O01Q==", + "version": "1.1.0-beta.2", + "resolved": "https://registry.npmjs.org/@trustvc/eip7702/-/eip7702-1.1.0-beta.2.tgz", + "integrity": "sha512-yo82l4fDCE6ZuTPeuDxBSgcRxAUOmVm51y16dJQH4xpM2ZkEKPXfPsMjpiPa636uOEVan+e38v/IQfy2HPg9sA==", "license": "MIT", "dependencies": { "@account-abstraction/contracts": "^0.8.0", @@ -23867,6 +23867,7 @@ "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, "license": "BSD-2-Clause", "optional": true, "bin": { diff --git a/package.json b/package.json index c433347..0492153 100644 --- a/package.json +++ b/package.json @@ -127,12 +127,12 @@ "@tradetrust-tt/tradetrust": "^6.10.3", "@tradetrust-tt/tt-verify": "^9.7.5", "@trustvc/document-store": "^1.0.3", + "@trustvc/eip7702": "^1.1.0-beta.2", "@trustvc/w3c": "^2.4.2", "@trustvc/w3c-context": "^2.4.0", "@trustvc/w3c-credential-status": "^2.4.0", "@trustvc/w3c-issuer": "^2.3.0", "@trustvc/w3c-vc": "^2.4.2", - "@trustvc/eip7702": "^1.0.0-beta.1", "ethers": "^5.8.0", "ethersV6": "npm:ethers@^6.14.4", "js-sha3": "^0.9.3", diff --git a/src/__tests__/eip7702-functions/admin.test.ts b/src/__tests__/eip7702-functions/admin.test.ts index 18195a0..8a8f6e2 100644 --- a/src/__tests__/eip7702-functions/admin.test.ts +++ b/src/__tests__/eip7702-functions/admin.test.ts @@ -3,8 +3,11 @@ import { vi, describe, beforeEach, it, expect } from 'vitest'; vi.mock('@trustvc/eip7702', () => ({ abis: { platformPaymasterAbi: [] }, constants: { - ChainId: { Sepolia: 11155111 }, - contractAddress: { PlatformAccountFactory: {} }, + ChainId: { Sepolia: 11155111, Amoy: 80002 }, + contractAddress: { + PlatformAccountFactory: { 11155111: '0xfactory', 80002: '0xfactoryAmoy' }, + PaymasterImplementation: { 11155111: '0xpaymasterImpl', 80002: '0xpaymasterImplAmoy' }, + }, }, })); @@ -30,6 +33,9 @@ import { addAuthorizedCaller, removeAuthorizedCaller, setDailyLimit, + stakePaymaster, + fundPaymaster, + delegateUser, } from '../../eip7702-functions'; import { getEthersContractFromProvider, isV6EthersProvider } from '../../utils/ethers'; @@ -494,3 +500,194 @@ describe('sendAdminTx error propagation', () => { ); }); }); + +// ─── stakePaymaster ─────────────────────────────────────────────────────────── + +describe('stakePaymaster', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('viem — calls writeContract with addStake, correct args, and value', async () => { + const signer = makeViemSigner(); + await stakePaymaster(signer as never, PAYMASTER, 86400, 1000000000000000000n); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: PAYMASTER, + functionName: 'addStake', + args: [86400], + value: 1000000000000000000n, + }), + ); + }); + + it('viem — returns the transaction hash', async () => { + const signer = makeViemSigner(); + expect(await stakePaymaster(signer as never, PAYMASTER, 86400, 1n)).toBe(TX_HASH); + }); + + it('ethers v5 — calls addStake with args and value override', async () => { + const mockContract = makeEthersV5Contract('addStake'); + vi.mocked(getEthersContractFromProvider).mockReturnValue(vi.fn(() => mockContract) as never); + vi.mocked(isV6EthersProvider).mockReturnValue(false); + await stakePaymaster(makeEthersV5Signer() as never, PAYMASTER, 86400, 500n); + expect(mockContract.addStake).toHaveBeenCalledWith(86400, { value: 500n }); + }); + + it('ethers v5 — returns the transaction hash', async () => { + setupEthersV5Mock('addStake'); + expect(await stakePaymaster(makeEthersV5Signer() as never, PAYMASTER, 86400, 1n)).toBe(TX_HASH); + }); + + it('ethers v6 — returns tx.hash directly', async () => { + setupEthersV6Mock('addStake'); + expect(await stakePaymaster(makeEthersV5Signer() as never, PAYMASTER, 86400, 1n)).toBe(TX_HASH); + }); +}); + +// ─── fundPaymaster ──────────────────────────────────────────────────────────── + +describe('fundPaymaster', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('viem — calls writeContract with deposit and value', async () => { + const signer = makeViemSigner(); + await fundPaymaster(signer as never, PAYMASTER, 2000000000000000000n); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: PAYMASTER, + functionName: 'deposit', + args: [], + value: 2000000000000000000n, + }), + ); + }); + + it('viem — returns the transaction hash', async () => { + const signer = makeViemSigner(); + expect(await fundPaymaster(signer as never, PAYMASTER, 1n)).toBe(TX_HASH); + }); + + it('ethers v5 — calls deposit with value override', async () => { + const mockContract = makeEthersV5Contract('deposit'); + vi.mocked(getEthersContractFromProvider).mockReturnValue(vi.fn(() => mockContract) as never); + vi.mocked(isV6EthersProvider).mockReturnValue(false); + await fundPaymaster(makeEthersV5Signer() as never, PAYMASTER, 999n); + expect(mockContract.deposit).toHaveBeenCalledWith({ value: 999n }); + }); + + it('ethers v5 — returns the transaction hash', async () => { + setupEthersV5Mock('deposit'); + expect(await fundPaymaster(makeEthersV5Signer() as never, PAYMASTER, 1n)).toBe(TX_HASH); + }); + + it('ethers v6 — returns tx.hash directly', async () => { + setupEthersV6Mock('deposit'); + expect(await fundPaymaster(makeEthersV5Signer() as never, PAYMASTER, 1n)).toBe(TX_HASH); + }); +}); + +// ─── delegateUser ───────────────────────────────────────────────────────────── + +const IMPL = '0x5555555555555555555555555555555555555555' as `0x${string}`; +const OWNER_ADDR = '0xowner' as `0x${string}`; +const SIGNED_AUTH = { + contractAddress: IMPL, + chainId: 11155111, + nonce: 1, + r: '0x', + s: '0x', + yParity: 0, +}; + +const makeOwnerSigner = () => ({ + account: { address: OWNER_ADDR }, + chain: { id: 11155111 }, + signAuthorization: vi.fn(() => Promise.resolve(SIGNED_AUTH)), + sendTransaction: vi.fn(() => Promise.resolve(TX_HASH)), +}); + +const makePayerSigner = () => ({ + account: { address: '0xpayer' as `0x${string}` }, + chain: { id: 11155111 }, + sendTransaction: vi.fn(() => Promise.resolve(TX_HASH)), +}); + +describe('delegateUser', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('owner signs authorization with the implementation address', async () => { + const owner = makeOwnerSigner(); + await delegateUser(IMPL, owner as never); + expect(owner.signAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ contractAddress: IMPL }), + ); + }); + + it('without payerSigner — signAuthorization uses executor: self', async () => { + const owner = makeOwnerSigner(); + await delegateUser(IMPL, owner as never); + expect(owner.signAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ executor: 'self' }), + ); + }); + + it('with payerSigner — signAuthorization does not set executor', async () => { + const owner = makeOwnerSigner(); + const payer = makePayerSigner(); + await delegateUser(IMPL, owner as never, payer as never); + expect(owner.signAuthorization).toHaveBeenCalledWith( + expect.not.objectContaining({ executor: expect.anything() }), + ); + }); + + it('without payerSigner — owner submits and pays gas', async () => { + const owner = makeOwnerSigner(); + await delegateUser(IMPL, owner as never); + expect(owner.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ authorizationList: [SIGNED_AUTH], to: OWNER_ADDR, data: '0x' }), + ); + }); + + it('with payerSigner — payer submits the transaction, owner does not', async () => { + const owner = makeOwnerSigner(); + const payer = makePayerSigner(); + await delegateUser(IMPL, owner as never, payer as never); + expect(payer.sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ authorizationList: [SIGNED_AUTH], to: OWNER_ADDR, data: '0x' }), + ); + expect(owner.sendTransaction).not.toHaveBeenCalled(); + }); + + it('returns the transaction hash', async () => { + const owner = makeOwnerSigner(); + expect(await delegateUser(IMPL, owner as never)).toBe(TX_HASH); + }); + + it('throws if ownerSigner has no account', async () => { + const owner = { ...makeOwnerSigner(), account: undefined as undefined }; + await expect(delegateUser(IMPL, owner as never)).rejects.toThrow( + 'ownerSigner must have an account', + ); + }); + + it('throws if payerSigner has no account', async () => { + const owner = makeOwnerSigner(); + const payer = { ...makePayerSigner(), account: undefined as undefined }; + await expect(delegateUser(IMPL, owner as never, payer as never)).rejects.toThrow( + 'payerSigner must have an account', + ); + }); + + it('throws if ownerSigner and payerSigner are on different chains', async () => { + const owner = makeOwnerSigner(); // chain id 11155111 + const payer = { ...makePayerSigner(), chain: { id: 80002 } }; + await expect(delegateUser(IMPL, owner as never, payer as never)).rejects.toThrow( + 'chain mismatch', + ); + }); +}); diff --git a/src/__tests__/eip7702-functions/deploy.test.ts b/src/__tests__/eip7702-functions/deploy.test.ts index 617471c..360e8d9 100644 --- a/src/__tests__/eip7702-functions/deploy.test.ts +++ b/src/__tests__/eip7702-functions/deploy.test.ts @@ -11,9 +11,10 @@ vi.mock('@trustvc/eip7702', () => ({ platformAccountFactoryAbi: [], }, constants: { - ChainId: { Sepolia: 11155111 }, + ChainId: { Sepolia: 11155111, Amoy: 80002 }, contractAddress: { - PlatformAccountFactory: { 11155111: '0xfactory' }, + PlatformAccountFactory: { 11155111: '0xfactory', 80002: '0xfactoryAmoy' }, + PaymasterImplementation: { 11155111: '0xpaymasterImpl', 80002: '0xpaymasterImplAmoy' }, }, }, })); diff --git a/src/__tests__/eip7702-functions/mint.test.ts b/src/__tests__/eip7702-functions/mint.test.ts index 16276b8..8ab1844 100644 --- a/src/__tests__/eip7702-functions/mint.test.ts +++ b/src/__tests__/eip7702-functions/mint.test.ts @@ -11,8 +11,11 @@ vi.mock('viem', () => ({ vi.mock('@trustvc/eip7702', () => ({ abis: { platformPaymasterAbi: [] }, constants: { - ChainId: { Sepolia: 11155111 }, - contractAddress: { PlatformAccountFactory: {} }, + ChainId: { Sepolia: 11155111, Amoy: 80002 }, + contractAddress: { + PlatformAccountFactory: { 11155111: '0xfactory', 80002: '0xfactoryAmoy' }, + PaymasterImplementation: { 11155111: '0xpaymasterImpl', 80002: '0xpaymasterImplAmoy' }, + }, }, })); diff --git a/src/eip7702-functions/constants/index.ts b/src/eip7702-functions/constants/index.ts index efdaa7d..492ab9c 100644 --- a/src/eip7702-functions/constants/index.ts +++ b/src/eip7702-functions/constants/index.ts @@ -1,12 +1,24 @@ +import { constants as eip7702Constants } from '@trustvc/eip7702'; + export const gaslessConstants = { // Sepolia - GASLESS_FACTORY_ADDRESS_SEPOLIA: '0x7e9ef6363180baa744eb32ceab367a44f52adc9f' as `0x${string}`, + GASLESS_FACTORY_ADDRESS_SEPOLIA: eip7702Constants.contractAddress.PlatformAccountFactory[ + eip7702Constants.ChainId.Sepolia + ] as `0x${string}`, + GASLESS_PAYMASTER_IMPL_ADDRESS_SEPOLIA: eip7702Constants.contractAddress.PaymasterImplementation[ + eip7702Constants.ChainId.Sepolia + ] as `0x${string}`, GASLESS_EIP7702_IMPL_ADDRESS_SEPOLIA: '0xa46ec3920ac5fc54f4ba33185a91ae250adf59b8' as `0x${string}`, TDOC_DEPLOYER_ADDRESS_SEPOLIA: '0x64bc665056dc8be4092e569ed13a7f273be28cd2' as `0x${string}`, // Amoy - GASLESS_FACTORY_ADDRESS_AMOY: '0xfbe1d336000d567f98ac5318f7c0144501388409' as `0x${string}`, + GASLESS_FACTORY_ADDRESS_AMOY: eip7702Constants.contractAddress.PlatformAccountFactory[ + eip7702Constants.ChainId.Amoy + ] as `0x${string}`, + GASLESS_PAYMASTER_IMPL_ADDRESS_AMOY: eip7702Constants.contractAddress.PaymasterImplementation[ + eip7702Constants.ChainId.Amoy + ] as `0x${string}`, GASLESS_EIP7702_IMPL_ADDRESS_AMOY: '0x044de1d4515a76ed9e431e8ec89e8d600405fd86' as `0x${string}`, GASLESS_TDOC_DEPLOYER_ADDRESS_AMOY: '0xfcafea839e576967b96ad1fbfb52b5ca26cd1d25' as `0x${string}`, diff --git a/src/eip7702-functions/platform-paymaster-functions/admin.ts b/src/eip7702-functions/platform-paymaster-functions/admin.ts index 477dcae..f58c460 100644 --- a/src/eip7702-functions/platform-paymaster-functions/admin.ts +++ b/src/eip7702-functions/platform-paymaster-functions/admin.ts @@ -12,6 +12,7 @@ async function sendAdminTx( paymasterAddress: `0x${string}`, functionName: string, args: unknown[], + value?: bigint, ): Promise { if ('writeContract' in signer) { return signer.writeContract({ @@ -21,6 +22,7 @@ async function sendAdminTx( args: args as never, chain: signer.chain, account: signer.account!, + ...(value !== undefined && { value }), }); } @@ -32,8 +34,9 @@ async function sendAdminTx( ethSigner as any, // eslint-disable-line @typescript-eslint/no-explicit-any ); const isV6 = isV6EthersProvider(ethSigner.provider); + const callArgs = value !== undefined ? [...args, { value }] : args; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const tx = await (contract as any)[functionName](...args); + const tx = await (contract as any)[functionName](...callArgs); if (isV6) { return tx.hash as `0x${string}`; } @@ -161,3 +164,69 @@ export const setDailyLimit = async ( paymasterAddress: `0x${string}`, dailyLimit: bigint, ): Promise => sendAdminTx(signer, paymasterAddress, 'setDailyLimit', [dailyLimit]); + +/** + * Stakes ETH on the PlatformPaymaster with the ERC-4337 EntryPoint. + * Required before the paymaster can sponsor UserOperations. + * @param {AdminSigner} signer - Owner ethers signer (v5/v6) or viem WalletClient. + * @param {string} paymasterAddress - Address of the deployed PlatformPaymaster. + * @param {number} unstakeDelaySec - Lock period in seconds before stake can be withdrawn. + * @param {bigint} amount - Amount of ETH to stake in wei. + * @returns {Promise} Transaction hash. + */ +export const stakePaymaster = async ( + signer: AdminSigner, + paymasterAddress: `0x${string}`, + unstakeDelaySec: number, + amount: bigint, +): Promise => sendAdminTx(signer, paymasterAddress, 'addStake', [unstakeDelaySec], amount); + +/** + * Deposits ETH into the PlatformPaymaster's EntryPoint balance to fund gas sponsorship. + * Unlike staking, deposited funds are not locked and can be withdrawn at any time. + * @param {AdminSigner} signer - Ethers signer (v5/v6) or viem WalletClient. + * @param {string} paymasterAddress - Address of the deployed PlatformPaymaster. + * @param {bigint} amount - Amount of ETH to deposit in wei. + * @returns {Promise} Transaction hash. + */ +export const fundPaymaster = async ( + signer: AdminSigner, + paymasterAddress: `0x${string}`, + amount: bigint, +): Promise => sendAdminTx(signer, paymasterAddress, 'deposit', [], amount); + +/** + * Delegates a user's EOA to an EIP-7702 smart account implementation. + * The owner signs the authorization (no gas required). If a payerSigner is provided, + * it submits the type-4 transaction and covers gas; otherwise the owner submits and pays. + * @param {string} implementationAddress - The EIP-7702 implementation contract address to delegate to. + * @param {WalletClient} ownerSigner - The user's viem WalletClient (signs the EIP-7702 authorization). + * @param {WalletClient} [payerSigner] - Optional funded viem WalletClient that submits the tx and pays gas. + * @returns {Promise} Transaction hash. + */ +export const delegateUser = async ( + implementationAddress: `0x${string}`, + ownerSigner: WalletClient, + payerSigner?: WalletClient, +): Promise => { + if (!ownerSigner.account) throw new Error('ownerSigner must have an account'); + const submitter = payerSigner ?? ownerSigner; + if (!submitter.account) throw new Error('payerSigner must have an account'); + if (payerSigner && ownerSigner.chain?.id !== payerSigner.chain?.id) + throw new Error( + `chain mismatch: ownerSigner is on chain ${ownerSigner.chain?.id} but payerSigner is on chain ${payerSigner.chain?.id}`, + ); + const authorization = await ownerSigner.signAuthorization({ + account: ownerSigner.account, + contractAddress: implementationAddress, + ...(payerSigner ? {} : { executor: 'self' as const }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (submitter.sendTransaction as any)({ + to: ownerSigner.account.address, + data: '0x', + authorizationList: [authorization], + account: submitter.account, + chain: submitter.chain, + }); +};