Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 149 additions & 1 deletion src/transaction/__tests__/transaction-erc20.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean> => {
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;
Expand Down