diff --git a/packages/core/src/abis/GTokenAuthorization.json b/packages/core/src/abis/GTokenAuthorization.json new file mode 100644 index 00000000..02455287 --- /dev/null +++ b/packages/core/src/abis/GTokenAuthorization.json @@ -0,0 +1,383 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { "name": "cap_", "type": "uint256", "internalType": "uint256" }, + { "name": "factory_", "type": "address", "internalType": "address" } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allowance", + "inputs": [ + { "name": "owner", "type": "address", "internalType": "address" }, + { "name": "spender", "type": "address", "internalType": "address" } + ], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "approve", + "inputs": [ + { "name": "spender", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "authorizationState", + "inputs": [ + { "name": "authorizer", "type": "address", "internalType": "address" }, + { "name": "nonce", "type": "bytes32", "internalType": "bytes32" } + ], + "outputs": [{ "name": "", "type": "uint8", "internalType": "enum GTokenAuthorization.AuthorizationState" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "balanceOf", + "inputs": [{ "name": "account", "type": "address", "internalType": "address" }], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "burn", + "inputs": [{ "name": "value", "type": "uint256", "internalType": "uint256" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "burnFrom", + "inputs": [ + { "name": "account", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "CANCEL_AUTHORIZATION_TYPEHASH", + "inputs": [], + "outputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "cancelAuthorization", + "inputs": [ + { "name": "authorizer", "type": "address", "internalType": "address" }, + { "name": "nonce", "type": "bytes32", "internalType": "bytes32" }, + { "name": "signature", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "cap", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "decimals", + "inputs": [], + "outputs": [{ "name": "", "type": "uint8", "internalType": "uint8" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "DOMAIN_SEPARATOR", + "inputs": [], + "outputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "factory", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "contract IxPNTsFactory" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "MAX_AUTH_VALIDITY", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "mint", + "inputs": [ + { "name": "to", "type": "address", "internalType": "address" }, + { "name": "amount", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "mySBT", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "contract ISBT" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "name", + "inputs": [], + "outputs": [{ "name": "", "type": "string", "internalType": "string" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [{ "name": "", "type": "address", "internalType": "address" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "RECEIVE_WITH_AUTHORIZATION_TYPEHASH", + "inputs": [], + "outputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "receiveWithAuthorization", + "inputs": [ + { "name": "from", "type": "address", "internalType": "address" }, + { "name": "to", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "validAfter", "type": "uint256", "internalType": "uint256" }, + { "name": "validBefore", "type": "uint256", "internalType": "uint256" }, + { "name": "nonce", "type": "bytes32", "internalType": "bytes32" }, + { "name": "xPNTsToken", "type": "address", "internalType": "address" }, + { "name": "signature", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "remainingMintableSupply", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setMySBT", + "inputs": [{ "name": "mySBT_", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "symbol", + "inputs": [], + "outputs": [{ "name": "", "type": "string", "internalType": "string" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "totalSupply", + "inputs": [], + "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transfer", + "inputs": [ + { "name": "to", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "TRANSFER_WITH_AUTHORIZATION_TYPEHASH", + "inputs": [], + "outputs": [{ "name": "", "type": "bytes32", "internalType": "bytes32" }], + "stateMutability": "view" + }, + { + "type": "function", + "name": "transferFrom", + "inputs": [ + { "name": "from", "type": "address", "internalType": "address" }, + { "name": "to", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" } + ], + "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [{ "name": "newOwner", "type": "address", "internalType": "address" }], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "transferWithAuthorization", + "inputs": [ + { "name": "from", "type": "address", "internalType": "address" }, + { "name": "to", "type": "address", "internalType": "address" }, + { "name": "value", "type": "uint256", "internalType": "uint256" }, + { "name": "validAfter", "type": "uint256", "internalType": "uint256" }, + { "name": "validBefore", "type": "uint256", "internalType": "uint256" }, + { "name": "nonce", "type": "bytes32", "internalType": "bytes32" }, + { "name": "xPNTsToken", "type": "address", "internalType": "address" }, + { "name": "signature", "type": "bytes", "internalType": "bytes" } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "version", + "inputs": [], + "outputs": [{ "name": "", "type": "string", "internalType": "string" }], + "stateMutability": "pure" + }, + { + "type": "event", + "name": "Approval", + "inputs": [ + { "name": "owner", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "spender", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "value", "type": "uint256", "indexed": false, "internalType": "uint256" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AuthorizationCanceled", + "inputs": [ + { "name": "authorizer", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "nonce", "type": "bytes32", "indexed": true, "internalType": "bytes32" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "AuthorizationUsed", + "inputs": [ + { "name": "authorizer", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "nonce", "type": "bytes32", "indexed": true, "internalType": "bytes32" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { "name": "previousOwner", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "newOwner", "type": "address", "indexed": true, "internalType": "address" } + ], + "anonymous": false + }, + { + "type": "event", + "name": "Transfer", + "inputs": [ + { "name": "from", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "to", "type": "address", "indexed": true, "internalType": "address" }, + { "name": "value", "type": "uint256", "indexed": false, "internalType": "uint256" } + ], + "anonymous": false + }, + { "type": "error", "name": "AuthorizationExpired", "inputs": [] }, + { "type": "error", "name": "AuthorizationNotYetValid", "inputs": [] }, + { "type": "error", "name": "AuthorizationUsedOrCanceled", "inputs": [] }, + { "type": "error", "name": "AuthorizationWindowInvalid", "inputs": [] }, + { "type": "error", "name": "AuthorizationWindowTooLong", "inputs": [] }, + { "type": "error", "name": "CallerMustBeRecipient", "inputs": [] }, + { + "type": "error", + "name": "ERC20ExceededCap", + "inputs": [ + { "name": "increasedSupply", "type": "uint256", "internalType": "uint256" }, + { "name": "cap", "type": "uint256", "internalType": "uint256" } + ] + }, + { + "type": "error", + "name": "ERC20InsufficientAllowance", + "inputs": [ + { "name": "spender", "type": "address", "internalType": "address" }, + { "name": "allowance", "type": "uint256", "internalType": "uint256" }, + { "name": "needed", "type": "uint256", "internalType": "uint256" } + ] + }, + { + "type": "error", + "name": "ERC20InsufficientBalance", + "inputs": [ + { "name": "sender", "type": "address", "internalType": "address" }, + { "name": "balance", "type": "uint256", "internalType": "uint256" }, + { "name": "needed", "type": "uint256", "internalType": "uint256" } + ] + }, + { + "type": "error", + "name": "ERC20InvalidApprover", + "inputs": [{ "name": "approver", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "ERC20InvalidCap", + "inputs": [{ "name": "cap", "type": "uint256", "internalType": "uint256" }] + }, + { + "type": "error", + "name": "ERC20InvalidReceiver", + "inputs": [{ "name": "receiver", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "ERC20InvalidSender", + "inputs": [{ "name": "sender", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "ERC20InvalidSpender", + "inputs": [{ "name": "spender", "type": "address", "internalType": "address" }] + }, + { "type": "error", "name": "InvalidSignature", "inputs": [] }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [{ "name": "owner", "type": "address", "internalType": "address" }] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [{ "name": "account", "type": "address", "internalType": "address" }] + }, + { "type": "error", "name": "RecipientNotInProtocol", "inputs": [] }, + { "type": "error", "name": "SBTAlreadySet", "inputs": [] } + ] +} diff --git a/packages/core/src/abis/abi.config.json b/packages/core/src/abis/abi.config.json index 52341912..b2c6c562 100644 --- a/packages/core/src/abis/abi.config.json +++ b/packages/core/src/abis/abi.config.json @@ -9,6 +9,7 @@ { "name": "DVTValidator.json", "hash": "7fbc002acbd897b23bbdfcc1a50811d2091a1e43a9c05334aa0e787604474f13" }, { "name": "EntryPoint.json", "hash": "fb52a7e1d92dfed8285b78d1849661936c09401227e27e2eceda905b20cd6844" }, { "name": "GToken.json", "hash": "27d6adc3c327cdee1d4f8b57f2a19f0a59ac6d9f020e664be6fc4a51ddf828a9" }, + { "name": "GTokenAuthorization.json", "hash": "6a3e474c6f070ba848b59b718e6477cf30fd35b849a65ecd1d72ccaf512b07e3" }, { "name": "GTokenStaking.json", "hash": "207b5e22dcfd734c90ca1c1035d06a3cbbd0a6646544ce46e3cde8ed43524d26" }, { "name": "MicroPaymentChannel.json", "hash": "f91f26a39a5c4f930710c713e117e1fd6f400565e90316e5e54134cc47b47dff" }, { "name": "MySBT.json", "hash": "b3c2bec8b4d26ff44f39b4c26a7bfc4754add68bd2dd233a7ff0a1facac162cf" }, diff --git a/packages/core/src/abis/index.ts b/packages/core/src/abis/index.ts index 279353e0..82febd38 100644 --- a/packages/core/src/abis/index.ts +++ b/packages/core/src/abis/index.ts @@ -10,6 +10,7 @@ // Core System import RegistryABIData from './Registry.json' with { type: 'json' }; import GTokenABIData from './GToken.json' with { type: 'json' }; +import GTokenAuthorizationABIData from './GTokenAuthorization.json' with { type: 'json' }; import GTokenStakingABIData from './GTokenStaking.json' with { type: 'json' }; import SuperPaymasterABIData from './SuperPaymaster.json' with { type: 'json' }; import PaymasterFactoryABIData from './PaymasterFactory.json' with { type: 'json' }; @@ -43,6 +44,13 @@ export const RegistryArtifact = RegistryABIData; export const GTokenABI = (GTokenABIData as any).abi || GTokenABIData; export const GTokenArtifact = GTokenABIData; +// GTokenAuthorization v2.2.0 — EIP-3009 gasless transfers (SuperPaymaster main, 2026-05-21) +// @notice Requires GTokenAuthorization contract deployment. Do NOT use with legacy GToken address. +// @notice MAX_AUTH_VALIDITY = 5 min — keep validBefore - validAfter < 300s. +// @notice receiveWithAuthorization: msg.sender must equal `to` (relay cannot submit). +export const GTokenAuthorizationABI = (GTokenAuthorizationABIData as any).abi || GTokenAuthorizationABIData; +export const GTokenAuthorizationArtifact = GTokenAuthorizationABIData; + export const GTokenStakingABI = (GTokenStakingABIData as any).abi || GTokenStakingABIData; export const GTokenStakingArtifact = GTokenStakingABIData; diff --git a/packages/core/src/actions/__tests__/gTokenAuthorization.test.ts b/packages/core/src/actions/__tests__/gTokenAuthorization.test.ts new file mode 100644 index 00000000..50481fc0 --- /dev/null +++ b/packages/core/src/actions/__tests__/gTokenAuthorization.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { type Address, type Hex } from 'viem'; +import { gTokenAuthorizationActions, AuthorizationState } from '../gTokenAuthorization.js'; +import { GTokenAuthorizationABI } from '../../abis/index.js'; + +const TOKEN = '0xbC17B6C319561bcA805981fC2846e4678f9114Cb' as Address; +const ALICE = '0x1111111111111111111111111111111111111111' as Address; +const BOB = '0x2222222222222222222222222222222222222222' as Address; +const FACTORY = '0x3333333333333333333333333333333333333333' as Address; +const SBT = '0x4444444444444444444444444444444444444444' as Address; +const NONCE = '0xabcdef0000000000000000000000000000000000000000000000000000000001' as Hex; +const SIG = '0xdeadbeef' as Hex; + +const makeReadClient = (returnValue: unknown) => ({ + readContract: vi.fn().mockResolvedValue(returnValue), +}); + +const makeWriteClient = () => ({ + writeContract: vi.fn().mockResolvedValue('0xtxhash'), +}); + +describe('gTokenAuthorizationActions — ABI coverage', () => { + it('GTokenAuthorizationABI contains transferWithAuthorization', () => { + const names = GTokenAuthorizationABI.map((e: any) => e.name); + expect(names).toContain('transferWithAuthorization'); + expect(names).toContain('receiveWithAuthorization'); + expect(names).toContain('cancelAuthorization'); + expect(names).toContain('authorizationState'); + expect(names).toContain('DOMAIN_SEPARATOR'); + expect(names).toContain('MAX_AUTH_VALIDITY'); + expect(names).toContain('mySBT'); + expect(names).toContain('factory'); + expect(names).toContain('setMySBT'); + }); + + it('GTokenAuthorizationABI contains EIP-3009 events', () => { + const events = GTokenAuthorizationABI.filter((e: any) => e.type === 'event').map((e: any) => e.name); + expect(events).toContain('AuthorizationUsed'); + expect(events).toContain('AuthorizationCanceled'); + }); + + it('GTokenAuthorizationABI contains EIP-3009 errors', () => { + const errors = GTokenAuthorizationABI.filter((e: any) => e.type === 'error').map((e: any) => e.name); + expect(errors).toContain('AuthorizationNotYetValid'); + expect(errors).toContain('AuthorizationExpired'); + expect(errors).toContain('AuthorizationWindowTooLong'); + expect(errors).toContain('AuthorizationWindowInvalid'); + expect(errors).toContain('AuthorizationUsedOrCanceled'); + expect(errors).toContain('InvalidSignature'); + expect(errors).toContain('RecipientNotInProtocol'); + expect(errors).toContain('CallerMustBeRecipient'); + expect(errors).toContain('SBTAlreadySet'); + }); +}); + +describe('gTokenAuthorizationActions — read methods', () => { + it('authorizationState returns AuthorizationState enum', async () => { + const client = makeReadClient(0) as any; + const actions = gTokenAuthorizationActions(client); + const result = await actions.authorizationState({ token: TOKEN, authorizer: ALICE, nonce: NONCE }); + expect(result).toBe(AuthorizationState.Unused); + expect(client.readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: 'authorizationState', + args: [ALICE, NONCE], + })); + }); + + it('authorizationState maps Used = 1', async () => { + const client = makeReadClient(1) as any; + const actions = gTokenAuthorizationActions(client); + const result = await actions.authorizationState({ token: TOKEN, authorizer: ALICE, nonce: NONCE }); + expect(result).toBe(AuthorizationState.Used); + }); + + it('MAX_AUTH_VALIDITY reads constant', async () => { + const client = makeReadClient(300n) as any; + const actions = gTokenAuthorizationActions(client); + const result = await actions.MAX_AUTH_VALIDITY({ token: TOKEN }); + expect(result).toBe(300n); + }); + + it('mySBT reads SBT address', async () => { + const client = makeReadClient(SBT) as any; + const actions = gTokenAuthorizationActions(client); + expect(await actions.mySBT({ token: TOKEN })).toBe(SBT); + }); + + it('factory reads factory address', async () => { + const client = makeReadClient(FACTORY) as any; + const actions = gTokenAuthorizationActions(client); + expect(await actions.factory({ token: TOKEN })).toBe(FACTORY); + }); +}); + +describe('gTokenAuthorizationActions — write methods', () => { + let client: ReturnType; + let actions: ReturnType; + + beforeEach(() => { + client = makeWriteClient() as any; + actions = gTokenAuthorizationActions(client as any); + }); + + it('transferWithAuthorization passes args in correct order', async () => { + await actions.transferWithAuthorization({ + token: TOKEN, from: ALICE, to: BOB, + value: 100n, validAfter: 0n, validBefore: 300n, + nonce: NONCE, xPNTsToken: FACTORY, signature: SIG, + }); + expect(client.writeContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: 'transferWithAuthorization', + args: [ALICE, BOB, 100n, 0n, 300n, NONCE, FACTORY, SIG], + })); + }); + + it('receiveWithAuthorization passes args in correct order', async () => { + await actions.receiveWithAuthorization({ + token: TOKEN, from: ALICE, to: BOB, + value: 50n, validAfter: 0n, validBefore: 240n, + nonce: NONCE, xPNTsToken: FACTORY, signature: SIG, + account: BOB, + }); + expect(client.writeContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: 'receiveWithAuthorization', + args: [ALICE, BOB, 50n, 0n, 240n, NONCE, FACTORY, SIG], + account: BOB, + })); + }); + + it('cancelAuthorization passes authorizer + nonce + signature', async () => { + await actions.cancelAuthorization({ + token: TOKEN, authorizer: ALICE, nonce: NONCE, signature: SIG, + }); + expect(client.writeContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: 'cancelAuthorization', + args: [ALICE, NONCE, SIG], + })); + }); + + it('setMySBT passes SBT address', async () => { + await actions.setMySBT({ token: TOKEN, mySBT: SBT }); + expect(client.writeContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: 'setMySBT', + args: [SBT], + })); + }); +}); diff --git a/packages/core/src/actions/gTokenAuthorization.ts b/packages/core/src/actions/gTokenAuthorization.ts new file mode 100644 index 00000000..0f931889 --- /dev/null +++ b/packages/core/src/actions/gTokenAuthorization.ts @@ -0,0 +1,156 @@ +import { type Address, type PublicClient, type WalletClient, type Hex, type Hash, type Account } from 'viem'; +import { GTokenAuthorizationABI } from '../abis/index.js'; +import { type GTokenActions } from './tokens.js'; + +// AuthorizationState enum mirrors GTokenAuthorization.AuthorizationState +export enum AuthorizationState { + Unused = 0, + Used = 1, + Canceled = 2, +} + +export type GTokenAuthorizationActions = GTokenActions & { + // ── EIP-3009 Write ──────────────────────────────────────────────────────── + // Any relay may submit. validBefore - validAfter must be <= 300s (MAX_AUTH_VALIDITY). + // + // xPNTsToken trust model (RC-2): + // xPNTsToken is NOT part of the EIP-712 signature — the signer does not commit to it. + // It is supplied by the relay at submission time as a hint for the RC-2 access check. + // Pass address(0) to fall back to the SBT-only path (no xPNTs required). + // If a non-zero xPNTsToken is accepted by the contract for access, the relay is trusted + // to supply the correct token address. Callers in untrusted relay environments should + // always pass address(0) to avoid relay-supplied access escalation. + transferWithAuthorization: (args: { + token: Address; + from: Address; + to: Address; + value: bigint; + validAfter: bigint; + validBefore: bigint; + nonce: Hex; + /** RC-2 access hint — NOT signed; pass address(0) to use SBT path only. See trust model above. */ + xPNTsToken: Address; + signature: Hex; + account?: Account | Address; + }) => Promise; + + // Only msg.sender == to may call on-chain (CallerMustBeRecipient if violated). + // Use for atomic deposit/wrapper flows where the recipient is the caller. + receiveWithAuthorization: (args: { + token: Address; + from: Address; + to: Address; + value: bigint; + validAfter: bigint; + validBefore: bigint; + nonce: Hex; + /** RC-2 access hint — NOT signed; pass address(0) to use SBT path only. See trust model above. */ + xPNTsToken: Address; + signature: Hex; + account?: Account | Address; + }) => Promise; + + // signature must be signed by `authorizer` over CancelAuthorization typehash. + cancelAuthorization: (args: { + token: Address; + authorizer: Address; + nonce: Hex; + signature: Hex; + account?: Account | Address; + }) => Promise; + + // ── EIP-3009 View ───────────────────────────────────────────────────────── + authorizationState: (args: { token: Address; authorizer: Address; nonce: Hex }) => Promise; + DOMAIN_SEPARATOR: (args: { token: Address }) => Promise; + TRANSFER_WITH_AUTHORIZATION_TYPEHASH: (args: { token: Address }) => Promise; + RECEIVE_WITH_AUTHORIZATION_TYPEHASH: (args: { token: Address }) => Promise; + CANCEL_AUTHORIZATION_TYPEHASH: (args: { token: Address }) => Promise; + + // RC-1: hard-capped validity window in seconds (= 300) + MAX_AUTH_VALIDITY: (args: { token: Address }) => Promise; + + // RC-2: SBT and factory references + mySBT: (args: { token: Address }) => Promise
; + factory: (args: { token: Address }) => Promise
; + + // One-time post-deploy config (owner only) + setMySBT: (args: { token: Address; mySBT: Address; account?: Account | Address }) => Promise; +}; + +export const gTokenAuthorizationActions = ( + client: PublicClient | WalletClient +): GTokenAuthorizationActions => { + const walletClient = client as WalletClient; + const publicClient = client as PublicClient; + + const readContract = (token: Address, functionName: string, args: unknown[] = []) => + publicClient.readContract({ + address: token, + abi: GTokenAuthorizationABI, + functionName, + args, + }) as Promise; + + const writeContract = (token: Address, functionName: string, args: unknown[], account?: Account | Address) => + (client as any).writeContract({ + address: token, + abi: GTokenAuthorizationABI, + functionName, + args, + account: account as any, + chain: (client as any).chain, + }); + + return { + // ── Inherited GToken / ERC20 ───────────────────────────────────────── + totalSupply: ({ token }) => readContract(token, 'totalSupply'), + balanceOf: ({ token, account }) => readContract(token, 'balanceOf', [account]), + transfer: ({ token, to, amount, account }) => writeContract(token, 'transfer', [to, amount], account), + transferFrom: ({ token, from, to, amount, account }) => writeContract(token, 'transferFrom', [from, to, amount], account), + approve: ({ token, spender, amount, account }) => writeContract(token, 'approve', [spender, amount], account), + allowance: ({ token, owner, spender }) => readContract(token, 'allowance', [owner, spender]), + name: ({ token }) => readContract(token, 'name'), + symbol: ({ token }) => readContract(token, 'symbol'), + decimals: ({ token }) => readContract(token, 'decimals'), + mint: ({ token, to, amount, account }) => writeContract(token, 'mint', [to, amount], account), + burn: ({ token, amount, account }) => writeContract(token, 'burn', [amount], account), + burnFrom: ({ token, from, amount, account }) => writeContract(token, 'burnFrom', [from, amount], account), + cap: ({ token }) => readContract(token, 'cap'), + remainingMintableSupply: ({ token }) => readContract(token, 'remainingMintableSupply'), + version: ({ token }) => readContract(token, 'version'), + owner: ({ token }) => readContract
(token, 'owner'), + transferOwnership: ({ token, newOwner, account }) => writeContract(token, 'transferOwnership', [newOwner], account), + renounceOwnership: ({ token, account }) => writeContract(token, 'renounceOwnership', [], account), + + // ── EIP-3009 Write ──────────────────────────────────────────────────── + transferWithAuthorization: ({ token, from, to, value, validAfter, validBefore, nonce, xPNTsToken, signature, account }) => + writeContract(token, 'transferWithAuthorization', [ + from, to, value, validAfter, validBefore, nonce, xPNTsToken, signature, + ], account), + + receiveWithAuthorization: ({ token, from, to, value, validAfter, validBefore, nonce, xPNTsToken, signature, account }) => + writeContract(token, 'receiveWithAuthorization', [ + from, to, value, validAfter, validBefore, nonce, xPNTsToken, signature, + ], account), + + cancelAuthorization: ({ token, authorizer, nonce, signature, account }) => + writeContract(token, 'cancelAuthorization', [authorizer, nonce, signature], account), + + // ── EIP-3009 View ───────────────────────────────────────────────────── + authorizationState: async ({ token, authorizer, nonce }) => { + const state = await readContract(token, 'authorizationState', [authorizer, nonce]); + return state as AuthorizationState; + }, + DOMAIN_SEPARATOR: ({ token }) => readContract(token, 'DOMAIN_SEPARATOR'), + TRANSFER_WITH_AUTHORIZATION_TYPEHASH: ({ token }) => readContract(token, 'TRANSFER_WITH_AUTHORIZATION_TYPEHASH'), + RECEIVE_WITH_AUTHORIZATION_TYPEHASH: ({ token }) => readContract(token, 'RECEIVE_WITH_AUTHORIZATION_TYPEHASH'), + CANCEL_AUTHORIZATION_TYPEHASH: ({ token }) => readContract(token, 'CANCEL_AUTHORIZATION_TYPEHASH'), + MAX_AUTH_VALIDITY: ({ token }) => readContract(token, 'MAX_AUTH_VALIDITY'), + mySBT: ({ token }) => readContract
(token, 'mySBT'), + factory: ({ token }) => readContract
(token, 'factory'), + + // ── One-time config ─────────────────────────────────────────────────── + setMySBT: ({ token, mySBT, account }) => + writeContract(token, 'setMySBT', [mySBT], account), + }; +}; diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index d427231b..b604b347 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -15,4 +15,5 @@ export * from './faucet.js'; export * from './x402.js'; export * from './agent.js'; export * from './channel.js'; +export * from './gTokenAuthorization.js'; // validators.ts legacy removed diff --git a/packages/x402/__tests__/index.test.ts b/packages/x402/__tests__/index.test.ts index 34957abc..b9c2bcc6 100644 --- a/packages/x402/__tests__/index.test.ts +++ b/packages/x402/__tests__/index.test.ts @@ -96,7 +96,7 @@ describe('@aastar/x402', () => { it('should be declared as const', () => { // `as const` provides TypeScript-level immutability // Verify the structure is stable and well-defined - expect(Object.keys(EIP3009_TYPES)).toEqual(['TransferWithAuthorization']); + expect(Object.keys(EIP3009_TYPES)).toEqual(['TransferWithAuthorization', 'ReceiveWithAuthorization', 'CancelAuthorization']); }); }); diff --git a/packages/x402/src/__tests__/eip3009-authorization.test.ts b/packages/x402/src/__tests__/eip3009-authorization.test.ts new file mode 100644 index 00000000..813ee58f --- /dev/null +++ b/packages/x402/src/__tests__/eip3009-authorization.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi } from 'vitest'; +import { type Address, type Hex } from 'viem'; +import { + EIP3009_TYPES, + GTOKEN_EIP712_DOMAIN, + signGTokenTransferWithAuthorization, + signReceiveWithAuthorization, + signCancelAuthorization, + generateNonce, +} from '../eip3009.js'; + +const TOKEN = '0xbC17B6C319561bcA805981fC2846e4678f9114Cb' as Address; +const ALICE = '0x1111111111111111111111111111111111111111' as Address; +const BOB = '0x2222222222222222222222222222222222222222' as Address; + +const makeWalletClient = (account: Address) => ({ + account: { address: account }, + signTypedData: vi.fn().mockResolvedValue('0xsignature'), +}); + +describe('EIP3009_TYPES', () => { + it('ReceiveWithAuthorization has the same 6 fields as TransferWithAuthorization', () => { + const transfer = EIP3009_TYPES.TransferWithAuthorization; + const receive = EIP3009_TYPES.ReceiveWithAuthorization; + expect(receive).toHaveLength(transfer.length); + const fieldNames = (arr: readonly { name: string; type: string }[]) => arr.map(f => f.name); + expect(fieldNames(receive)).toEqual(fieldNames(transfer)); + }); + + it('CancelAuthorization has authorizer + nonce fields', () => { + const cancel = EIP3009_TYPES.CancelAuthorization; + expect(cancel).toHaveLength(2); + expect(cancel[0].name).toBe('authorizer'); + expect(cancel[0].type).toBe('address'); + expect(cancel[1].name).toBe('nonce'); + expect(cancel[1].type).toBe('bytes32'); + }); +}); + +describe('GTOKEN_EIP712_DOMAIN', () => { + it('has correct name and version for GTokenAuthorization', () => { + expect(GTOKEN_EIP712_DOMAIN.name).toBe('GToken'); + expect(GTOKEN_EIP712_DOMAIN.version).toBe('1'); + }); +}); + +describe('signGTokenTransferWithAuthorization', () => { + const BASE_PARAMS = { + from: ALICE, to: BOB, value: 100n, + tokenName: 'GToken', tokenVersion: '1', + chainId: 11155111, verifyingContract: TOKEN, + }; + + it('calls signTypedData with TransferWithAuthorization primaryType for valid window', async () => { + const wallet = makeWalletClient(ALICE); + await signGTokenTransferWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 0n, validBefore: 299n, nonce: generateNonce(), + }); + expect(wallet.signTypedData).toHaveBeenCalledWith(expect.objectContaining({ + primaryType: 'TransferWithAuthorization', + message: expect.objectContaining({ from: ALICE, to: BOB, value: 100n }), + })); + }); + + it('throws if walletClient has no account', async () => { + const wallet = { account: undefined, signTypedData: vi.fn() }; + await expect(signGTokenTransferWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 0n, validBefore: 299n, nonce: generateNonce(), + })).rejects.toThrow('WalletClient must have an account'); + }); + + it('throws when signer does not match from', async () => { + const wallet = makeWalletClient(BOB); + await expect(signGTokenTransferWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 0n, validBefore: 299n, nonce: generateNonce(), + })).rejects.toThrow('does not match from'); + }); + + it('throws when validBefore <= validAfter', async () => { + const wallet = makeWalletClient(ALICE); + await expect(signGTokenTransferWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 100n, validBefore: 100n, nonce: generateNonce(), + })).rejects.toThrow('validBefore must be greater than validAfter'); + }); + + it('throws when authorization window exceeds 300s', async () => { + const wallet = makeWalletClient(ALICE); + await expect(signGTokenTransferWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 0n, validBefore: 301n, nonce: generateNonce(), + })).rejects.toThrow('exceeds MAX_AUTH_VALIDITY (300s)'); + }); +}); + +describe('signReceiveWithAuthorization', () => { + const BASE_PARAMS = { + from: ALICE, to: BOB, value: 100n, + tokenName: 'GToken', tokenVersion: '1', + chainId: 11155111, verifyingContract: TOKEN, + }; + + it('calls signTypedData with ReceiveWithAuthorization primaryType', async () => { + const wallet = makeWalletClient(ALICE); + await signReceiveWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 0n, validBefore: 299n, nonce: generateNonce(), + }); + expect(wallet.signTypedData).toHaveBeenCalledWith(expect.objectContaining({ + primaryType: 'ReceiveWithAuthorization', + message: expect.objectContaining({ from: ALICE, to: BOB, value: 100n }), + })); + }); + + it('throws if walletClient has no account', async () => { + const wallet = { account: undefined, signTypedData: vi.fn() }; + await expect(signReceiveWithAuthorization(wallet as any, { + ...BASE_PARAMS, validAfter: 0n, validBefore: 299n, nonce: generateNonce(), + })).rejects.toThrow('WalletClient must have an account'); + }); + + it('throws when signer does not match from', async () => { + const wallet = makeWalletClient(ALICE); + await expect(signReceiveWithAuthorization(wallet as any, { + ...BASE_PARAMS, from: BOB, validAfter: 0n, validBefore: 299n, nonce: generateNonce(), + })).rejects.toThrow('does not match from'); + }); + + it('throws when validBefore <= validAfter', async () => { + const wallet = makeWalletClient(BOB); + await expect(signReceiveWithAuthorization(wallet as any, { + ...BASE_PARAMS, from: BOB, validAfter: 100n, validBefore: 100n, nonce: generateNonce(), + })).rejects.toThrow('validBefore must be greater than validAfter'); + }); + + it('throws when authorization window exceeds 300s', async () => { + const wallet = makeWalletClient(BOB); + await expect(signReceiveWithAuthorization(wallet as any, { + ...BASE_PARAMS, from: BOB, validAfter: 0n, validBefore: 301n, nonce: generateNonce(), + })).rejects.toThrow('exceeds MAX_AUTH_VALIDITY (300s)'); + }); +}); + +describe('signCancelAuthorization', () => { + const BASE_PARAMS = { + tokenName: 'GToken', tokenVersion: '1', + chainId: 11155111, verifyingContract: TOKEN, + }; + + it('calls signTypedData with CancelAuthorization primaryType', async () => { + const wallet = makeWalletClient(ALICE); + const nonce = generateNonce(); + await signCancelAuthorization(wallet as any, { authorizer: ALICE, nonce, ...BASE_PARAMS }); + expect(wallet.signTypedData).toHaveBeenCalledWith(expect.objectContaining({ + primaryType: 'CancelAuthorization', + message: { authorizer: ALICE, nonce }, + })); + }); + + it('throws if walletClient has no account', async () => { + const wallet = { account: undefined, signTypedData: vi.fn() }; + await expect(signCancelAuthorization(wallet as any, { + authorizer: ALICE, nonce: generateNonce(), ...BASE_PARAMS, + })).rejects.toThrow('WalletClient must have an account'); + }); + + it('throws when signer does not match authorizer', async () => { + const wallet = makeWalletClient(BOB); + await expect(signCancelAuthorization(wallet as any, { + authorizer: ALICE, nonce: generateNonce(), ...BASE_PARAMS, + })).rejects.toThrow('does not match authorizer'); + }); +}); diff --git a/packages/x402/src/eip3009.ts b/packages/x402/src/eip3009.ts index 6c433dda..2ca8602d 100644 --- a/packages/x402/src/eip3009.ts +++ b/packages/x402/src/eip3009.ts @@ -9,6 +9,20 @@ export const EIP3009_TYPES = { { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' }, ], + // Distinct typehash from TransferWithAuthorization — prevents replay across variants. + // msg.sender must equal `to` on-chain (CallerMustBeRecipient error if violated). + ReceiveWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + CancelAuthorization: [ + { name: 'authorizer', type: 'address' }, + { name: 'nonce', type: 'bytes32' }, + ], } as const; export function getEIP3009Domain(tokenName: string, tokenVersion: string, chainId: number, verifyingContract: Address) { @@ -26,6 +40,9 @@ export function generateNonce(): Hex { return toHex(bytes); } +// GToken EIP-712 domain constants (GTokenAuthorization v2.2.0) +export const GTOKEN_EIP712_DOMAIN = { name: 'GToken', version: '1' } as const; + export async function signTransferWithAuthorization( walletClient: WalletClient, params: { @@ -68,3 +85,131 @@ export async function signTransferWithAuthorization( }, }); } + +/** + * Sign a TransferWithAuthorization for GTokenAuthorization (EIP-3009). + * GToken-specific wrapper: enforces MAX_AUTH_VALIDITY = 300s before signing. + * Use this instead of the generic signTransferWithAuthorization when the + * verifying contract is GTokenAuthorization. + */ +export async function signGTokenTransferWithAuthorization( + walletClient: WalletClient, + params: { + from: Address; + to: Address; + value: bigint; + validAfter: bigint; + validBefore: bigint; + nonce: Hex; + tokenName: string; + tokenVersion: string; + chainId: number; + verifyingContract: Address; + } +): Promise { + const account = walletClient.account; + if (!account) { + throw new Error('WalletClient must have an account'); + } + if (account.address.toLowerCase() !== params.from.toLowerCase()) { + throw new Error(`Signer ${account.address} does not match from ${params.from}`); + } + // GTokenAuthorization enforces MAX_AUTH_VALIDITY = 300s on-chain. + if (params.validBefore <= params.validAfter) { + throw new Error('validBefore must be greater than validAfter'); + } + if (params.validBefore - params.validAfter > 300n) { + throw new Error( + `Authorization window ${params.validBefore - params.validAfter}s exceeds MAX_AUTH_VALIDITY (300s)` + ); + } + return signTransferWithAuthorization(walletClient, params); +} + +/** + * Sign a ReceiveWithAuthorization for GTokenAuthorization (EIP-3009). + * The signed `to` address must be the one submitting the transaction on-chain. + * Note: `xPNTsToken` is NOT included in the signature (it's a relay-supplied hint for RC-2). + */ +export async function signReceiveWithAuthorization( + walletClient: WalletClient, + params: { + from: Address; + to: Address; + value: bigint; + validAfter: bigint; + validBefore: bigint; + nonce: Hex; + tokenName: string; + tokenVersion: string; + chainId: number; + verifyingContract: Address; + } +): Promise { + const account = walletClient.account; + if (!account) { + throw new Error('WalletClient must have an account'); + } + if (account.address.toLowerCase() !== params.from.toLowerCase()) { + throw new Error(`Signer ${account.address} does not match from ${params.from}`); + } + // GTokenAuthorization enforces MAX_AUTH_VALIDITY = 300s on-chain (RC-1). + if (params.validBefore <= params.validAfter) { + throw new Error('validBefore must be greater than validAfter'); + } + if (params.validBefore - params.validAfter > 300n) { + throw new Error( + `Authorization window ${params.validBefore - params.validAfter}s exceeds MAX_AUTH_VALIDITY (300s)` + ); + } + + return walletClient.signTypedData({ + account, + domain: getEIP3009Domain(params.tokenName, params.tokenVersion, params.chainId, params.verifyingContract), + types: EIP3009_TYPES, + primaryType: 'ReceiveWithAuthorization', + message: { + from: params.from, + to: params.to, + value: params.value, + validAfter: params.validAfter, + validBefore: params.validBefore, + nonce: params.nonce, + }, + }); +} + +/** + * Sign a CancelAuthorization for GTokenAuthorization (EIP-3009). + * Must be signed by the original `authorizer` address. + */ +export async function signCancelAuthorization( + walletClient: WalletClient, + params: { + authorizer: Address; + nonce: Hex; + tokenName: string; + tokenVersion: string; + chainId: number; + verifyingContract: Address; + } +): Promise { + const account = walletClient.account; + if (!account) { + throw new Error('WalletClient must have an account'); + } + if (account.address.toLowerCase() !== params.authorizer.toLowerCase()) { + throw new Error(`Signer ${account.address} does not match authorizer ${params.authorizer}`); + } + + return walletClient.signTypedData({ + account, + domain: getEIP3009Domain(params.tokenName, params.tokenVersion, params.chainId, params.verifyingContract), + types: EIP3009_TYPES, + primaryType: 'CancelAuthorization', + message: { + authorizer: params.authorizer, + nonce: params.nonce, + }, + }); +} diff --git a/packages/x402/src/index.ts b/packages/x402/src/index.ts index a56f47c6..4e659725 100644 --- a/packages/x402/src/index.ts +++ b/packages/x402/src/index.ts @@ -5,7 +5,16 @@ export type { SettleResponse, VerifyResponse, FacilitatorSupported, FacilitatorConfig, EIP3009Authorization, DirectPaymentPayload, ResourceInfo, NetworkId, } from './types.js'; -export { signTransferWithAuthorization, generateNonce, getEIP3009Domain, EIP3009_TYPES } from './eip3009.js'; +export { + signTransferWithAuthorization, + signGTokenTransferWithAuthorization, + signReceiveWithAuthorization, + signCancelAuthorization, + generateNonce, + getEIP3009Domain, + EIP3009_TYPES, + GTOKEN_EIP712_DOMAIN, +} from './eip3009.js'; export { encodePaymentRequired, decodePaymentRequired, encodePaymentPayload, decodePaymentPayload,