|
| 1 | +import { describe, it, expect, vi } from 'vitest'; |
| 2 | +import type { WalletClient } from 'viem'; |
| 3 | +import { |
| 4 | + assertDeadlineFresh, |
| 5 | + buildPermitTypedData, |
| 6 | + signPermit, |
| 7 | + validatePermitSignature, |
| 8 | + MAX_PERMIT_DEADLINE_WINDOW_SECONDS, |
| 9 | +} from '../evm/permit'; |
| 10 | + |
| 11 | +const OWNER = '0xA0b86991C6218b36c1d19D4a2e9Eb0cE3606eB48' as const; |
| 12 | +const SPENDER = '0x1111111111111111111111111111111111111111' as const; |
| 13 | +const TOKEN = '0x2222222222222222222222222222222222222222' as const; |
| 14 | + |
| 15 | +// 65-byte signature (132 hex chars) shaped like a real EIP-712 reply. r and s |
| 16 | +// are non-zero, s is in the low half, v is 27. Used as the wallet's mock reply |
| 17 | +// in the signPermit happy-path tests. |
| 18 | +const VALID_SIG = ('0x' + |
| 19 | + '11'.repeat(32) + // r |
| 20 | + '22'.repeat(32) + // s |
| 21 | + '1b' // v = 27 |
| 22 | +) as `0x${string}`; |
| 23 | + |
| 24 | +function fakeWallet(opts: { |
| 25 | + account?: `0x${string}`; |
| 26 | + chainId?: number; |
| 27 | + signature?: `0x${string}`; |
| 28 | + signError?: string; |
| 29 | +} = {}) { |
| 30 | + const account = opts.account ?? OWNER; |
| 31 | + const chainId = opts.chainId ?? 1; |
| 32 | + const signature = opts.signature ?? VALID_SIG; |
| 33 | + return { |
| 34 | + getAddresses: vi.fn().mockResolvedValue([account]), |
| 35 | + getChainId: vi.fn().mockResolvedValue(chainId), |
| 36 | + signTypedData: vi.fn().mockImplementation(() => { |
| 37 | + if (opts.signError) return Promise.reject(new Error(opts.signError)); |
| 38 | + return Promise.resolve(signature); |
| 39 | + }), |
| 40 | + } as unknown as WalletClient; |
| 41 | +} |
| 42 | + |
| 43 | +describe('assertDeadlineFresh', () => { |
| 44 | + it('accepts a deadline within the SDK cap window', () => { |
| 45 | + const now = Math.floor(Date.now() / 1000); |
| 46 | + const deadline = BigInt(now + 30 * 60); // 30 minutes |
| 47 | + expect(() => assertDeadlineFresh(deadline)).not.toThrow(); |
| 48 | + }); |
| 49 | + |
| 50 | + it('rejects a deadline already in the past', () => { |
| 51 | + const now = Math.floor(Date.now() / 1000); |
| 52 | + expect(() => assertDeadlineFresh(BigInt(now - 1))).toThrow(/not in the future/); |
| 53 | + }); |
| 54 | + |
| 55 | + it('rejects an unbounded deadline (e.g. MAX_SAFE_INTEGER)', () => { |
| 56 | + // Acts as a no-expiry bearer permit — defeats the EIP-2612 deadline |
| 57 | + // mechanism. Must be rejected so a bug in the caller can't sign one. |
| 58 | + expect(() => assertDeadlineFresh(BigInt(Number.MAX_SAFE_INTEGER))).toThrow(/exceeds the SDK cap/); |
| 59 | + }); |
| 60 | + |
| 61 | + it('rejects a deadline more than the cap into the future', () => { |
| 62 | + const now = Math.floor(Date.now() / 1000); |
| 63 | + const tooFar = BigInt(now + MAX_PERMIT_DEADLINE_WINDOW_SECONDS + 60); |
| 64 | + expect(() => assertDeadlineFresh(tooFar)).toThrow(/exceeds the SDK cap/); |
| 65 | + }); |
| 66 | + |
| 67 | + it('honors a caller-provided larger window when explicitly set', () => { |
| 68 | + // The cap is a default; advanced callers can opt out by supplying a wider |
| 69 | + // bound. This keeps the function flexible while making the safe path |
| 70 | + // automatic. |
| 71 | + const now = Math.floor(Date.now() / 1000); |
| 72 | + const twoHours = 60 * 60 * 2; |
| 73 | + expect(() => assertDeadlineFresh(BigInt(now + twoHours), now, twoHours + 1)).not.toThrow(); |
| 74 | + }); |
| 75 | +}); |
| 76 | + |
| 77 | +describe('buildPermitTypedData', () => { |
| 78 | + it('packs the EIP-2612 domain with chainId, name, version, verifyingContract', () => { |
| 79 | + const td = buildPermitTypedData({ |
| 80 | + chainId: 137, |
| 81 | + tokenAddress: TOKEN, |
| 82 | + tokenName: 'USD Coin', |
| 83 | + tokenVersion: '2', |
| 84 | + owner: OWNER, |
| 85 | + spender: SPENDER, |
| 86 | + value: 1_000_000n, |
| 87 | + nonce: 5n, |
| 88 | + deadline: 9_999_999_999n, |
| 89 | + }); |
| 90 | + expect(td.domain).toEqual({ |
| 91 | + name: 'USD Coin', |
| 92 | + version: '2', |
| 93 | + chainId: 137, |
| 94 | + verifyingContract: TOKEN, |
| 95 | + }); |
| 96 | + expect(td.primaryType).toBe('Permit'); |
| 97 | + // Permit struct must match EIP-2612 exactly (owner, spender, value, nonce, deadline). |
| 98 | + expect(td.types.Permit).toEqual([ |
| 99 | + { name: 'owner', type: 'address' }, |
| 100 | + { name: 'spender', type: 'address' }, |
| 101 | + { name: 'value', type: 'uint256' }, |
| 102 | + { name: 'nonce', type: 'uint256' }, |
| 103 | + { name: 'deadline', type: 'uint256' }, |
| 104 | + ]); |
| 105 | + }); |
| 106 | + |
| 107 | + it('defaults version to "1" when omitted', () => { |
| 108 | + const td = buildPermitTypedData({ |
| 109 | + chainId: 1, |
| 110 | + tokenAddress: TOKEN, |
| 111 | + tokenName: 'DAI', |
| 112 | + owner: OWNER, |
| 113 | + spender: SPENDER, |
| 114 | + value: 1n, |
| 115 | + nonce: 0n, |
| 116 | + deadline: 9_999_999_999n, |
| 117 | + }); |
| 118 | + expect(td.domain.version).toBe('1'); |
| 119 | + }); |
| 120 | +}); |
| 121 | + |
| 122 | +describe('validatePermitSignature', () => { |
| 123 | + it('accepts a well-formed signature with low-s and v=27', () => { |
| 124 | + expect(validatePermitSignature(VALID_SIG)).toEqual({ valid: true }); |
| 125 | + }); |
| 126 | + |
| 127 | + it('rejects a wrong-length string', () => { |
| 128 | + const short = '0xdeadbeef' as `0x${string}`; |
| 129 | + const out = validatePermitSignature(short); |
| 130 | + expect(out.valid).toBe(false); |
| 131 | + expect(out.reason).toMatch(/Expected 132 hex chars/); |
| 132 | + }); |
| 133 | + |
| 134 | + it('rejects an r=0 signature', () => { |
| 135 | + const sig = ('0x' + '00'.repeat(32) + '22'.repeat(32) + '1b') as `0x${string}`; |
| 136 | + expect(validatePermitSignature(sig).valid).toBe(false); |
| 137 | + }); |
| 138 | + |
| 139 | + it('rejects a high-s signature (EIP-2 malleability)', () => { |
| 140 | + // s = secp256k1 N - 1 → high half of the curve order. |
| 141 | + const highS = 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140'; |
| 142 | + const sig = ('0x' + '11'.repeat(32) + highS + '1b') as `0x${string}`; |
| 143 | + expect(validatePermitSignature(sig).valid).toBe(false); |
| 144 | + }); |
| 145 | + |
| 146 | + it('rejects an out-of-range v', () => { |
| 147 | + const sig = ('0x' + '11'.repeat(32) + '22'.repeat(32) + 'ff') as `0x${string}`; |
| 148 | + expect(validatePermitSignature(sig).valid).toBe(false); |
| 149 | + }); |
| 150 | +}); |
| 151 | + |
| 152 | +describe('signPermit', () => { |
| 153 | + function freshDeadline(): bigint { |
| 154 | + return BigInt(Math.floor(Date.now() / 1000) + 5 * 60); |
| 155 | + } |
| 156 | + |
| 157 | + it('signs and returns split v/r/s for a valid input', async () => { |
| 158 | + const wallet = fakeWallet(); |
| 159 | + const out = await signPermit({ |
| 160 | + walletClient: wallet, |
| 161 | + chainId: 1, |
| 162 | + tokenAddress: TOKEN, |
| 163 | + tokenName: 'USD Coin', |
| 164 | + tokenVersion: '2', |
| 165 | + owner: OWNER, |
| 166 | + spender: SPENDER, |
| 167 | + value: 1_000_000n, |
| 168 | + nonce: 0n, |
| 169 | + deadline: freshDeadline(), |
| 170 | + }); |
| 171 | + expect(out.signature).toBe(VALID_SIG); |
| 172 | + expect([27, 28]).toContain(out.v); |
| 173 | + expect(out.r).toMatch(/^0x[0-9a-f]{64}$/i); |
| 174 | + expect(out.s).toMatch(/^0x[0-9a-f]{64}$/i); |
| 175 | + }); |
| 176 | + |
| 177 | + it('refuses to sign when the wallet account differs from the permit owner', async () => { |
| 178 | + const wallet = fakeWallet({ account: '0x9999999999999999999999999999999999999999' }); |
| 179 | + await expect(signPermit({ |
| 180 | + walletClient: wallet, |
| 181 | + chainId: 1, |
| 182 | + tokenAddress: TOKEN, |
| 183 | + tokenName: 'USD Coin', |
| 184 | + owner: OWNER, |
| 185 | + spender: SPENDER, |
| 186 | + value: 1n, |
| 187 | + nonce: 0n, |
| 188 | + deadline: freshDeadline(), |
| 189 | + })).rejects.toThrow(/does not match permit owner/); |
| 190 | + }); |
| 191 | + |
| 192 | + it('refuses to sign when the wallet chainId disagrees with the permit chainId', async () => { |
| 193 | + // The wallet is on chain 137 but the caller is asking us to sign for chain 1. |
| 194 | + // The signed message would be replayable on the wallet's actual chain. |
| 195 | + const wallet = fakeWallet({ chainId: 137 }); |
| 196 | + await expect(signPermit({ |
| 197 | + walletClient: wallet, |
| 198 | + chainId: 1, |
| 199 | + tokenAddress: TOKEN, |
| 200 | + tokenName: 'USD Coin', |
| 201 | + owner: OWNER, |
| 202 | + spender: SPENDER, |
| 203 | + value: 1n, |
| 204 | + nonce: 0n, |
| 205 | + deadline: freshDeadline(), |
| 206 | + })).rejects.toThrow(/Wallet chainId 137 does not match permit chainId 1/); |
| 207 | + }); |
| 208 | + |
| 209 | + it('refuses to sign with a MAX_SAFE_INTEGER deadline (no expiry)', async () => { |
| 210 | + const wallet = fakeWallet(); |
| 211 | + await expect(signPermit({ |
| 212 | + walletClient: wallet, |
| 213 | + chainId: 1, |
| 214 | + tokenAddress: TOKEN, |
| 215 | + tokenName: 'USD Coin', |
| 216 | + owner: OWNER, |
| 217 | + spender: SPENDER, |
| 218 | + value: 1n, |
| 219 | + nonce: 0n, |
| 220 | + deadline: BigInt(Number.MAX_SAFE_INTEGER), |
| 221 | + })).rejects.toThrow(/exceeds the SDK cap/); |
| 222 | + }); |
| 223 | + |
| 224 | + it('throws when the wallet returns a malformed signature', async () => { |
| 225 | + // r=0 → fails validatePermitSignature inside signPermit before returning. |
| 226 | + const badSig = ('0x' + '00'.repeat(32) + '22'.repeat(32) + '1b') as `0x${string}`; |
| 227 | + const wallet = fakeWallet({ signature: badSig }); |
| 228 | + await expect(signPermit({ |
| 229 | + walletClient: wallet, |
| 230 | + chainId: 1, |
| 231 | + tokenAddress: TOKEN, |
| 232 | + tokenName: 'USD Coin', |
| 233 | + owner: OWNER, |
| 234 | + spender: SPENDER, |
| 235 | + value: 1n, |
| 236 | + nonce: 0n, |
| 237 | + deadline: freshDeadline(), |
| 238 | + })).rejects.toThrow(/invalid permit signature/); |
| 239 | + }); |
| 240 | +}); |
0 commit comments