diff --git a/src/transaction/__tests__/transaction-erc20.test.ts b/src/transaction/__tests__/transaction-erc20.test.ts index d5a83612..5cab371f 100644 --- a/src/transaction/__tests__/transaction-erc20.test.ts +++ b/src/transaction/__tests__/transaction-erc20.test.ts @@ -37,7 +37,7 @@ import { type ExternalSignerConnector, HardwareWallet } from '../../wallet/hardw import { RailgunWallet } from '../../wallet/railgun-wallet'; import { config } from '../../test/config.test'; import { hashBoundParamsV2, hashBoundParamsV3 } from '../bound-params'; -import { TXIDVersion } from '../../models'; +import { Proof, TXIDVersion, UnprovedTransactionInputs } from '../../models'; import WalletInfo from '../../wallet/wallet-info'; import { TransactionBatch } from '../transaction-batch'; import { getTokenDataERC20, getTokenDataHashERC20 } from '../../note/note-util'; @@ -1140,6 +1140,154 @@ describe('transaction-erc20', function test() { expect(txs2[0].nullifiers.length).to.equal(1); }); + it('Should reject tampered proofs and tampered public inputs in verifyRailgunProof', async () => { + transactionBatch.addOutput(await makeNote(1n)); + + const spendingSolutionGroups = + await transactionBatch.generateValidSpendingSolutionGroupsAllOutputs(wallet, txidVersion); + expect(spendingSolutionGroups.length).to.equal(1); + + const tx = transactionBatch.generateTransactionForSpendingSolutionGroup( + spendingSolutionGroups[0], + TransactionBatch.getChangeOutput(wallet, spendingSolutionGroups[0]), + ); + + const globalBoundParams: PoseidonMerkleVerifier.GlobalBoundParamsStruct = { + minGasPrice: 0n, + chainID: chain.id, + senderCiphertext: '0x', + to: ZERO_ADDRESS, + data: '0x', + }; + + const { publicInputs, privateInputs, boundParams } = await tx.generateTransactionRequest( + wallet, + txidVersion, + testEncryptionKey, + globalBoundParams, + ); + + const signature = await wallet.sign(publicInputs, testEncryptionKey); + const signatureArray: [bigint, bigint, bigint] = [ + signature.R8[0], + signature.R8[1], + signature.S, + ]; + const unprovedTransactionInputs: UnprovedTransactionInputs = + txidVersion === TXIDVersion.V2_PoseidonMerkle + ? { + txidVersion: TXIDVersion.V2_PoseidonMerkle, + privateInputs, + publicInputs, + boundParams: boundParams as BoundParamsStruct, + signature: signatureArray, + } + : { + txidVersion: TXIDVersion.V3_PoseidonMerkle, + privateInputs, + publicInputs, + boundParams: boundParams as PoseidonMerkleVerifier.BoundParamsStruct, + signature: signatureArray, + }; + + const { proof } = await prover.proveRailgun( + txidVersion, + unprovedTransactionInputs, + () => {}, + ); + + const artifacts = await testArtifactsGetter.getArtifacts(publicInputs); + + // A bit-flipped proof must not verify. The modified coordinate almost certainly + // isn't on the curve, so snarkjs may either return false OR throw during + // deserialization — both count as rejection. + const verifyRejects = async ( + tamperedInputs: typeof publicInputs, + tamperedProof: Proof, + ): Promise => { + try { + return !(await prover.verifyRailgunProof(tamperedInputs, tamperedProof, artifacts)); + } catch { + return true; + } + }; + + // Sanity check: legitimate proof verifies. + expect(await prover.verifyRailgunProof(publicInputs, proof, artifacts)).to.equal(true); + + // Flip the lowest bit of pi_a.x. + const bitFlippedProof: Proof = JSON.parse(JSON.stringify(proof)); + const piAxFlipped = BigInt(bitFlippedProof.pi_a[0]) ^ 1n; + bitFlippedProof.pi_a[0] = `0x${piAxFlipped.toString(16)}`; + expect(await verifyRejects(publicInputs, bitFlippedProof)).to.equal(true); + + // Neutral element (point at infinity). A buggy verifier might accept this + // because e(O, π_b) = e(π_a, O) = 1, leading to a trivial 1 == 1 check. + // Test both common encodings: all-zero affine and projective (0, 1, 0). + const zeroProof: Proof = { + pi_a: ['0x0', '0x0'], + pi_b: [ + ['0x0', '0x0'], + ['0x0', '0x0'], + ], + pi_c: ['0x0', '0x0'], + }; + expect(await verifyRejects(publicInputs, zeroProof)).to.equal(true); + + const projectiveIdentityProof = { + pi_a: ['0x0', '0x1', '0x0'], + pi_b: [ + ['0x0', '0x0'], + ['0x1', '0x0'], + ['0x0', '0x0'], + ], + pi_c: ['0x0', '0x1', '0x0'], + } as unknown as Proof; + expect(await verifyRejects(publicInputs, projectiveIdentityProof)).to.equal(true); + + // Trivial proof: just the BN254 generators for G1 (π_a, π_c) and G2 (π_b). + // Every coordinate is on-curve and in-subgroup, so snarkjs runs the full + // pairing check and must return false (no throw) for a proof that proves nothing. + const trivialGeneratorProof: Proof = { + pi_a: ['0x1', '0x2'], + pi_b: [ + [ + '0x198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2', + '0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed', + ], + [ + '0x090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b', + '0x12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa', + ], + ], + pi_c: ['0x1', '0x2'], + }; + expect( + await prover.verifyRailgunProof(publicInputs, trivialGeneratorProof, artifacts), + ).to.equal(false); + + // Each public input is bound to the proof — tampering any one must fail verification. + const tamperedMerkleRoot = { ...publicInputs, merkleRoot: publicInputs.merkleRoot + 1n }; + expect(await prover.verifyRailgunProof(tamperedMerkleRoot, proof, artifacts)).to.equal(false); + + const tamperedBoundParamsHash = { ...publicInputs, boundParamsHash: 0n }; + expect( + await prover.verifyRailgunProof(tamperedBoundParamsHash, proof, artifacts), + ).to.equal(false); + + const tamperedNullifiers = { + ...publicInputs, + nullifiers: publicInputs.nullifiers.map((n) => n + 1n), + }; + expect(await prover.verifyRailgunProof(tamperedNullifiers, proof, artifacts)).to.equal(false); + + const tamperedCommitments = { + ...publicInputs, + commitmentsOut: publicInputs.commitmentsOut.map((c) => c + 1n), + }; + expect(await prover.verifyRailgunProof(tamperedCommitments, proof, artifacts)).to.equal(false); + }); + it('Should test transaction proof progress callback final value', async () => { transactionBatch.addOutput(await makeNote(1n)); let loadProgress = 0;