From deb3f2adf6fbe646bc49801676aa428e763c3fce Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 23 Apr 2024 23:05:53 -0700 Subject: [PATCH] [PAY-2747] Fix attestation decoding and add Secp256k1Program utils (#8210) --- .changeset/six-pens-flash.md | 5 + package-lock.json | 12 ++ .../solana-relay/src/routes/health/health.ts | 11 +- .../assertRelayAllowedInstructions.test.ts | 83 ++++++++------ .../relay/assertRelayAllowedInstructions.ts | 33 ++++-- packages/spl/package.json | 1 + packages/spl/src/index.ts | 1 + .../RewardManagerProgram.test.ts | 102 ++++++++++++++++- .../reward-manager/RewardManagerProgram.ts | 4 +- .../src/secp256k1/Secp256k1Program.test.ts | 27 +++++ .../spl/src/secp256k1/Secp256k1Program.ts | 106 ++++++++++++++++++ 11 files changed, 330 insertions(+), 55 deletions(-) create mode 100644 .changeset/six-pens-flash.md create mode 100644 packages/spl/src/secp256k1/Secp256k1Program.test.ts create mode 100644 packages/spl/src/secp256k1/Secp256k1Program.ts diff --git a/.changeset/six-pens-flash.md b/.changeset/six-pens-flash.md new file mode 100644 index 00000000000..e8ecfede393 --- /dev/null +++ b/.changeset/six-pens-flash.md @@ -0,0 +1,5 @@ +--- +'@audius/spl': patch +--- + +Add Secp256k1Program extensions to @audius/spl to aid in decoding and debugging secp256k1 instructions diff --git a/package-lock.json b/package-lock.json index 824e59f2096..c9ad20fd835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -139582,6 +139582,7 @@ "license": "Apache-2.0", "dependencies": { "@coral-xyz/anchor": "0.29.0", + "@noble/hashes": "1.4.0", "@solana/buffer-layout": "4.0.1", "@solana/buffer-layout-utils": "0.2.0", "@solana/spl-token": "0.3.8", @@ -139591,6 +139592,17 @@ "vitest": "0.34.6" } }, + "packages/spl/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "packages/sql-ts": { "name": "@audius/sql-ts", "version": "1.0.34", diff --git a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/health/health.ts b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/health/health.ts index e5ab92c94a2..badc4c1f4cd 100644 --- a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/health/health.ts +++ b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/health/health.ts @@ -1,8 +1,7 @@ - import { Request, Response, NextFunction } from 'express' - import type { RelayRequestBody } from '@audius/sdk' +import { Request, Response } from 'express' -export const health = async (req: Request, res: Response) => { - res.status(200).json({ - isHealthy: true - }) +export const health = async (_req: Request, res: Response) => { + res.status(200).json({ + isHealthy: true + }) } diff --git a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts index b5c66883168..3d5a2223b17 100644 --- a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts +++ b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.test.ts @@ -25,7 +25,6 @@ import { } from '@audius/spl' import { InvalidRelayInstructionError } from './InvalidRelayInstructionError' import { describe, it } from 'vitest' -import { AudiusLibs } from '@audius/sdk' const CLAIMABLE_TOKEN_PROGRAM_ID = new PublicKey(config.claimableTokenProgramId) @@ -55,28 +54,6 @@ const audioClaimableTokenAuthority = PublicKey.findProgramAddressSync( const getRandomPublicKey = () => Keypair.generate().publicKey -const getInittedLibs = async () => { - // @ts-ignore - const libs = new AudiusLibs({ - solanaWeb3Config: { - solanaClusterEndpoint: config.solanaEndpoint, - mintAddress: config.waudioMintAddress, - usdcMintAddress: config.usdcMintAddress, - solanaTokenAddress: TOKEN_PROGRAM_ID.toBase58(), - feePayerAddress: config.solanaFeePayerWallets[0].publicKey, - claimableTokenProgramAddress: config.claimableTokenProgramId, - rewardsManagerProgramId: config.rewardsManagerProgramId, - rewardsManagerProgramPDA: config.rewardsManagerAccountAddress, - rewardsManagerTokenPDA: '', - useRelay: false, - confirmationTimeout: 0, - paymentRouterProgramId: config.paymentRouterProgramId - } - }) - await libs.init() - return libs -} - describe('Solana Relay', function () { describe('Associated Token Account Program', function () { it('should allow create token account with matching close for valid mints', async function () { @@ -216,10 +193,8 @@ describe('Solana Relay', function () { it('should allow USDC transfers to userbanks', async function () { // Dummy eth address to make the encoder happy const wallet = '0xe42b199d864489387bf64262874fc6472bcbc151' - const userbank = await ( - await getInittedLibs() - ).solanaWeb3Manager!.deriveUserBank({ - mint: 'usdc', + const userbank = await ClaimableTokensProgram.deriveUserBank({ + claimableTokensPDA: usdcClaimableTokenAuthority, ethAddress: wallet }) @@ -519,7 +494,8 @@ describe('Solana Relay', function () { payer, mint, authority: usdcClaimableTokenAuthority, - userBank + userBank, + programId: CLAIMABLE_TOKEN_PROGRAM_ID }), ClaimableTokensProgram.createTransferInstruction({ payer, @@ -527,14 +503,16 @@ describe('Solana Relay', function () { sourceUserBank: userBank, destination, nonceAccount, - authority: usdcClaimableTokenAuthority + authority: usdcClaimableTokenAuthority, + programId: CLAIMABLE_TOKEN_PROGRAM_ID }), ClaimableTokensProgram.createAccountInstruction({ ethAddress: wallet, payer, mint, authority: audioClaimableTokenAuthority, - userBank + userBank, + programId: CLAIMABLE_TOKEN_PROGRAM_ID }), ClaimableTokensProgram.createTransferInstruction({ payer, @@ -542,7 +520,8 @@ describe('Solana Relay', function () { sourceUserBank: userBank, destination, nonceAccount, - authority: audioClaimableTokenAuthority + authority: audioClaimableTokenAuthority, + programId: CLAIMABLE_TOKEN_PROGRAM_ID }) ] await assertRelayAllowedInstructions(instructions) @@ -917,7 +896,6 @@ describe('Solana Relay', function () { }) it('should not allow transfers when not authenticated', async function () { - const feePayer = getRandomPublicKey() const fromPubkey = getRandomPublicKey() const toPubkey = getRandomPublicKey() await assert.rejects(async () => @@ -975,20 +953,51 @@ describe('Solana Relay', function () { }) describe('Other Programs', function () { - it('allows memo and SECP instructions', async function () { + it('allows memo instructions', async function () { await assertRelayAllowedInstructions([ new TransactionInstruction({ programId: MEMO_PROGRAM_ID, keys: [] }), - new TransactionInstruction({ programId: MEMO_V2_PROGRAM_ID, keys: [] }), + new TransactionInstruction({ programId: MEMO_V2_PROGRAM_ID, keys: [] }) + ]) + }) + + it('allows valid secp256k1 instructions', async function () { + await assertRelayAllowedInstructions([ Secp256k1Program.createInstructionWithEthAddress({ // Dummy eth address to make the encoder happy - ethAddress: '0xe42b199d864489387bf64262874fc6472bcbc151', - message: Buffer.from('some message', 'utf-8'), - signature: Buffer.alloc(64), + ethAddress: '0x8fcfa10bd3808570987dbb5b1ef4ab74400fbfda', + message: Buffer.from( + '68d5397bb16195ea47091010f3abb8fc6b5cdfa65f00e1f505000000005f623a33383639383d3e3530373431303135335f00b6462e955da5841b6d9e1e2529b830f00f31bf', + 'hex' + ), + signature: Buffer.from( + 'f89b2e6f97f95f1306b468b10b1a18df9569b07d9d7b81b241d6fc99d9ec782e4e449f5c3c63836ed52c9344d3de5c3133fead711e421af545822f09bd78cb39', + 'hex' + ), recoveryId: 0 }) ]) }) + it('rejects invalid secp256k1 instructions', async function () { + await assert.rejects(async () => + assertRelayAllowedInstructions([ + Secp256k1Program.createInstructionWithEthAddress({ + // Dummy eth address to make the encoder happy + ethAddress: '0x00b6462e955da5841b6d9e1e2529b830f00f31bf', + message: Buffer.from( + '81729dc83c157f41de7df4b72fc7e90d8d64d5aa5f00e1f505000000005f72656665727265643a353339343735333137', + 'hex' + ), + signature: Buffer.from( + '00d405b277dc948f97d7b7db8648cb16590d66084ba49642fedb08380ce5027a95d0a895287a3331332e7ad13daba87eed5c70820a19ca2eb6cc0ea1eb4695ba', + 'hex' + ), + recoveryId: 0 + }) + ]) + ) + }) + it('does not allow other random programs', async function () { await assert.rejects( async () => diff --git a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts index fc01ba84c96..be841f7967f 100644 --- a/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts +++ b/packages/discovery-provider/plugins/pedalboard/apps/solana-relay/src/routes/relay/assertRelayAllowedInstructions.ts @@ -10,7 +10,6 @@ import { import { PublicKey, TransactionInstruction, - Secp256k1Program, SystemProgram, SystemInstruction } from '@solana/web3.js' @@ -21,7 +20,8 @@ import { ClaimableTokensProgram, RewardManagerProgram, isCreateAssociatedTokenAccountIdempotentInstruction, - isCreateAssociatedTokenAccountInstruction + isCreateAssociatedTokenAccountInstruction, + Secp256k1Program } from '@audius/spl' import { config } from '../../config' import bs58 from 'bs58' @@ -196,8 +196,7 @@ const assertAllowedRewardsManagerProgramInstruction = ( */ const assertAllowedClaimableTokenProgramInstruction = async ( instructionIndex: number, - instruction: TransactionInstruction, - user?: { blockchainUserId?: number; handle?: string | null } + instruction: TransactionInstruction ) => { const decodedInstruction = ClaimableTokensProgram.decodeInstruction(instruction) @@ -324,6 +323,24 @@ const assertAllowedSystemProgramInstruction = ( } } +const assertValidSecp256k1ProgramInstruction = ( + instructionIndex: number, + instruction: TransactionInstruction +) => { + try { + if ( + !Secp256k1Program.verifySignature(Secp256k1Program.decode(instruction)) + ) { + throw new Error('Signer does not match') + } + } catch (e) { + throw new InvalidRelayInstructionError( + instructionIndex, + 'Invalid Secp256k1Program instruction' + ) + } +} + /** * Checks each of the instructions to make sure it's something we want to relay. * The main goals of the checks are to ensure the feePayer isn't abused. @@ -368,11 +385,7 @@ export const assertRelayAllowedInstructions = async ( assertAllowedRewardsManagerProgramInstruction(i, instruction) break case CLAIMABLE_TOKEN_PROGRAM_ID: - await assertAllowedClaimableTokenProgramInstruction( - i, - instruction, - options?.user - ) + await assertAllowedClaimableTokenProgramInstruction(i, instruction) break case JUPITER_AGGREGATOR_V6_PROGRAM_ID: await assertAllowedJupiterProgramInstruction( @@ -390,6 +403,8 @@ export const assertRelayAllowedInstructions = async ( ) break case Secp256k1Program.programId.toBase58(): + assertValidSecp256k1ProgramInstruction(i, instruction) + break case MEMO_PROGRAM_ID: case MEMO_V2_PROGRAM_ID: case TRACK_LISTEN_COUNT_PROGRAM_ID: diff --git a/packages/spl/package.json b/packages/spl/package.json index 35106451022..a5859c2923e 100644 --- a/packages/spl/package.json +++ b/packages/spl/package.json @@ -23,6 +23,7 @@ "homepage": "https://github.com/AudiusProject/audius-protocol/tree/main/packages/spl", "dependencies": { "@coral-xyz/anchor": "0.29.0", + "@noble/hashes": "1.4.0", "@solana/buffer-layout": "4.0.1", "@solana/buffer-layout-utils": "0.2.0", "@solana/spl-token": "0.3.8", diff --git a/packages/spl/src/index.ts b/packages/spl/src/index.ts index 636bc48d52b..89cf1805fa6 100644 --- a/packages/spl/src/index.ts +++ b/packages/spl/src/index.ts @@ -1,6 +1,7 @@ export { ClaimableTokensProgram } from './claimable-tokens/ClaimableTokensProgram' export { RewardManagerInstruction } from './reward-manager/constants' export { RewardManagerProgram } from './reward-manager/RewardManagerProgram' +export { Secp256k1Program } from './secp256k1/Secp256k1Program' export { ethAddress } from './layout-utils' export * from './associated-token' export * from './payment-router' diff --git a/packages/spl/src/reward-manager/RewardManagerProgram.test.ts b/packages/spl/src/reward-manager/RewardManagerProgram.test.ts index 474780d568d..720d86f1c51 100644 --- a/packages/spl/src/reward-manager/RewardManagerProgram.test.ts +++ b/packages/spl/src/reward-manager/RewardManagerProgram.test.ts @@ -85,7 +85,7 @@ describe('RewardManagerProgram', () => { expect(attestation.antiAbuseOracleEthAddress).toBe(null) }) - it('decodes the account data', () => { + it('decodes the account data of a single attestation', () => { const data = Buffer.from([ 1, 182, 193, 28, 253, 102, 169, 6, 208, 160, 135, 219, 13, 183, 183, 115, 130, 16, 205, 49, 82, 187, 88, 76, 117, 96, 175, 210, 205, 23, 16, 17, 91, @@ -138,6 +138,106 @@ describe('RewardManagerProgram', () => { expect(decoded.messages.length === 1) }) + it('decodes the account data of three attestations', () => { + const data = Buffer.from([ + 1, 89, 83, 235, 128, 55, 250, 76, 101, 233, 208, 198, 87, 196, 72, 83, + 209, 60, 0, 168, 125, 185, 129, 114, 121, 85, 236, 215, 187, 200, 142, + 245, 203, 3, 242, 137, 121, 147, 149, 29, 83, 167, 227, 235, 34, 66, 214, + 161, 77, 32, 40, 20, 13, 200, 38, 3, 48, 178, 184, 224, 69, 105, 57, 71, + 185, 90, 101, 197, 87, 24, 61, 0, 57, 125, 95, 0, 225, 245, 5, 0, 0, 0, 0, + 95, 98, 58, 49, 48, 54, 56, 55, 57, 61, 62, 49, 48, 48, 52, 54, 53, 48, + 52, 49, 55, 95, 152, 17, 186, 62, 171, 31, 44, 217, 162, 223, 237, 177, + 158, 140, 42, 105, 114, 157, 200, 182, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 229, 178, 86, + 211, 2, 234, 47, 78, 4, 184, 243, 191, 216, 105, 90, 222, 20, 122, 182, + 141, 152, 17, 186, 62, 171, 31, 44, 217, 162, 223, 237, 177, 158, 140, 42, + 105, 114, 157, 200, 182, 38, 3, 48, 178, 184, 224, 69, 105, 57, 71, 185, + 90, 101, 197, 87, 24, 61, 0, 57, 125, 95, 0, 225, 245, 5, 0, 0, 0, 0, 95, + 98, 58, 49, 48, 54, 56, 55, 57, 61, 62, 49, 48, 48, 52, 54, 53, 48, 52, + 49, 55, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 152, 17, 186, 62, 171, 31, 44, 217, 162, 223, 237, 177, + 158, 140, 42, 105, 114, 157, 200, 182, 44, 214, 106, 57, 49, 195, 101, + 150, 239, 176, 55, 176, 103, 83, 71, 109, 206, 107, 78, 134, 38, 3, 48, + 178, 184, 224, 69, 105, 57, 71, 185, 90, 101, 197, 87, 24, 61, 0, 57, 125, + 95, 0, 225, 245, 5, 0, 0, 0, 0, 95, 98, 58, 49, 48, 54, 56, 55, 57, 61, + 62, 49, 48, 48, 52, 54, 53, 48, 52, 49, 55, 95, 152, 17, 186, 62, 171, 31, + 44, 217, 162, 223, 237, 177, 158, 140, 42, 105, 114, 157, 200, 182, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 100, 112, 218, 243, 189, 50, 245, 1, 69, 18, 188, 223, 13, + 2, 35, 47, 86, 64, 165, 189, 152, 17, 186, 62, 171, 31, 44, 217, 162, 223, + 237, 177, 158, 140, 42, 105, 114, 157, 200, 182, 38, 3, 48, 178, 184, 224, + 69, 105, 57, 71, 185, 90, 101, 197, 87, 24, 61, 0, 57, 125, 95, 0, 225, + 245, 5, 0, 0, 0, 0, 95, 98, 58, 49, 48, 54, 56, 55, 57, 61, 62, 49, 48, + 48, 52, 54, 53, 48, 52, 49, 55, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 152, 17, 186, 62, 171, 31, 44, + 217, 162, 223, 237, 177, 158, 140, 42, 105, 114, 157, 200, 182 + ]) + const decoded = RewardManagerProgram.decodeAttestationsAccountData(data) + expect(decoded.version).toBe(1) + expect(decoded.rewardManagerState.toBase58()).toBe( + '71hWFVYokLaN1PNYzTAWi13EfJ7Xt9VbSWUKsXUT8mxE' + ) + expect(decoded.count).toBe(3) + + // Check message 0: DN Attestation + expect(decoded.messages[0].senderEthAddress).toBe( + '0xf2897993951d53a7e3eb2242d6a14d2028140dc8' + ) + expect(decoded.messages[0].attestation.recipientEthAddress).toBe( + '0x260330b2b8e045693947b95a65c557183d00397d' + ) + expect(decoded.messages[0].attestation.amount).toBe(BigInt(100000000)) + expect(decoded.messages[0].attestation.disbursementId).toBe( + 'b:106879=>1004650417' + ) + expect(decoded.messages[0].attestation.antiAbuseOracleEthAddress).toBe( + '0x9811ba3eab1f2cd9a2dfedb19e8c2a69729dc8b6' + ) + expect(decoded.messages[0].operator).toBe( + '0xe5b256d302ea2f4e04b8f3bfd8695ade147ab68d' + ) + + // Check message 1: Oracle Attestation + expect(decoded.messages[1].senderEthAddress).toBe( + '0x9811ba3eab1f2cd9a2dfedb19e8c2a69729dc8b6' + ) + expect(decoded.messages[1].attestation.recipientEthAddress).toBe( + '0x260330b2b8e045693947b95a65c557183d00397d' + ) + expect(decoded.messages[1].attestation.amount).toBe(BigInt(100000000)) + expect(decoded.messages[1].attestation.disbursementId).toBe( + 'b:106879=>1004650417' + ) + expect(decoded.messages[1].attestation.antiAbuseOracleEthAddress).toBe(null) + expect(decoded.messages[1].operator).toBe( + '0x9811ba3eab1f2cd9a2dfedb19e8c2a69729dc8b6' + ) + + // Check message 2: DN Attestation + expect(decoded.messages[2].senderEthAddress).toBe( + '0x2cd66a3931c36596efb037b06753476dce6b4e86' + ) + expect(decoded.messages[2].attestation.recipientEthAddress).toBe( + '0x260330b2b8e045693947b95a65c557183d00397d' + ) + expect(decoded.messages[2].attestation.amount).toBe(BigInt(100000000)) + expect(decoded.messages[2].attestation.disbursementId).toBe( + 'b:106879=>1004650417' + ) + expect(decoded.messages[2].attestation.antiAbuseOracleEthAddress).toBe( + '0x9811ba3eab1f2cd9a2dfedb19e8c2a69729dc8b6' + ) + expect(decoded.messages[2].operator).toBe( + '0x6470daf3bd32f5014512bcdf0d02232f5640a5bd' + ) + }) + it('encodes the evaluate attestation instruction data', () => { const mockPubkey = new PublicKey( '7c7wdSMAvswavryV6d9knEskoptUx919F2bLFYPrffqQ' diff --git a/packages/spl/src/reward-manager/RewardManagerProgram.ts b/packages/spl/src/reward-manager/RewardManagerProgram.ts index 73bad7ae129..2237ff8aba3 100644 --- a/packages/spl/src/reward-manager/RewardManagerProgram.ts +++ b/packages/spl/src/reward-manager/RewardManagerProgram.ts @@ -84,7 +84,7 @@ export class RewardManagerProgram { seq( struct([ ethAddress('senderEthAddress'), - attestationLayout('message'), + attestationLayout('attestation'), // Though the actual attestation message is only 83 bytes, we allocate // 128 bytes for each element of this array on the program side. // Thus we add 45 bytes of padding here to be consistent. @@ -508,7 +508,7 @@ export class RewardManagerProgram { public static decodeAttestationsAccountData(data: Buffer | Uint8Array) { const decoded = this.layouts.attestationsAccountData.decode(data) - // decoded.messages = decoded.messages.filter((m) => m.index !== 0) + decoded.messages = decoded.messages.slice(0, decoded.count) for (let i = 0; i < decoded.messages.length; i++) { if ( decoded.messages[i].attestation.antiAbuseOracleEthAddress === diff --git a/packages/spl/src/secp256k1/Secp256k1Program.test.ts b/packages/spl/src/secp256k1/Secp256k1Program.test.ts new file mode 100644 index 00000000000..14937aa1f62 --- /dev/null +++ b/packages/spl/src/secp256k1/Secp256k1Program.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' + +import { Secp256k1Program } from './Secp256k1Program' + +describe('Secp256k1Program', () => { + it('returns true when signature is valid and matches instruction', () => { + // Data from a valid submitAttestation SECP instruction + const validSignature = + '012000000c000061004500008fcfa10bd3808570987dbb5b1ef4ab74400fbfdaf89b2e6f97f95f1306b468b10b1a18df9569b07d9d7b81b241d6fc99d9ec782e4e449f5c3c63836ed52c9344d3de5c3133fead711e421af545822f09bd78cb390068d5397bb16195ea47091010f3abb8fc6b5cdfa65f00e1f505000000005f623a33383639383d3e3530373431303135335f00b6462e955da5841b6d9e1e2529b830f00f31bf' + expect( + Secp256k1Program.verifySignature( + Secp256k1Program.decode(Buffer.from(validSignature, 'hex')) + ) + ) + }) + + it('throws when the signature recovery is off the curve', () => { + // Data from an invalid submitAttestation SECP instruction (malformed signature) + const invalidSignature = + '012000000c0000610030000000b6462e955da5841b6d9e1e2529b830f00f31bf00d405b277dc948f97d7b7db8648cb16590d66084ba49642fedb08380ce5027a95d0a895287a3331332e7ad13daba87eed5c70820a19ca2eb6cc0ea1eb4695ba0081729dc83c157f41de7df4b72fc7e90d8d64d5aa5f00e1f505000000005f72656665727265643a353339343735333137' + expect(() => + Secp256k1Program.verifySignature( + Secp256k1Program.decode(Buffer.from(invalidSignature, 'hex')) + ) + ).toThrow() + }) +}) diff --git a/packages/spl/src/secp256k1/Secp256k1Program.ts b/packages/spl/src/secp256k1/Secp256k1Program.ts new file mode 100644 index 00000000000..9a8ab110bea --- /dev/null +++ b/packages/spl/src/secp256k1/Secp256k1Program.ts @@ -0,0 +1,106 @@ +import { keccak_256 } from '@noble/hashes/sha3' +import * as secp from '@noble/secp256k1' +import { struct, u8, u16, blob } from '@solana/buffer-layout' +import { + Secp256k1Program as BaseSecp256k1Program, + TransactionInstruction +} from '@solana/web3.js' + +/** + * The layout of Secp256k1 instruction data. Copied from @solana/web3.js because + * it isn't exported there. + * + * @see {@link https://github.com/solana-labs/solana-web3.js/blob/d0d4d3e4d96f4fc7a4a9adf24e189be60183f460/packages/library-legacy/src/programs/secp256k1.ts#L47 SECP256K1_INSTRUCTION_LAYOUT} + */ +const SECP256K1_INSTRUCTION_LAYOUT = struct< + Readonly<{ + ethAddress: Uint8Array + ethAddressInstructionIndex: number + ethAddressOffset: number + messageDataOffset: number + messageDataSize: number + messageInstructionIndex: number + numSignatures: number + recoveryId: number + signature: Uint8Array + signatureInstructionIndex: number + signatureOffset: number + }> +>([ + u8('numSignatures'), + u16('signatureOffset'), + u8('signatureInstructionIndex'), + u16('ethAddressOffset'), + u8('ethAddressInstructionIndex'), + u16('messageDataOffset'), + u16('messageDataSize'), + u8('messageInstructionIndex'), + blob(20, 'ethAddress'), + blob(64, 'signature'), + u8('recoveryId') +]) + +type DecodedSecp256k1Instruction = ReturnType + +/** + * Extends the @solana/web3.js Secp256k1Program API with a decode method + * and other useful utilities. + */ +export class Secp256k1Program extends BaseSecp256k1Program { + /** + * Decodes an Secp256k1 instruction data into a Typescript object. + * Useful for debugging. + */ + static decode(instructionOrData: TransactionInstruction | Uint8Array) { + const data = + instructionOrData instanceof TransactionInstruction + ? instructionOrData.data + : instructionOrData + const decoded = SECP256K1_INSTRUCTION_LAYOUT.decode(data) + const message = data.subarray( + decoded.messageDataOffset, + decoded.messageDataOffset + decoded.messageDataSize + ) + return { + ...decoded, + message + } + } + + /** + * Creates an Ethereum address from a secp256k1 public key. + * + * Port of the secp256k1 program's Rust code. + * @see {@link https://github.com/solana-labs/solana/blob/27eff8408b7223bb3c4ab70523f8a8dca3ca6645/sdk/src/secp256k1_instruction.rs#L906C1-L914C2 construct_eth_pubkey} + */ + static constructEthPubkey(pubkey: Uint8Array) { + return keccak_256(Buffer.from(pubkey.subarray(1))).subarray(12) + } + + /** + * Recovers the true signer for a decoded instruction. + */ + static recoverSigner(decoded: DecodedSecp256k1Instruction) { + const messageHash = keccak_256(decoded.message) + return secp.recoverPublicKey( + messageHash, + decoded.signature, + decoded.recoveryId + ) + } + + /** + * Verifies the true signer for a decoded instruction matches the one + * in the instruction data. + */ + static verifySignature(decoded: DecodedSecp256k1Instruction) { + const signer = Secp256k1Program.recoverSigner(decoded) + const address = Secp256k1Program.constructEthPubkey(signer) + for (let i = 0; i < address.length; i++) { + if (address.at(i) !== decoded.ethAddress.at(i)) { + return false + } + } + return address.byteLength === decoded.ethAddress.byteLength + } +}