diff --git a/tokens/external-delegate-token-master/anchor/package.json b/tokens/external-delegate-token-master/anchor/package.json index b364ec843..0662e922a 100644 --- a/tokens/external-delegate-token-master/anchor/package.json +++ b/tokens/external-delegate-token-master/anchor/package.json @@ -16,6 +16,8 @@ "@solana/web3.js": "^1.98.4" }, "devDependencies": { + "@noble/curves": "^1.9.7", + "@noble/hashes": "^2.2.0", "@types/bn.js": "^5.1.0", "@types/chai": "^5.2.3", "@types/mocha": "^10.0.10", diff --git a/tokens/external-delegate-token-master/anchor/pnpm-lock.yaml b/tokens/external-delegate-token-master/anchor/pnpm-lock.yaml index 50bb02794..70378550d 100644 --- a/tokens/external-delegate-token-master/anchor/pnpm-lock.yaml +++ b/tokens/external-delegate-token-master/anchor/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: specifier: ^1.98.4 version: 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) devDependencies: + '@noble/curves': + specifier: ^1.9.7 + version: 1.9.7 + '@noble/hashes': + specifier: ^2.2.0 + version: 2.3.0 '@types/bn.js': specifier: ^5.1.0 version: 5.2.0 @@ -255,14 +261,18 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@noble/curves@1.9.0': - resolution: {integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==} + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1517,12 +1527,14 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@noble/curves@1.9.0': + '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 '@noble/hashes@1.8.0': {} + '@noble/hashes@2.3.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -2139,7 +2151,7 @@ snapshots: '@solana/web3.js@1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: '@babel/runtime': 7.27.1 - '@noble/curves': 1.9.0 + '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.1.1(typescript@5.9.3) diff --git a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs index 425bf8aee..19d7917b3 100644 --- a/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs +++ b/tokens/external-delegate-token-master/anchor/programs/external-delegate-token-master/src/lib.rs @@ -14,30 +14,36 @@ pub mod external_delegate_token_master { let user_account = &mut ctx.accounts.user_account; user_account.authority = ctx.accounts.authority.key(); user_account.ethereum_address = [0; 20]; + user_account.nonce = 0; Ok(()) } - pub fn set_ethereum_address( - ctx: Context, - ethereum_address: [u8; 20], - ) -> Result<()> { + pub fn set_ethereum_address(ctx: Context, ethereum_address: [u8; 20]) -> Result<()> { let user_account = &mut ctx.accounts.user_account; user_account.ethereum_address = ethereum_address; Ok(()) } - pub fn transfer_tokens( - ctx: Context, - amount: u64, - signature: [u8; 65], - message: [u8; 32], - ) -> Result<()> { - let user_account = &ctx.accounts.user_account; + pub fn transfer_tokens(ctx: Context, amount: u64, signature: [u8; 65]) -> Result<()> { + // The digest is derived on-chain from the exact parameters that determine where funds + // move, plus a nonce, so a signature can't be replayed for a different transfer or reused + // after it's been consumed. It is signed raw (no EIP-191 prefix), so a wallet's default + // personal_sign output is intentionally not compatible. + let digest = transfer_digest( + &ctx.accounts.user_account.key(), + &ctx.accounts.user_token_account.key(), + &ctx.accounts.recipient_token_account.key(), + amount, + ctx.accounts.user_account.nonce, + ); - if !verify_ethereum_signature(&user_account.ethereum_address, &message, &signature) { + if !verify_ethereum_signature(&ctx.accounts.user_account.ethereum_address, &digest, &signature) { return Err(ErrorCode::InvalidSignature.into()); } + let user_account = &mut ctx.accounts.user_account; + user_account.nonce = user_account.nonce.checked_add(1).ok_or(ErrorCode::NonceOverflow)?; + // Transfer tokens let transfer_instruction = Transfer { from: ctx.accounts.user_token_account.to_account_info(), @@ -49,7 +55,7 @@ pub mod external_delegate_token_master { CpiContext::new_with_signer( ctx.accounts.token_program.key(), transfer_instruction, - &[&[user_account.key().as_ref(), &[ctx.bumps.user_pda]]], + &[&[ctx.accounts.user_account.key().as_ref(), &[ctx.bumps.user_pda]]], ), amount, )?; @@ -69,10 +75,7 @@ pub mod external_delegate_token_master { CpiContext::new_with_signer( ctx.accounts.token_program.key(), transfer_instruction, - &[&[ - ctx.accounts.user_account.key().as_ref(), - &[ctx.bumps.user_pda], - ]], + &[&[ctx.accounts.user_account.key().as_ref(), &[ctx.bumps.user_pda]]], ), amount, )?; @@ -83,7 +86,7 @@ pub mod external_delegate_token_master { #[derive(Accounts)] pub struct Initialize<'info> { - #[account(init, payer = authority, space = 8 + 32 + 20)] + #[account(init, payer = authority, space = 8 + 32 + 20 + 8)] // Ensure this is only for user_account pub user_account: Account<'info, UserAccount>, #[account(mut)] @@ -100,7 +103,7 @@ pub struct SetEthereumAddress<'info> { #[derive(Accounts)] pub struct TransferTokens<'info> { - #[account(has_one = authority)] + #[account(mut, has_one = authority)] pub user_account: Account<'info, UserAccount>, pub authority: Signer<'info>, #[account(mut)] @@ -136,27 +139,49 @@ pub struct AuthorityTransfer<'info> { pub struct UserAccount { pub authority: Pubkey, pub ethereum_address: [u8; 20], + pub nonce: u64, } #[error_code] pub enum ErrorCode { #[msg("Invalid Ethereum signature")] InvalidSignature, + #[msg("Nonce overflow")] + NonceOverflow, +} + +const TRANSFER_DOMAIN: &[u8] = b"external-delegate-token-master:transfer_tokens:v1"; + +/// Binds every value that determines where funds move (plus a nonce) into the digest that +/// must be Ethereum-signed, so a signature can't be replayed for a different transfer. +fn transfer_digest( + user_account: &Pubkey, + user_token_account: &Pubkey, + recipient_token_account: &Pubkey, + amount: u64, + nonce: u64, +) -> [u8; 32] { + let mut hasher = Keccak256::new(); + hasher.update(TRANSFER_DOMAIN); + hasher.update(crate::ID.as_ref()); + hasher.update(user_account.as_ref()); + hasher.update(user_token_account.as_ref()); + hasher.update(recipient_token_account.as_ref()); + hasher.update(&amount.to_le_bytes()); + hasher.update(&nonce.to_le_bytes()); + hasher.finalize().into() } -fn verify_ethereum_signature( - ethereum_address: &[u8; 20], - message: &[u8; 32], - signature: &[u8; 65], -) -> bool { +fn verify_ethereum_signature(ethereum_address: &[u8; 20], digest: &[u8; 32], signature: &[u8; 65]) -> bool { let recovery_id = signature[64]; let mut sig = [0u8; 64]; sig.copy_from_slice(&signature[..64]); - if let Ok(pubkey) = secp256k1_recover(message, recovery_id, &sig) { + if let Ok(pubkey) = secp256k1_recover(digest, recovery_id, &sig) { + // `to_bytes()` already returns the bare 64-byte X||Y with no 0x04 prefix — do not slice. let pubkey_bytes = pubkey.to_bytes(); let mut recovered_address = [0u8; 20]; - recovered_address.copy_from_slice(&keccak256(&pubkey_bytes[1..])[12..]); + recovered_address.copy_from_slice(&keccak256(&pubkey_bytes[..])[12..]); recovered_address == *ethereum_address } else { false diff --git a/tokens/external-delegate-token-master/anchor/tests/litesvm.test.ts b/tokens/external-delegate-token-master/anchor/tests/litesvm.test.ts index 3a85f7b13..57d445cfd 100644 --- a/tokens/external-delegate-token-master/anchor/tests/litesvm.test.ts +++ b/tokens/external-delegate-token-master/anchor/tests/litesvm.test.ts @@ -1,4 +1,6 @@ import * as anchor from '@anchor-lang/core'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { keccak_256 } from '@noble/hashes/sha3.js'; import { createAssociatedTokenAccountInstruction, createInitializeMint2Instruction, @@ -10,13 +12,64 @@ import { import { Keypair, LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction } from '@solana/web3.js'; import { getTokenDecoder } from '@solana-program/token'; import { LiteSVMProvider } from 'anchor-litesvm'; -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { LiteSVM } from 'litesvm'; import IDL from '../target/idl/external_delegate_token_master.json'; import type { ExternalDelegateTokenMaster } from '../target/types/external_delegate_token_master'; const PROGRAM_ID = new PublicKey(IDL.address); +// Must match `TRANSFER_DOMAIN` in lib.rs exactly. +const TRANSFER_DOMAIN = Buffer.from('external-delegate-token-master:transfer_tokens:v1', 'ascii'); + +const expectAnchorError = async (promise: Promise, code: string) => { + let caught: any; + try { + await promise; + } catch (error) { + caught = error; + } + assert.isDefined(caught, `expected the transaction to fail with ${code}`); + assert.strictEqual(caught?.error?.errorCode?.code, code, `expected ${code}, got: ${caught}`); +}; + +// Derives an Ethereum address from a raw secp256k1 private key. Note this DOES slice off the +// leading byte: @noble/curves returns an uncompressed key prefixed with 0x04, unlike +// `solana_secp256k1_recover::Secp256k1Pubkey::to_bytes()` on the Rust side, which is already +// the bare 64-byte X||Y with no prefix. Conflating the two is exactly the bug this fix corrects. +const deriveEthAddress = (privateKey: Uint8Array): Uint8Array => { + const uncompressed = secp256k1.getPublicKey(privateKey, false); + return keccak_256(uncompressed.slice(1)).slice(-20); +}; + +// Byte-for-byte identical to `transfer_digest` in lib.rs. +const buildTransferDigest = ( + userAccountKey: PublicKey, + userTokenAccountKey: PublicKey, + recipientTokenAccountKey: PublicKey, + amount: anchor.BN, + nonce: anchor.BN, +): Uint8Array => + keccak_256( + Buffer.concat([ + TRANSFER_DOMAIN, + PROGRAM_ID.toBuffer(), + userAccountKey.toBuffer(), + userTokenAccountKey.toBuffer(), + recipientTokenAccountKey.toBuffer(), + amount.toArrayLike(Buffer, 'le', 8), + nonce.toArrayLike(Buffer, 'le', 8), + ]), + ); + +const signDigest = (digest: Uint8Array, privateKey: Uint8Array): Buffer => { + const sig = secp256k1.sign(digest, privateKey); + const out = Buffer.alloc(65); + Buffer.from(sig.toCompactRawBytes()).copy(out, 0); + out[64] = sig.recovery; + return out; +}; + describe('External Delegate Token Master Tests', () => { const client = new LiteSVM(); client.addProgramFromFile(PROGRAM_ID, 'target/deploy/external_delegate_token_master.so'); @@ -45,6 +98,7 @@ describe('External Delegate Token Master Tests', () => { const account = await program.account.userAccount.fetch(userAccount.publicKey); expect(account.authority.toString()).to.equal(authority.publicKey.toString()); expect(account.ethereumAddress).to.deep.equal(new Array(20).fill(0)); + expect(account.nonce.toString()).to.equal('0'); }); it('should set ethereum address', async () => { @@ -105,4 +159,169 @@ describe('External Delegate Token Master Tests', () => { const userBalance = getTokenDecoder().decode(client.getAccount(userTokenAccount)!.data).amount; expect(userBalance).to.equal(BigInt(1_000_000_000 - 250)); }); + + describe('transferTokens (Ethereum-signature-gated)', () => { + const ethPrivateKey = secp256k1.utils.randomSecretKey(); + const otherEthPrivateKey = secp256k1.utils.randomSecretKey(); + let mint: PublicKey; + let userTokenAccount: PublicKey; + let recipientTokenAccount: PublicKey; + let happyPathSignature: Buffer; + let happyPathAmount: anchor.BN; + + before(async () => { + await program.methods + .setEthereumAddress(Array.from(deriveEthAddress(ethPrivateKey))) + .accountsPartial({ userAccount: userAccount.publicKey, authority: authority.publicKey }) + .signers([authority]) + .rpc(); + + const mintKeypair = Keypair.generate(); + mint = mintKeypair.publicKey; + userTokenAccount = getAssociatedTokenAddressSync(mint, userPda, true); + const recipient = Keypair.generate(); + recipientTokenAccount = getAssociatedTokenAddressSync(mint, recipient.publicKey); + + const lamports = await provider.connection.getMinimumBalanceForRentExemption(MINT_SIZE); + const setupTx = new Transaction().add( + SystemProgram.createAccount({ + fromPubkey: wallet.publicKey, + newAccountPubkey: mint, + space: MINT_SIZE, + lamports, + programId: TOKEN_PROGRAM_ID, + }), + createInitializeMint2Instruction(mint, 6, authority.publicKey, null), + createAssociatedTokenAccountInstruction(wallet.publicKey, userTokenAccount, userPda, mint), + createAssociatedTokenAccountInstruction( + wallet.publicKey, + recipientTokenAccount, + recipient.publicKey, + mint, + ), + createMintToInstruction(mint, userTokenAccount, authority.publicKey, 1_000_000_000), + ); + await provider.sendAndConfirm!(setupTx, [mintKeypair, authority]); + }); + + it('transfers tokens with a valid, correctly-bound ethereum signature', async () => { + const { nonce } = await program.account.userAccount.fetch(userAccount.publicKey); + const amount = new anchor.BN(300); + const digest = buildTransferDigest( + userAccount.publicKey, + userTokenAccount, + recipientTokenAccount, + amount, + nonce, + ); + const signature = signDigest(digest, ethPrivateKey); + + await program.methods + .transferTokens(amount, Array.from(signature)) + .accountsPartial({ + userAccount: userAccount.publicKey, + authority: authority.publicKey, + userTokenAccount, + recipientTokenAccount, + userPda, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([authority]) + .rpc(); + + const recipientBalance = getTokenDecoder().decode(client.getAccount(recipientTokenAccount)!.data).amount; + expect(recipientBalance).to.equal(BigInt(300)); + const senderBalance = getTokenDecoder().decode(client.getAccount(userTokenAccount)!.data).amount; + expect(senderBalance).to.equal(BigInt(1_000_000_000 - 300)); + + const account = await program.account.userAccount.fetch(userAccount.publicKey); + expect(account.nonce.toString()).to.equal('1'); + + // Kept for the replay test below — must be the exact bytes that were consumed here. + happyPathSignature = signature; + happyPathAmount = amount; + }); + + it('rejects the same signature replayed after the nonce has advanced', async () => { + // Without this, the resubmitted transaction is byte-identical to the one already + // processed in the happy-path test and gets rejected as a duplicate at the runtime + // level (before program logic runs) rather than exercising the on-chain nonce check. + client.expireBlockhash(); + + await expectAnchorError( + program.methods + .transferTokens(happyPathAmount, Array.from(happyPathSignature)) + .accountsPartial({ + userAccount: userAccount.publicKey, + authority: authority.publicKey, + userTokenAccount, + recipientTokenAccount, + userPda, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([authority]) + .rpc(), + 'InvalidSignature', + ); + }); + + it('rejects a valid signature submitted with a different amount', async () => { + const { nonce } = await program.account.userAccount.fetch(userAccount.publicKey); + const signedAmount = new anchor.BN(100); + const digest = buildTransferDigest( + userAccount.publicKey, + userTokenAccount, + recipientTokenAccount, + signedAmount, + nonce, + ); + const signature = signDigest(digest, ethPrivateKey); + const submittedAmount = new anchor.BN(999); + + await expectAnchorError( + program.methods + .transferTokens(submittedAmount, Array.from(signature)) + .accountsPartial({ + userAccount: userAccount.publicKey, + authority: authority.publicKey, + userTokenAccount, + recipientTokenAccount, + userPda, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([authority]) + .rpc(), + 'InvalidSignature', + ); + }); + + it('rejects a correctly-bound digest signed by an unregistered ethereum key', async () => { + const { nonce } = await program.account.userAccount.fetch(userAccount.publicKey); + const amount = new anchor.BN(100); + const digest = buildTransferDigest( + userAccount.publicKey, + userTokenAccount, + recipientTokenAccount, + amount, + nonce, + ); + const signature = signDigest(digest, otherEthPrivateKey); + + await expectAnchorError( + program.methods + .transferTokens(amount, Array.from(signature)) + .accountsPartial({ + userAccount: userAccount.publicKey, + authority: authority.publicKey, + userTokenAccount, + recipientTokenAccount, + userPda, + tokenProgram: TOKEN_PROGRAM_ID, + }) + .signers([authority]) + .rpc(), + 'InvalidSignature', + ); + }); + }); });