diff --git a/packages/bitcore-wallet-client/src/lib/verifier.ts b/packages/bitcore-wallet-client/src/lib/verifier.ts index c81cc8dcc5..549fb9788e 100644 --- a/packages/bitcore-wallet-client/src/lib/verifier.ts +++ b/packages/bitcore-wallet-client/src/lib/verifier.ts @@ -1,7 +1,11 @@ import { BitcoreLib as Bitcore, BitcoreLibCash, - Utils as CWCUtils + BitcoreLibDoge, + BitcoreLibLtc, + Utils as CWCUtils, + Validation as CWCValidation, + Web3 } from '@bitpay-labs/crypto-wallet-core'; import { singleton } from 'preconditions'; import { Constants, Utils } from './common'; @@ -10,7 +14,34 @@ import log from './log'; import type { Address } from '../types/address'; const $ = singleton(); -const BCHAddress = BitcoreLibCash.Address; + +// Each supported multisig/UTXO chain canonicalizes destinations through its +// own bitcore-lib fork's Address class rather than raw string/lowercase +// comparison, so equivalent encodings (BTC/LTC Bech32 case, LTC legacy +// `3...`/modern `M...` P2SH, BCH cashaddr/legacy) compare equal while +// unparseable addresses throw and are treated as a verification failure +// instead of matching merely because their raw strings match. +const ADDRESS_LIB_BY_CHAIN: Record { toString(): string } }> = { + btc: Bitcore, + bch: BitcoreLibCash, + doge: BitcoreLibDoge, + ltc: BitcoreLibLtc +}; + +interface PayproEntry { + toAddress: string; + amount: bigint; + /** The original output/instruction object, for chain-family fields beyond address+amount (EVM calldata, etc). */ + raw: any; +} + +type PayproEntriesNormalizationResult = + | { valid: true; entries: PayproEntry[] } + | { + valid: false; + invalidEntryIndex: number; + invalidField: 'destination address' | 'amount'; + }; /** * @desc Verifier constructor. Checks data given by the server @@ -50,6 +81,13 @@ export class Verifier { return this.atomicValuesEqual(value1, value2); } + private static optionalStringsEqual(value1: any, value2: any): boolean { + const value1Missing = value1 == null; + const value2Missing = value2 == null; + if (value1Missing || value2Missing) return value1Missing && value2Missing; + return typeof value1 === 'string' && typeof value2 === 'string' && value1 === value2; + } + private static mapInputsByOutpoint(inputs) { if (!Array.isArray(inputs)) return null; @@ -238,6 +276,23 @@ export class Verifier { return true; } + /** + * Check transaction proposal + * + * Confirms tx proposal signature, and if paypro included in ops, confirms it + * + * @param {Function} credentials + * @param {Object} txp + * @param {Object} Optional: paypro + * @param {Boolean} isLegit + */ + static checkTxProposal(credentials, txp, opts) { + opts = opts || {}; + + return this.checkTxProposalSignature(credentials, txp) && + (!opts.paypro || this.checkPaypro(txp, opts.paypro)); + } + static checkTxProposalSignature(credentials, txp) { $.checkArgument(txp.creatorId, 'Invalid txp: Missing creatorId'); $.checkState(credentials.isComplete(), 'Failed state: credentials at checkTxProposalSignature'); @@ -305,53 +360,372 @@ export class Verifier { return true; } + /** + * Checks a PayPro-funded transaction proposal against the signed PayPro + * response it was supposed to pay. Should never be used to check multiTx + * or multiSendContractAddress txps + * + * `txp` is sourced from BWS and is untrusted - a compromised server or a + * malicious co-signer could have altered it. `payproOpts` is derived from + * a directly-verified, signed PayPro response and is treated as ground + * truth. Any mismatch between them is therefore presumed tampering, not a + * benign difference, and the default outcome on any doubt is rejection. + * + * The general rule: compare exactly whatever determines what the merchant + * receives or how the payment is attributed, for whichever chain family + * the proposal resolves to, and explicitly exclude fields that only affect + * fees or transport (EVM gas price/limit, UTXO input selection and + * change). Destination address and amount alone are not sufficient once a + * chain's instructions carry payment meaning outside those two fields - + * most notably, ERC-20 (and other token) instructions typically show a + * visible `amount` of `0`, with the real recipient and amount encoded in + * calldata, which would make destination+amount alone a vacuous check. + */ static checkPaypro(txp, payproOpts) { - let toAddress, amount; + const falseWithLogWarn = (reason: string): false => { + const txpId = txp && typeof txp === 'object' && typeof txp.id === 'string' + ? txp.id + : 'unknown'; + log.warn(`[TXP ${txpId}] PayPro verification failed: ${reason}`); + return false; + }; - if (parseInt(txp.version) >= 3) { - toAddress = txp.outputs[0].toAddress; - amount = txp.amount; + // Validate and normalize the complete proposal and invoice before comparing them. + if (!txp || typeof txp !== 'object') return falseWithLogWarn('missing transaction proposal'); + if (!payproOpts || typeof payproOpts !== 'object') return falseWithLogWarn('missing PayPro data'); + if (!Array.isArray(payproOpts.instructions) || payproOpts.instructions.length === 0) { + return falseWithLogWarn('missing PayPro instructions'); + } + + let chain: string; + if (txp.chain == null || txp.chain === '') { + if (typeof txp.coin !== 'string' || txp.coin === '') { + return falseWithLogWarn('missing transaction chain'); + } + const coin = txp.coin.toLowerCase(); + // Cannot fallback to 'eth' like Utils.getChain() + if (Constants.BITPAY_SUPPORTED_ETH_ERC20.includes(coin)) { + chain = 'eth'; + } else if (Constants.CHAINS.includes(coin)) { + chain = coin; + } else { + return falseWithLogWarn(`missing transaction chain for coin: ${txp.coin}`); + } } else { - toAddress = txp.toAddress; - amount = txp.amount; + if (typeof txp.chain !== 'string') return falseWithLogWarn('invalid transaction chain'); + chain = txp.chain.toLowerCase(); } - if (amount != (payproOpts.instructions || []).reduce((sum, i) => sum += i.amount, 0)) return false; + const isUtxoChain = Constants.UTXO_CHAINS.includes(chain); + const isEvmChain = Constants.EVM_CHAINS.includes(chain); + const isRippleChain = Constants.RIPPLE_CHAINS.includes(chain); + const isSvmChain = Constants.SVM_CHAINS.includes(chain); + if (!(isUtxoChain || isEvmChain || isRippleChain || isSvmChain)) { + return falseWithLogWarn(`unsupported transaction chain: ${chain}`); + } - if (txp.coin == 'btc' && toAddress != payproOpts.instructions[0].toAddress) - return false; + // payproOpts chain/network/currency optional - validate if present + if (payproOpts.chain != null) { + // If payproOpts.chain present + // must be a string in agreement with chain derived above + if (typeof payproOpts.chain !== 'string' || payproOpts.chain.toLowerCase() !== chain) { + return falseWithLogWarn('signed PayPro chain does not match transaction chain'); + } + } + if (payproOpts.network != null) { + // If payproOpts.network present + // must be a string, txp.network must also be a string, and they must match (case-insensitive) + if ( + typeof payproOpts.network !== 'string' || + typeof txp.network !== 'string' || + payproOpts.network.toLowerCase() !== txp.network.toLowerCase() + ) { + return falseWithLogWarn('signed PayPro network does not match transaction network'); + } + } + if (payproOpts.currency != null) { + // If payproOpts.currency present + // must be a string & must match converted txp.coin + if (typeof payproOpts.currency !== 'string' || typeof txp.coin !== 'string') { + return falseWithLogWarn('signed PayPro currency does not match transaction currency'); + } + let expectedCurrency = Utils.getCurrencyCodeFromCoinAndChain(txp.coin, chain); + // PayProV2.selectPaymentOption rewrites an outgoing 'USDP' request to + // 'PAX' before it reaches the PayPro server, so a real signed response + // for a `coin: 'usdp'` proposal carries currency 'PAX', not 'USDP'. + if (expectedCurrency === 'USDP') expectedCurrency = 'PAX'; + if (payproOpts.currency !== expectedCurrency) { + return falseWithLogWarn('signed PayPro currency does not match transaction currency'); + } + } - // Workaround for cashaddr/legacy address problems... + // txp.version snapshot + const versionValue = txp.version; if ( - txp.coin == 'bch' && - new BCHAddress(toAddress).toString() != - new BCHAddress(payproOpts.instructions[0].toAddress).toString() - ) - return false; + typeof versionValue !== 'number' && + typeof versionValue !== 'string' + ) return falseWithLogWarn('invalid transaction proposal version'); + const version = Number(versionValue); + if (!Number.isInteger(version) || version < 1) { + return falseWithLogWarn('invalid transaction proposal version'); + } + + let rawOutputs = version >= 3 + ? txp.outputs + : [{ toAddress: txp.toAddress, amount: txp.amount }]; + if (!Array.isArray(rawOutputs) || rawOutputs.length === 0) { + return falseWithLogWarn('missing transaction outputs'); + } + + // Utils.buildTx() still honors a legacy BWC <= 8.9.0 compatibility + // field, top-level `txp.data`, by overwriting outputs[0].data with it + // right before signing. Fold that same override into outputs[0] here, + // on a local copy, so the calldata comparison below judges the value + // that will actually be signed rather than the pre-override one - + // otherwise a matching outputs[0].data could pass verification while a + // divergent top-level txp.data silently changes what gets signed. This + // also means only an *identical* legacy value can still pass; a + // disagreeing one now reads as a plain calldata mismatch. + if (isEvmChain && txp.data) { + rawOutputs = [{ ...rawOutputs[0], data: txp.data }, ...rawOutputs.slice(1)]; + } + + const normalizedOutputs = this.normalizePayproEntries(rawOutputs); + if (normalizedOutputs.valid === false) { + return falseWithLogWarn( + `transaction output at index ${normalizedOutputs.invalidEntryIndex} ` + + `has an invalid ${normalizedOutputs.invalidField}` + ); + } + const outputs = normalizedOutputs.entries; + + const normalizedPayproInstructions = this.normalizePayproEntries(payproOpts.instructions); + if (normalizedPayproInstructions.valid === false) { + return falseWithLogWarn( + `PayPro instruction at index ${normalizedPayproInstructions.invalidEntryIndex} ` + + `has an invalid ${normalizedPayproInstructions.invalidField}` + ); + } + const payproInstructions = normalizedPayproInstructions.entries; + + if (outputs.length !== payproInstructions.length) { + return falseWithLogWarn('transaction output and PayPro instruction counts differ'); + } + + const txpAmount = this.normalizeAtomicValue(txp.amount); + if (txpAmount === null) return falseWithLogWarn('invalid transaction amount'); + const instructionTotal = payproInstructions.reduce((total, entry) => total + entry.amount, 0n); + if (txpAmount !== instructionTotal) { + return falseWithLogWarn('transaction and PayPro instruction amounts differ'); + } + const outputTotal = outputs.reduce((total, entry) => total + entry.amount, 0n); + if (txpAmount !== outputTotal) { + return falseWithLogWarn('transaction amount and output total differ'); + } // this generates problems... // if (feeRate && payproOpts.requiredFeeRate && // feeRate < payproOpts.requiredFeeRate) // return false; - return true; + // Accept only a complete match for the resolved chain, comparing each + // chain family's own canonical parsed destination plus whatever else in + // that family determines what the merchant receives or how the payment + // is attributed - never raw/lowercased strings, and never destination + + // amount alone once calldata/tag/memo can carry payment meaning. UTXO + // outputs are compared as an order-independent, duplicate-safe multiset + // (change/input selection reorders them); account-chain entries are + // compared in order, since e.g. an ERC-20 approve+pay sequence is not + // the same transaction with its two calls swapped. + try { + if (isUtxoChain) { + const addressLib = ADDRESS_LIB_BY_CHAIN[chain]; + const normalizeAddress = (address: string) => new addressLib.Address(address).toString(); + if (this.payproEntrySetsMatch(outputs, payproInstructions, normalizeAddress)) return true; + return falseWithLogWarn(`${chain.toUpperCase()} outputs do not match PayPro instructions`); + } + + if (isEvmChain) { + const compareCalldata = (output: PayproEntry, instruction: PayproEntry) => + this.normalizeEvmCalldata(output.raw?.data) === this.normalizeEvmCalldata(instruction.raw?.data); + if (this.accountEntriesMatch(outputs, payproInstructions, this.normalizeEvmAddress, compareCalldata)) { + return true; + } + return falseWithLogWarn(`${chain.toUpperCase()} outputs do not match PayPro instructions`); + } + + if (isRippleChain) { + if ( + this.accountEntriesMatch(outputs, payproInstructions, this.normalizeRippleAddress) && + this.ripplePaymentDetailsMatch(txp, payproInstructions[0]?.raw) + ) { + return true; + } + return falseWithLogWarn('XRP outputs do not match PayPro instructions'); + } + + // isSvmChain + if ( + this.accountEntriesMatch(outputs, payproInstructions, this.normalizeSolAddress) && + this.solPaymentDetailsMatch(txp, payproInstructions[0]?.raw) + ) { + return true; + } + return falseWithLogWarn('SOL outputs do not match PayPro instructions'); + } catch { + return falseWithLogWarn(`invalid ${chain.toUpperCase()} address or instruction data`); + } } /** - * Check transaction proposal - * - * @param {Function} credentials - * @param {Object} txp - * @param {Object} Optional: paypro - * @param {Boolean} isLegit + * Returns normalized entries or identifies the index and field of the first invalid entry. */ - static checkTxProposal(credentials, txp, opts) { - opts = opts || {}; + private static normalizePayproEntries(entries: any[]): PayproEntriesNormalizationResult { + const normalizedEntries: PayproEntry[] = []; + for (const [index, entry] of entries.entries()) { + if (typeof entry?.toAddress !== 'string' || entry.toAddress.trim() === '') { + return { + valid: false, + invalidEntryIndex: index, + invalidField: 'destination address' + }; + } - if (!this.checkTxProposalSignature(credentials, txp)) return false; + const amount = this.normalizeAtomicValue(entry.amount); + if (amount === null) { + return { + valid: false, + invalidEntryIndex: index, + invalidField: 'amount' + }; + } - if (opts.paypro && !this.checkPaypro(txp, opts.paypro)) return false; + normalizedEntries.push({ toAddress: entry.toAddress, amount, raw: entry }); + } + return { valid: true, entries: normalizedEntries }; + } + + /** + * Returns true if both args are empty arrays - should be handled upstream + */ + private static payproEntrySetsMatch( + outputs: PayproEntry[], + instructions: PayproEntry[], + normalizeAddress: (address: string) => string + ): boolean { + if (outputs.length !== instructions.length) return false; + + const entriesSortCompareFn = (entry1: { toAddress: string; amount: bigint }, entry2: { toAddress: string; amount: bigint }) => { + if (entry1.toAddress !== entry2.toAddress) { + return entry1.toAddress < entry2.toAddress ? -1 : 1; + } + if (entry1.amount === entry2.amount) return 0; + return entry1.amount < entry2.amount ? -1 : 1; + }; + const normalizeAndSort = (entries: PayproEntry[]) => entries + .map(entry => ({ + toAddress: normalizeAddress(entry.toAddress), + amount: entry.amount + })) + .sort(entriesSortCompareFn); + + const normalizedOutputs = normalizeAndSort(outputs); + const normalizedInstructions = normalizeAndSort(instructions); + return normalizedOutputs.every((output, index) => + output.toAddress === normalizedInstructions[index].toAddress && + output.amount === normalizedInstructions[index].amount + ); + } + /** + * Order-sensitive account-chain match: address and amount must agree at + * every index, plus any family-specific `compareExtra` field (e.g. EVM + * calldata). Unlike UTXO outputs, account-chain entries are an ordered + * sequence of calls/payments, so a reordering of otherwise-identical + * entries is a different transaction and must not compare equal. + */ + private static accountEntriesMatch( + outputs: PayproEntry[], + instructions: PayproEntry[], + normalizeAddress: (address: string) => string, + compareExtra?: (output: PayproEntry, instruction: PayproEntry, index: number) => boolean + ): boolean { + if (outputs.length !== instructions.length) return false; + for (let i = 0; i < outputs.length; i++) { + if (normalizeAddress(outputs[i].toAddress) !== normalizeAddress(instructions[i].toAddress)) return false; + if (outputs[i].amount !== instructions[i].amount) return false; + if (compareExtra && !compareExtra(outputs[i], instructions[i], i)) return false; + } return true; } + + /** + * Canonicalizes an EVM address via EIP-55 checksum validation. Accepts + * all-lowercase, all-uppercase, and correctly-checksummed mixed-case + * forms as equivalent; rejects an incorrectly-checksummed mixed-case + * address rather than silently accepting it. + */ + private static normalizeEvmAddress(address: string): string { + if (!Web3.utils.isAddress(address)) throw new Error('invalid EVM address'); + return Web3.utils.toChecksumAddress(address); + } + + /** + * Canonicalizes EVM calldata for comparison (case-insensitive hex), + * rejecting anything that isn't well-formed `0x`-prefixed hex so that two + * identical malformed strings don't compare equal merely by coincidence. + * `null`/`undefined` (no calldata) normalizes to `null`. + */ + private static normalizeEvmCalldata(data: any): string | null { + if (data == null) return null; + if (typeof data !== 'string' || !/^0x([0-9a-fA-F]{2})*$/.test(data)) { + throw new Error('invalid EVM calldata'); + } + return data.toLowerCase(); + } + + /** + * XRP addresses are case-sensitive base58check; there is no alternate + * encoding to normalize between, so this only validates and passes the + * address through unchanged. + */ + private static normalizeRippleAddress(address: string): string { + if (!CWCValidation.validateAddress('xrp', 'livenet', address)) throw new Error('invalid XRP address'); + return address; + } + + /** + * SOL addresses are case-sensitive base58; there is no alternate encoding + * to normalize between, so this only validates and passes the address + * through unchanged. + */ + private static normalizeSolAddress(address: string): string { + if (!CWCValidation.validateAddress('sol', 'livenet', address)) throw new Error('invalid SOL address'); + return address; + } + + /** + * XRP payment meaning isn't fully captured by destination+amount: the app + * copies the PayPro instruction's destination tag and invoice ID onto the + * top-level transaction proposal (`txp.destinationTag`/`txp.invoiceID`), + * and both route the payment to a specific account holder behind a shared + * XRP address. Compares those against the signed instruction's nested + * `outputs[0]` fields. + */ + private static ripplePaymentDetailsMatch(txp: any, signedInstruction: any): boolean { + const signedOutput = signedInstruction?.outputs?.[0]; + return this.optionalAtomicValuesEqual(txp.destinationTag, signedOutput?.destinationTag) && + this.optionalStringsEqual(txp.invoiceID, signedOutput?.invoiceID); + } + + /** + * SOL payment meaning isn't fully captured by destination+amount either: + * the app maps the PayPro instruction's `outputs[0].invoiceID` to + * `txp.memo`, which is serialized on-chain as a memo instruction. + */ + private static solPaymentDetailsMatch(txp: any, signedInstruction: any): boolean { + const signedOutput = signedInstruction?.outputs?.[0]; + return this.optionalStringsEqual(txp.memo, signedOutput?.invoiceID); + } + } diff --git a/packages/bitcore-wallet-client/test/api.test.ts b/packages/bitcore-wallet-client/test/api.test.ts index 71f20a20a4..c4cff6beaa 100644 --- a/packages/bitcore-wallet-client/test/api.test.ts +++ b/packages/bitcore-wallet-client/test/api.test.ts @@ -13,6 +13,7 @@ import * as CWC from '@bitpay-labs/crypto-wallet-core'; import BWS from '@bitpay-labs/bitcore-wallet-service'; import Client, { Credentials } from '../src'; import { Request } from '../src/lib/request'; +import { Verifier } from '../src/lib/verifier'; import { Utils } from '../src/lib/common'; import * as TestData from './data/testdata'; import { Errors } from '../src/lib/errors'; @@ -598,6 +599,44 @@ describe('client API', function() { '0xeb068504a817c80082520894a062a07a0a56beb2872b12f388f511d694626730870dd764300b800080018080' ]); }); + it('should build unsigned ERC-20 approve/pay transactions in PayPro instruction order', () => { + // The verifier compares txp.outputs against the signed PayPro + // instructions as logical arrays; this proves the security-relevant + // result - the actual unsigned transactions handed to the signer - + // also preserves that same approve-then-pay order and nonce spacing. + const erc20Body = JSON.parse(TestData.payProJsonV2Body.erc20); + const [approveInstruction, payInstruction] = erc20Body.instructions; + const from = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + const baseNonce = 5; + + const txp = { + version: 3, + chain: 'eth', + coin: 'usdc', + from, + payProUrl: erc20Body.paymentUrl, + amount: 0, + nonce: baseNonce, + gasPrice: 20000000000, + outputs: [ + { toAddress: approveInstruction.toAddress, amount: 0, data: approveInstruction.data, gasLimit: 60000 }, + { toAddress: payInstruction.toAddress, amount: 0, data: payInstruction.data, gasLimit: 120000 } + ] + }; + + const rawTxs = Utils.buildTx(txp).uncheckedSerialize(); + rawTxs.should.have.lengthOf(2); + + const [approveTx, payTx] = rawTxs.map(raw => CWC.ethers.Transaction.from(raw)); + + approveTx.to.toLowerCase().should.equal(approveInstruction.toAddress.toLowerCase()); + approveTx.data.toLowerCase().should.equal(approveInstruction.data.toLowerCase()); + approveTx.nonce.should.equal(baseNonce); + + payTx.to.toLowerCase().should.equal(payInstruction.toAddress.toLowerCase()); + payTx.data.toLowerCase().should.equal(payInstruction.data.toLowerCase()); + payTx.nonce.should.equal(baseNonce + 1); + }); it('should build a matic txp correctly', () => { const toAddress = '0xa062a07a0a56beb2872b12f388f511d694626730'; const key = new Key({ seedData: masterPrivateKey, seedType: 'extendedPrivateKey' }); @@ -5321,6 +5360,287 @@ describe('client API', function() { }); }); }); + + // Unlike 'Payment Protocol V2 account-chain boundary' below, this suite + // drives a genuinely signed ERC-20 PayPro fixture through the real + // getPayProV2 -> selectPaymentOption -> signature verification -> + // processResponse path, so it proves the *signed* instruction order + // survives to checkPaypro, not just that some ordering is enforced. + describe('signed account-chain instruction ordering', function() { + let savedTrustedKeys; + const erc20Body = JSON.parse(TestData.payProJsonV2Body.erc20); + const [approveInstruction, payInstruction] = erc20Body.instructions; + const approveOutput = { toAddress: approveInstruction.toAddress, amount: 0, data: approveInstruction.data }; + const payOutput = { toAddress: payInstruction.toAddress, amount: 0, data: payInstruction.data }; + const createUsdcTxp = outputs => ({ + id: 'txp-usdc-erc20-ordering', + version: 3, + chain: 'eth', + coin: 'usdc', + network: 'livenet', + amount: 0, + from: '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A', + outputs, + payProUrl: erc20Body.paymentUrl, + creatorId: 'creator' + }); + + beforeEach(function(done) { + savedTrustedKeys = Client.PayProV2.trustedKeys; + Client.PayProV2.trustedKeys = { + ...savedTrustedKeys, + [TestData.payProJsonV2TestKey.identity]: TestData.payProJsonV2TestKey.keyData + }; + mockRequest(TestData.payProJsonV2.erc20.body, TestData.payProJsonV2.erc20.headers); + // The wallet itself is a plain ETH wallet - BWS only registers a + // copayer's coin as a chain name (see Copayer.create's + // Constants.CHAINS check), never a token symbol. The token being + // spent (USDC) lives on the transaction proposal's own `coin` + // field below, not on the wallet. + helpers.createAndJoinWallet(clients, keys, 1, 1, { coin: 'eth', network: 'livenet' }, () => { + sandbox.stub(Verifier, 'checkTxProposalSignature').returns(true); + done(); + }); + }); + + afterEach(function() { + Client.PayProV2.trustedKeys = savedTrustedKeys; + }); + + it('accepts a BWS proposal whose approve/pay outputs match the signed PayPro instruction order', function(done) { + const selectPaymentOptionSpy = sandbox.spy(Client.PayProV2, 'selectPaymentOption'); + sandbox.stub(clients[0].request, 'get').resolves({ body: [createUsdcTxp([approveOutput, payOutput])] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + should.not.exist(err); + should.exist(txps); + txps.should.have.lengthOf(1); + sinon.assert.calledOnce(selectPaymentOptionSpy); + txps[0].outputs[0].toAddress.should.equal(approveOutput.toAddress); + txps[0].outputs[1].toAddress.should.equal(payOutput.toAddress); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('raises SERVER_COMPROMISED when BWS reverses the signed approve/pay order', function(done) { + sandbox.stub(clients[0].request, 'get').resolves({ body: [createUsdcTxp([payOutput, approveOutput])] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); + done(); + } catch (e) { + done(e); + } + }); + }); + }); + + // drives historical *native* ETH PayPro fixture + describe('signed native ETH instruction fidelity', function() { + let savedTrustedKeys; + const ethBody = JSON.parse(TestData.payProJsonV2Body.eth); + const [ethInstruction] = ethBody.instructions; + const createEthTxp = (overrides: any = {}) => ({ + id: 'txp-eth-native-fidelity', + version: 3, + chain: 'eth', + coin: 'eth', + network: 'livenet', + amount: ethInstruction.amount, + outputs: [{ toAddress: ethInstruction.toAddress, amount: ethInstruction.amount, data: ethInstruction.data }], + payProUrl: ethBody.paymentUrl, + creatorId: 'creator', + ...overrides + }); + + beforeEach(function(done) { + savedTrustedKeys = Client.PayProV2.trustedKeys; + Client.PayProV2.trustedKeys = { + ...savedTrustedKeys, + [TestData.payProJsonV2TestKey.identity]: TestData.payProJsonV2TestKey.keyData + }; + mockRequest(TestData.payProJsonV2.eth.body, TestData.payProJsonV2.eth.headers); + helpers.createAndJoinWallet(clients, keys, 1, 1, { coin: 'eth', network: 'livenet' }, () => { + sandbox.stub(Verifier, 'checkTxProposalSignature').returns(true); + done(); + }); + }); + + afterEach(function() { + Client.PayProV2.trustedKeys = savedTrustedKeys; + }); + + it('accepts a BWS proposal whose native ETH output matches the signed PayPro instruction', function(done) { + const selectPaymentOptionSpy = sandbox.spy(Client.PayProV2, 'selectPaymentOption'); + sandbox.stub(clients[0].request, 'get').resolves({ body: [createEthTxp()] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + should.not.exist(err); + should.exist(txps); + txps.should.have.lengthOf(1); + sinon.assert.calledOnce(selectPaymentOptionSpy); + txps[0].outputs[0].toAddress.should.equal(ethInstruction.toAddress); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('raises SERVER_COMPROMISED for a substituted native ETH destination', function(done) { + const substitutedAddress = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + const txp = createEthTxp({ + outputs: [{ toAddress: substitutedAddress, amount: ethInstruction.amount, data: ethInstruction.data }] + }); + sandbox.stub(clients[0].request, 'get').resolves({ body: [txp] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('raises SERVER_COMPROMISED when the proposal drops the signed invoice calldata', function(done) { + const txp = createEthTxp({ + outputs: [{ toAddress: ethInstruction.toAddress, amount: ethInstruction.amount }] + }); + sandbox.stub(clients[0].request, 'get').resolves({ body: [txp] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); + done(); + } catch (e) { + done(e); + } + }); + }); + }); + }); + + // `getPayProV2` and the txproposals response are stubbed directly, rather + // than driven through a fully signed PayProV2 fixture, because the point + // here is caller-boundary reachability, not instruction-shape fidelity: + // there's no UTXO condition between getTxProposals() and checkPaypro(), so + // an ETH proposal must reach the same SERVER_COMPROMISED gate a BTC one does. + describe('Payment Protocol V2 account-chain boundary', function() { + const amount = 10000; + const merchantAddress = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'; + const substitutedAddress = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + const paypro = { instructions: [{ toAddress: merchantAddress, amount }] }; + const createEthTxp = toAddress => ({ + id: 'txp-eth-paypro-boundary', + version: 3, + chain: 'eth', + coin: 'eth', + amount, + outputs: [{ toAddress, amount }], + payProUrl: 'https://bitpay.com/i/EthAccountChainBoundary', + creatorId: 'creator' + }); + let checkTxProposalSignatureStub, getPayProV2Stub, requestGetStub; + + beforeEach(function(done) { + helpers.createAndJoinWallet(clients, keys, 1, 1, { coin: 'eth' }, () => { + checkTxProposalSignatureStub = sandbox.stub(Verifier, 'checkTxProposalSignature').returns(true); + getPayProV2Stub = sandbox.stub(clients[0], 'getPayProV2').resolves(paypro); + done(); + }); + }); + + it('accepts a matching ETH PayPro proposal through getTxProposals', function(done) { + requestGetStub = sandbox.stub(clients[0].request, 'get').resolves({ body: [createEthTxp(merchantAddress)] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + should.not.exist(err); + sinon.assert.calledOnce(getPayProV2Stub); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('raises SERVER_COMPROMISED for a substituted ETH PayPro destination through getTxProposals', function(done) { + requestGetStub = sandbox.stub(clients[0].request, 'get').resolves({ body: [createEthTxp(substitutedAddress)] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); + done(); + } catch (e) { + done(e); + } + }); + }); + + // Same destination and amount, different calldata - proves the + // destination/amount check alone is not enough to catch a tampered + // EVM PayPro proposal at this caller boundary. + const evmData = '0xa9059cbb0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda94000000000000000000000000000000000000000000000000000000000000989680'; + const differentEvmData = '0xa9059cbb0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda940000000000000000000000000000000000000000000000000000000000000001'; + const dataPaypro = { instructions: [{ toAddress: merchantAddress, amount, to: merchantAddress, value: amount, data: evmData }] }; + const createEthTxpWithData = data => ({ + id: 'txp-eth-paypro-boundary-data', + version: 3, + chain: 'eth', + coin: 'eth', + amount, + outputs: [{ toAddress: merchantAddress, amount, data }], + payProUrl: 'https://bitpay.com/i/EthAccountChainBoundary', + creatorId: 'creator' + }); + + it('accepts a matching ETH PayPro proposal with identical calldata through getTxProposals', function(done) { + getPayProV2Stub.resolves(dataPaypro); + requestGetStub = sandbox.stub(clients[0].request, 'get').resolves({ body: [createEthTxpWithData(evmData)] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + should.not.exist(err); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('raises SERVER_COMPROMISED for an ETH PayPro proposal whose calldata was substituted', function(done) { + getPayProV2Stub.resolves(dataPaypro); + requestGetStub = sandbox.stub(clients[0].request, 'get').resolves({ body: [createEthTxpWithData(differentEvmData)] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); + done(); + } catch (e) { + done(e); + } + }); + }); + + // Output-level calldata matches the signed instruction, but Utils.buildTx() + // (common/utils.ts) still applies the top-level `txp.data` BWC <= 8.9.0 + // compatibility field by overwriting outputs[0].data at sign time - after + // checkPaypro has already accepted the proposal based on outputs[0].data + // alone. This lets a matching output carry a top-level override that + // signs different calldata than what was verified. + it('raises SERVER_COMPROMISED for an ETH PayPro proposal whose top-level legacy txp.data overrides the accepted calldata', function(done) { + getPayProV2Stub.resolves(dataPaypro); + const txp: any = createEthTxpWithData(evmData); + txp.data = differentEvmData; + requestGetStub = sandbox.stub(clients[0].request, 'get').resolves({ body: [txp] }); + clients[0].getTxProposals({}, (err, txps) => { + try { + err.should.be.an.instanceOf(Errors.SERVER_COMPROMISED); + done(); + } catch (e) { + done(e); + } + }); + }); }); describe('Proposals with explicit ID', () => { diff --git a/packages/bitcore-wallet-client/test/data/testdata.ts b/packages/bitcore-wallet-client/test/data/testdata.ts index c698a3f082..bd5620c57d 100644 --- a/packages/bitcore-wallet-client/test/data/testdata.ts +++ b/packages/bitcore-wallet-client/test/data/testdata.ts @@ -103,10 +103,43 @@ export const payProJson = { const bodyV2 = { bch: '{"time":"2019-11-05T17:05:31.791Z","expires":"2019-11-05T17:20:31.791Z","memo":"Payment request for BitPay invoice XM8XbreRs6cnKkR3yYT6qQ for merchant BitPay Visa® Load (USD-USA)","paymentUrl":"https://bitpay.com/i/XM8XbreRs6cnKkR3yYT6qQ","paymentId":"XM8XbreRs6cnKkR3yYT6qQ","chain":"BCH","network":"main","instructions":[{"type":"transaction","requiredFeeRate":1,"outputs":[{"amount":337900,"address":"qpymzlw4dfgawe2hy6xalj0qnzwedrqfvg96jl5ev6"}]}]}', btc: '{"time":"2019-11-05T15:21:09.047Z","expires":"2019-11-05T15:36:09.047Z","memo":"Payment request for BitPay invoice LanynqCPoL2JQb8z8s5Z3X for merchant BitPay Visa® Load (USD-USA)","paymentUrl":"https://bitpay.com/i/LanynqCPoL2JQb8z8s5Z3X","paymentId":"LanynqCPoL2JQb8z8s5Z3X","chain":"BTC","network":"main","instructions":[{"type":"transaction","requiredFeeRate":34.337,"outputs":[{"amount":19800,"address":"1CpEMwff6DA52FLoq4JAhd2xFSEjQxyokm"}]}]}', - eth: '{"time":"2019-10-10T14:57:01.924Z","expires":"2019-10-10T15:12:01.924Z","memo":"Payment request for BitPay invoice GsbhMZeeUebqzEeDmNubEP for merchant BitPay Visa® Load (USD-USA)","paymentUrl":"https://bitpay.com/i/GsbhMZeeUebqzEeDmNubEP","paymentId":"GsbhMZeeUebqzEeDmNubEP","chain":"ETH","network":"main","currency":"ETH","instructions":[{"type":"transaction","amount":5214000000000000,"toAddress":"0x52dE8D3fEbd3a06d3c627f59D56e6892B80DCf12","value":5214000000000000,"to":"0x52dE8D3fEbd3a06d3c627f59D56e6892B80DCf12","data":"0xb6b4af050000000000000000000000000000000000000000000000000012861af9dbe00000000000000000000000000000000000000000000000000000000005a43875660000000000000000000000000000000000000000000000000000016db9644f77cadbc5e4ee0119e349b39e42a049f5526b4eca8c225709d3fd73550c87de3d2096c9e28e9f3b440d991720673f01a67d3f74a912339beb77ed696f65f35e5bc4000000000000000000000000000000000000000000000000000000000000001c84ebb3c8fdeb8c59e35b1248a1af05ba8a332d745cc38a3193b1792e414dbdae41b55cbb5dbddf27fc539dd13a3bf1c72671d744b8706fcfb3eb2fce968456b40000000000000000000000000000000000000000000000000000000000000000","gasPrice":24229999974}]}' + eth: '{"time":"2019-10-10T14:57:01.924Z","expires":"2019-10-10T15:12:01.924Z","memo":"Payment request for BitPay invoice GsbhMZeeUebqzEeDmNubEP for merchant BitPay Visa® Load (USD-USA)","paymentUrl":"https://bitpay.com/i/GsbhMZeeUebqzEeDmNubEP","paymentId":"GsbhMZeeUebqzEeDmNubEP","chain":"ETH","network":"main","currency":"ETH","instructions":[{"type":"transaction","amount":5214000000000000,"toAddress":"0x52dE8D3fEbd3a06d3c627f59D56e6892B80DCf12","value":5214000000000000,"to":"0x52dE8D3fEbd3a06d3c627f59D56e6892B80DCf12","data":"0xb6b4af050000000000000000000000000000000000000000000000000012861af9dbe00000000000000000000000000000000000000000000000000000000005a43875660000000000000000000000000000000000000000000000000000016db9644f77cadbc5e4ee0119e349b39e42a049f5526b4eca8c225709d3fd73550c87de3d2096c9e28e9f3b440d991720673f01a67d3f74a912339beb77ed696f65f35e5bc4000000000000000000000000000000000000000000000000000000000000001c84ebb3c8fdeb8c59e35b1248a1af05ba8a332d745cc38a3193b1792e414dbdae41b55cbb5dbddf27fc539dd13a3bf1c72671d744b8706fcfb3eb2fce968456b40000000000000000000000000000000000000000000000000000000000000000","gasPrice":24229999974}]}', + // Two ordered EVM instructions - an ERC-20 `approve` followed by BitPay's + // `pay` contract call - both with a zero visible amount, matching real + // token PayPro requests where the actual payment amount lives in calldata. + // `0xA0b8...eB48` is mainnet USDC's real contract address + // (crypto-wallet-core's token registry); `approve`/`pay` calldata is + // ABI-encoded with `ethers.Interface` against `approve(address,uint256)` + // and BWS's real `Invoice.pay(...)` signature + // (chain/eth/abi-invoice.ts), not hand-written hex, so the selectors + // (`0x095ea7b3`, `0xb6b4af05`) and argument layout are genuine. + // `0x1BA1...f7C1` (the `pay` target) is an arbitrary but validly + // EIP-55-checksummed placeholder for BitPay's invoice contract, not a + // real deployed address. + erc20: '{"time":"2026-08-01T00:00:00.000Z","expires":"2026-08-01T00:15:00.000Z","memo":"Payment request for BitPay invoice UsdcErc20Fixture1 for merchant Test Merchant","paymentUrl":"https://bitpay.com/i/UsdcErc20Fixture1","paymentId":"UsdcErc20Fixture1","chain":"ETH","network":"main","currency":"USDC","instructions":[{"type":"transaction","amount":0,"toAddress":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","value":0,"to":"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","data":"0x095ea7b30000000000000000000000001ba1e35a29e2a52a1b1a1e2c8dbb28b9b5b6f7c10000000000000000000000000000000000000000000000000000000000989680"},{"type":"transaction","amount":0,"toAddress":"0x1BA1E35A29E2A52a1b1A1e2c8dbB28B9B5B6f7C1","value":0,"to":"0x1BA1E35A29E2A52a1b1A1e2c8dbB28B9B5B6f7C1","data":"0xb6b4af05000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000009502f9000000000000000000000000000000000000000000000000000000001b8dac5b40000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000004000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"}]}', + xrp: '{"time":"2026-08-01T00:00:00.000Z","expires":"2026-08-01T00:15:00.000Z","memo":"Payment request for BitPay invoice XrpFixture1 for merchant Test Merchant","paymentUrl":"https://bitpay.com/i/XrpFixture1","paymentId":"XrpFixture1","chain":"XRP","network":"main","currency":"XRP","instructions":[{"type":"transaction","requiredFeeRate":12,"outputs":[{"address":"rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh","amount":10000,"destinationTag":12345,"invoiceID":"0000000000000000000000000000000000000000000000000000000000000001"}]}]}', + sol: '{"time":"2026-08-01T00:00:00.000Z","expires":"2026-08-01T00:15:00.000Z","memo":"Payment request for BitPay invoice SolFixture1 for merchant Test Merchant","paymentUrl":"https://bitpay.com/i/SolFixture1","paymentId":"SolFixture1","chain":"SOL","network":"main","currency":"SOL","instructions":[{"type":"transaction","outputs":[{"address":"5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d","amount":10000,"invoiceID":"SolInvoiceFixture1"}]}]}' }; export const payProJsonV2Body = bodyV2; +// Identity/pubkey for the fixtures below that are signed with a test-only +// keypair rather than a real BitPay production key. `PayProV2.trustedKeys` +// only trusts a fixed set of production keys, so any test that verifies one +// of these fixtures through the real `PayProV2.verifyResponse` path must +// first merge this entry into `PayProV2.trustedKeys` (see the existing +// `domains` stub pattern in api.test.ts's "Payment Protocol V2" suite). +// Private key (test-only, never used for anything real): +// 4e7f2a6c1d9b8e3f05a1c7d4b6e9f2038a4c6d8e0f123456789abcdef0123ab +export const payProJsonV2TestKey = { + identity: '1CxjbDcNzxEGxRBiiCmPm2HZneedE4FQz8', + keyData: { + owner: 'IS-1413 test fixture key (NOT a real BitPay key)', + networks: ['main'], + domains: ['bitpay.com'], + publicKey: '0227a3a3ef276d0e8fd39b1b9bb02123103be427ef1edf10815059dace6e98b1c7' + } +}; + export const payProJsonV2 = { 'btc': { @@ -127,12 +160,48 @@ export const payProJsonV2 = { 'x-signature-type': 'ecc' } }, + // Signed with `payProJsonV2TestKey`, not a real BitPay production key - + // BitPay's real private keys aren't available to this test suite. Any + // caller must register `payProJsonV2TestKey` into `PayProV2.trustedKeys` + // first, or verification fails with "signed by unknown key" 'eth': { - body: Buffer.from(bodyV2.bch), + body: Buffer.from(bodyV2.eth), headers: { - 'x-identity': '1EMqSoDzMdBuuvM2RUnup3FnDeo6wuHxEg', - signature: '1701100e5bda63e7d4c311ab3c58d6edd01b6aa7b8cb314f36303dda3ce6a53b7ddebb9f9afe05fc6dd250cad215f8010472e57c4b71cab95e122d9fadb39957', - digest: 'SHA-256=1c1c47d338efaf7a45e693051b04e50eb0a86c1fec0e3882b1987c58bfe7d058', + 'x-identity': payProJsonV2TestKey.identity, + signature: '683801508575cc7077897814f8ffcfc07038a78a42bf19c6491013287865acb10ad25c71e8e3310296085a2865ba8ddc38c9f1904fd345bcdb452f6182add40e', + digest: 'SHA-256=337b16645745e48f0eeef01e596abbc52c7a833c0bafb661979838bc3b3f4ef1', + 'x-signature-type': 'ecc' + } + }, + // Two ordered EVM instructions (ERC-20 `approve` + BitPay `pay`), both with + // a zero visible amount. Signed with `payProJsonV2TestKey`. + 'erc20': { + body: Buffer.from(bodyV2.erc20), + headers: { + 'x-identity': payProJsonV2TestKey.identity, + signature: '02df4ad64811ef08486bbc223e2d3bf8d173d25bf5b3ff6de46c5b97bffd70c1281ca277daf47c6e66c150c50305b6d2d1ec72774bd1bfca41d2e3ebea947b70', + digest: 'SHA-256=b69afce8f0665d60187263bf74d34e2742d0a590c97abdb1fda6919a0ccc0a7d', + 'x-signature-type': 'ecc' + } + }, + // XRP instruction carrying a destination tag and invoice ID. Signed with + // `payProJsonV2TestKey`. + 'xrp': { + body: Buffer.from(bodyV2.xrp), + headers: { + 'x-identity': payProJsonV2TestKey.identity, + signature: '7cdf731d005fe1cecd3989b1a203a3c77240468f583977290d22f163ebb2b94f0d15bcdbd1cacd36e3493ade08e25adc3e098a05bf4159395588cc015f0d31c2', + digest: 'SHA-256=0f092ea4ba9f5b5f27051ba9370977518a780fa542e4e18086ae54bb4a60355a', + 'x-signature-type': 'ecc' + } + }, + // SOL instruction carrying an invoice memo/ID. Signed with `payProJsonV2TestKey`. + 'sol': { + body: Buffer.from(bodyV2.sol), + headers: { + 'x-identity': payProJsonV2TestKey.identity, + signature: '7677376aedabe1a1108eb06547b700d217d5215f092f710d277363d59217c7a40f2076bb1c8649652f74249fa609985421a2229bc72e22ac5ea6ea1fb2adbbda', + digest: 'SHA-256=9439d49538617c2dd5c179e6eecdf793ccd8ca046cc3055d2cb804de605d88f3', 'x-signature-type': 'ecc' } } diff --git a/packages/bitcore-wallet-client/test/payproV2.test.ts b/packages/bitcore-wallet-client/test/payproV2.test.ts index 802fa56f8a..ce380673ee 100644 --- a/packages/bitcore-wallet-client/test/payproV2.test.ts +++ b/packages/bitcore-wallet-client/test/payproV2.test.ts @@ -1,5 +1,6 @@ 'use strict'; +import { ethers } from '@bitpay-labs/crypto-wallet-core'; import { PayProV2 } from '../src/lib/payproV2'; import * as TestData from './data/testdata'; @@ -313,4 +314,112 @@ describe('PayProV2', () => { }); }); }); + + // `payProJsonV2.eth`/`.erc20`/`.xrp`/`.sol` are signed with a test-only + // keypair rather than a real BitPay key, so `PayProV2.trustedKeys` must + // trust that key for the duration of these cases (mirrors the `domains` + // stub already used for the real keys in api.test.ts). + describe('account-chain fixtures (test-signed)', () => { + let savedTrustedKeys; + + beforeEach(() => { + savedTrustedKeys = PayProV2.trustedKeys; + PayProV2.trustedKeys = { + ...savedTrustedKeys, + [TestData.payProJsonV2TestKey.identity]: TestData.payProJsonV2TestKey.keyData + }; + }); + + afterEach(() => { + PayProV2.trustedKeys = savedTrustedKeys; + }); + + const approveIface = new ethers.Interface(['function approve(address spender, uint256 amount)']); + const payIface = new ethers.Interface([ + 'function pay(uint256 value, uint256 gasPrice, uint256 expiration, bytes32 payload, bytes32 hash, uint8 v, bytes32 r, bytes32 s, address tokenContract)' + ]); + + const cases = [ + { + description: 'a native ETH transfer', + fixture: 'eth', + assert: res => { + res.currency.should.equal('ETH'); + res.instructions.should.have.lengthOf(1); + const [ix] = res.instructions; + ix.toAddress.should.equal('0x52dE8D3fEbd3a06d3c627f59D56e6892B80DCf12'); + ix.amount.should.equal(5214000000000000); + // This fixture is a real, unmodified 2019 BitPay invoice response + // (see bodyV2.eth) - + // Invoice.pay(...)'s decoded `value` matching the instruction's + // own `amount`/`value` fields confirms the calldata genuinely + // encodes this same payment, not just a matching selector. + const decoded = payIface.decodeFunctionData('pay', ix.data); + decoded.value.should.equal(5214000000000000n); + decoded.tokenContract.should.equal(ethers.ZeroAddress); // native ETH, not a token + } + }, + { + description: 'two ordered ERC-20 approve/pay instructions', + fixture: 'erc20', + assert: res => { + res.currency.should.equal('USDC'); + res.instructions.should.have.lengthOf(2); + const [approveIx, payIx] = res.instructions; + + // Mainnet USDC's real contract address + approveIx.toAddress.should.equal('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + approveIx.amount.should.equal(0); + const [spender, approveAmount] = approveIface.decodeFunctionData('approve', approveIx.data); + spender.should.equal('0x1BA1E35A29E2A52a1b1A1e2c8dbB28B9B5B6f7C1'); + approveAmount.should.equal(10000000n); + + payIx.toAddress.should.equal('0x1BA1E35A29E2A52a1b1A1e2c8dbB28B9B5B6f7C1'); + payIx.amount.should.equal(0); + const decodedPay = payIface.decodeFunctionData('pay', payIx.data); + decodedPay.value.should.equal(10000000n); + decodedPay.tokenContract.should.equal('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + } + }, + { + description: 'an XRP destination tag and invoice ID', + fixture: 'xrp', + assert: res => { + res.currency.should.equal('XRP'); + res.instructions.should.have.lengthOf(1); + res.instructions[0].toAddress.should.equal('rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'); + res.instructions[0].amount.should.equal(10000); + res.instructions[0].outputs[0].destinationTag.should.equal(12345); + res.instructions[0].outputs[0].invoiceID.should.equal( + '0000000000000000000000000000000000000000000000000000000000000001' + ); + } + }, + { + description: 'a SOL invoice memo', + fixture: 'sol', + assert: res => { + res.currency.should.equal('SOL'); + res.instructions.should.have.lengthOf(1); + res.instructions[0].toAddress.should.equal('5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d'); + res.instructions[0].amount.should.equal(10000); + res.instructions[0].outputs[0].invoiceID.should.equal('SolInvoiceFixture1'); + } + } + ]; + for (const testCase of cases) { + it(`resolves a genuinely signed PayPro V2 response for ${testCase.description}`, (done) => { + const fixture = TestData.payProJsonV2[testCase.fixture]; + mockRequest(fixture.body, fixture.headers); + PayProV2.selectPaymentOption({ + paymentUrl: `https://bitpay.com/i/${testCase.fixture}Fixture` + }).then((res) => { + testCase.assert(res); + done(); + }).catch(err => { + done(err); + }); + }); + } + }); }); diff --git a/packages/bitcore-wallet-client/test/verifier.test.ts b/packages/bitcore-wallet-client/test/verifier.test.ts index 4dad8ab00e..aaf3b111ee 100644 --- a/packages/bitcore-wallet-client/test/verifier.test.ts +++ b/packages/bitcore-wallet-client/test/verifier.test.ts @@ -1,8 +1,10 @@ 'use strict'; import chai from 'chai'; +import sinon from 'sinon'; import { Verifier } from '../src/lib/verifier'; import { Key } from '../src/lib/key'; +import log from '../src/lib/log'; chai.should(); @@ -12,6 +14,923 @@ const aKey = new Key({ }); describe('Verifier', function() { + describe('checkPaypro', function() { + const amount = 10000; + const addresses = { + btc: [ + '1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA', + '1BoatSLRHtKNngkdXEeobR76b53LETtpyT' + ], + bch: { + cashaddr: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + legacy: 'CTH8H8Zj6DSnXFBKQeDG28ogAS92iS16Bp', + different: 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy' + }, + doge: [ + 'DBtUjAUFWHqS1y8CgetQtPW6YKDhffyFT4', + 'D77Z1nmgSZxJTmtN65n2MVF9yvLSB4MpiC' + ], + ltc: [ + 'LQqWdV81RmiEzXoACvWDQPZEXXU1Q16suH', + 'Lcyaicjq2aFgcgRX5mDhhQkXN8RFvzWowa' + ] + }; + // Equivalent-but-not-identical-string encodings that a chain's own address + // library treats as the same destination. Bech32 forms below are derived + // from a fixed test-only private key scalar so they are deterministic and + // independently reproducible; the P2SH pair is the ticket's own fixture. + const equivalenceFixtures = { + btcBech32: { + lower: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + upper: 'BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4' + }, + ltcBech32: { + lower: 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9', + upper: 'LTC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KGMN4N9' + }, + ltcP2sh: { + legacy: '3GueMn6ruWVfQTN4XKBGEbCbGLwRSUhfnS', + modern: 'MP7nffWprdM6CxdxdCAc4ESzb3XsQQPZMp' + } + }; + const supportedChains = ['btc', 'bch', 'doge', 'ltc']; + const addressFor = chain => chain === 'bch' ? addresses.bch.cashaddr : addresses[chain][0]; + const differentAddressFor = chain => chain === 'bch' ? addresses.bch.different : addresses[chain][1]; + const createTxp = (chain, overrides = {}) => ({ + version: 3, + chain, + coin: chain, + amount, + outputs: [{ toAddress: addressFor(chain), amount }], + ...overrides + }); + const createPaypro = (chain, overrides = {}) => ({ + instructions: [{ toAddress: addressFor(chain), amount }], + ...overrides + }); + + const matchingCases = [ + { + description: 'accepts matching BTC PayPro destination and amount', + chain: 'btc' + }, + { + description: 'accepts matching DOGE PayPro destination and amount', + chain: 'doge' + }, + { + description: 'accepts matching LTC PayPro destination and amount', + chain: 'ltc' + } + ]; + for (const testCase of matchingCases) { + it(testCase.description, function() { + Verifier.checkPaypro( + createTxp(testCase.chain), + createPaypro(testCase.chain) + ).should.equal(true); + }); + } + + const destinationMismatchCases = [ + { + description: 'rejects a BTC PayPro destination mismatch with the correct amount', + chain: 'btc' + }, + { + description: 'rejects a DOGE PayPro destination mismatch with the correct amount', + chain: 'doge' + }, + { + description: 'rejects an LTC PayPro destination mismatch with the correct amount', + chain: 'ltc' + } + ]; + for (const testCase of destinationMismatchCases) { + it(testCase.description, function() { + const paypro = createPaypro(testCase.chain, { + instructions: [{ + toAddress: differentAddressFor(testCase.chain), + amount + }] + }); + Verifier.checkPaypro(createTxp(testCase.chain), paypro).should.equal(false); + }); + } + + it('accepts equivalent BCH cashaddr and legacy destinations', function() { + const txp = createTxp('bch', { + outputs: [{ toAddress: addresses.bch.legacy, amount }] + }); + Verifier.checkPaypro(txp, createPaypro('bch')).should.equal(true); + }); + + it('rejects a different BCH PayPro destination with the correct amount', function() { + const paypro = createPaypro('bch', { + instructions: [{ toAddress: addresses.bch.different, amount }] + }); + Verifier.checkPaypro(createTxp('bch'), paypro).should.equal(false); + }); + + it('rejects an amount mismatch for every supported multisig chain', function() { + for (const chain of supportedChains) { + const paypro = createPaypro(chain, { + instructions: [{ toAddress: addressFor(chain), amount: amount + 1 }] + }); + chai.expect(Verifier.checkPaypro(createTxp(chain), paypro), chain).to.equal(false); + } + }); + + it('rejects an unsupported chain instead of accepting it by default', function() { + const txp = createTxp('btc', { chain: 'unsupportedchain' }); + Verifier.checkPaypro(txp, createPaypro('btc')).should.equal(false); + }); + + it('rejects missing transaction or PayPro data without throwing', function() { + const cases = [ + { txp: null, paypro: createPaypro('btc') }, + { txp: createTxp('btc'), paypro: null } + ]; + for (const testCase of cases) { + let result; + chai.expect(() => { + result = Verifier.checkPaypro(testCase.txp, testCase.paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + } + }); + + it('rejects invalid transaction proposal versions instead of coercing them', function() { + const invalidVersions = [undefined, null, true, {}, [], [3], 0, -1, 1.5, '', ' ', '3x']; + for (const version of invalidVersions) { + chai.expect( + Verifier.checkPaypro(createTxp('btc', { version }), createPaypro('btc')), + String(version) + ).to.equal(false); + } + }); + + const malformedInstructionCases = [ + { description: 'rejects missing PayPro instructions without throwing', paypro: {} }, + { description: 'rejects empty PayPro instructions without throwing', paypro: { instructions: [] } } + ]; + for (const testCase of malformedInstructionCases) { + it(testCase.description, function() { + let result; + chai.expect(() => { + result = Verifier.checkPaypro(createTxp('btc'), testCase.paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + } + + const malformedOutputCases = [ + { description: 'rejects a version 3 proposal with missing outputs without throwing', outputs: undefined }, + { description: 'rejects a version 3 proposal with empty outputs without throwing', outputs: [] } + ]; + for (const testCase of malformedOutputCases) { + it(testCase.description, function() { + let result; + chai.expect(() => { + result = Verifier.checkPaypro( + createTxp('btc', { outputs: testCase.outputs }), + createPaypro('btc') + ); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + } + + it('logs the entry index and invalid field for malformed outputs and instructions', function() { + const warn = sinon.stub(log, 'warn'); + try { + const outputs = [ + { toAddress: addresses.btc[0], amount: 5000 }, + { toAddress: ' ', amount: 5000 } + ]; + const instructions = [ + { toAddress: addresses.btc[0], amount: 5000 }, + { toAddress: addresses.btc[1], amount: 5000 } + ]; + Verifier.checkPaypro( + createTxp('btc', { id: 'txp-id', outputs }), + createPaypro('btc', { instructions }) + ).should.equal(false); + sinon.assert.calledOnceWithExactly( + warn, + '[TXP txp-id] PayPro verification failed: transaction output at index 1 has an invalid destination address' + ); + + warn.resetHistory(); + instructions[1] = { toAddress: addresses.btc[1], amount: -1 }; + outputs[1] = { toAddress: addresses.btc[1], amount: 5000 }; + Verifier.checkPaypro( + createTxp('btc', { id: 'txp-id', outputs }), + createPaypro('btc', { instructions }) + ).should.equal(false); + sinon.assert.calledOnceWithExactly( + warn, + '[TXP txp-id] PayPro verification failed: PayPro instruction at index 1 has an invalid amount' + ); + } finally { + warn.restore(); + } + }); + + const unparseableBchCases = [ + { + description: 'rejects an unparseable BCH proposal destination without throwing', + txp: createTxp('bch', { outputs: [{ toAddress: 'not-a-bch-address', amount }] }), + paypro: createPaypro('bch') + }, + { + description: 'rejects an unparseable BCH PayPro destination without throwing', + txp: createTxp('bch'), + paypro: createPaypro('bch', { + instructions: [{ toAddress: 'not-a-bch-address', amount }] + }) + } + ]; + for (const testCase of unparseableBchCases) { + it(testCase.description, function() { + let result; + chai.expect(() => { + result = Verifier.checkPaypro(testCase.txp, testCase.paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + } + + const addressEquivalenceCases = [ + { + description: 'accepts equivalent BTC Bech32 case forms', + chain: 'btc', + outputAddress: equivalenceFixtures.btcBech32.lower, + instructionAddress: equivalenceFixtures.btcBech32.upper + }, + { + description: 'accepts equivalent LTC Bech32 case forms', + chain: 'ltc', + outputAddress: equivalenceFixtures.ltcBech32.lower, + instructionAddress: equivalenceFixtures.ltcBech32.upper + }, + { + description: 'accepts equivalent LTC legacy 3... and modern M... P2SH destinations', + chain: 'ltc', + outputAddress: equivalenceFixtures.ltcP2sh.legacy, + instructionAddress: equivalenceFixtures.ltcP2sh.modern + } + ]; + for (const testCase of addressEquivalenceCases) { + it(testCase.description, function() { + const txp = createTxp(testCase.chain, { + outputs: [{ toAddress: testCase.outputAddress, amount }] + }); + const paypro = createPaypro(testCase.chain, { + instructions: [{ toAddress: testCase.instructionAddress, amount }] + }); + Verifier.checkPaypro(txp, paypro).should.equal(true); + }); + } + + const identicalMalformedDestinationChains = ['btc', 'bch', 'doge', 'ltc']; + for (const chain of identicalMalformedDestinationChains) { + it(`rejects identical malformed ${chain.toUpperCase()} destinations without throwing`, function() { + const malformed = 'not-a-real-address!!!'; + const txp = createTxp(chain, { outputs: [{ toAddress: malformed, amount }] }); + const paypro = createPaypro(chain, { instructions: [{ toAddress: malformed, amount }] }); + let result; + chai.expect(() => { + result = Verifier.checkPaypro(txp, paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + + it(`rejects a malformed ${chain.toUpperCase()} proposal destination with a valid PayPro instruction`, function() { + const malformed = 'not-a-real-address!!!'; + const txp = createTxp(chain, { outputs: [{ toAddress: malformed, amount }] }); + let result; + chai.expect(() => { + result = Verifier.checkPaypro(txp, createPaypro(chain)); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + + it(`rejects a malformed ${chain.toUpperCase()} PayPro instruction with a valid proposal destination`, function() { + const malformed = 'not-a-real-address!!!'; + const paypro = createPaypro(chain, { + instructions: [{ toAddress: malformed, amount }] + }); + let result; + chai.expect(() => { + result = Verifier.checkPaypro(createTxp(chain), paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + } + + const legacyCases = [ + { chain: 'btc' }, + { chain: 'bch' }, + { chain: 'doge' }, + { chain: 'ltc' } + ]; + for (const testCase of legacyCases) { + it(`preserves matching legacy ${testCase.chain.toUpperCase()} transaction proposal behavior`, function() { + const txp = createTxp(testCase.chain, { + version: 2, + chain: undefined, + toAddress: addressFor(testCase.chain), + outputs: undefined + }); + Verifier.checkPaypro(txp, createPaypro(testCase.chain)).should.equal(true); + }); + + it(`rejects a legacy ${testCase.chain.toUpperCase()} transaction proposal destination mismatch`, function() { + const txp = createTxp(testCase.chain, { + version: 2, + chain: undefined, + toAddress: differentAddressFor(testCase.chain), + outputs: undefined + }); + Verifier.checkPaypro(txp, createPaypro(testCase.chain)).should.equal(false); + }); + } + + it('accepts a proposal whose complete output set matches all PayPro instructions', function() { + const txp = createTxp('btc', { + outputs: [ + { toAddress: addresses.btc[1], amount: 4000 }, + { toAddress: addresses.btc[0], amount: 6000 } + ] + }); + const paypro = createPaypro('btc', { + instructions: [ + { toAddress: addresses.btc[0], amount: 6000 }, + { toAddress: addresses.btc[1], amount: 4000 } + ] + }); + Verifier.checkPaypro(txp, paypro).should.equal(true); + }); + + it('accepts matching duplicate output and instruction pairs', function() { + const entries = [ + { toAddress: addresses.btc[0], amount: 5000 }, + { toAddress: addresses.btc[0], amount: 5000 } + ]; + const txp = createTxp('btc', { outputs: entries }); + const paypro = createPaypro('btc', { instructions: entries }); + Verifier.checkPaypro(txp, paypro).should.equal(true); + }); + + it('rejects a proposal when a non-first PayPro destination is changed', function() { + const txp = createTxp('btc', { + outputs: [ + { toAddress: addresses.btc[0], amount: 6000 }, + { toAddress: addresses.btc[1], amount: 4000 } + ] + }); + const paypro = createPaypro('btc', { + instructions: [ + { toAddress: addresses.btc[0], amount: 6000 }, + { toAddress: '1dice8EMZmqKvrGE4Qc9bUFf9PX3xaYDp', amount: 4000 } + ] + }); + Verifier.checkPaypro(txp, paypro).should.equal(false); + }); + + it('rejects different output and instruction counts even when totals match', function() { + const txp = createTxp('btc'); + const paypro = createPaypro('btc', { + instructions: [ + { toAddress: addresses.btc[0], amount: 6000 }, + { toAddress: addresses.btc[1], amount: 4000 } + ] + }); + Verifier.checkPaypro(txp, paypro).should.equal(false); + }); + + it('rejects an output amount change even when the proposal total still matches', function() { + const txp = createTxp('btc', { + outputs: [ + { toAddress: addresses.btc[0], amount: 5000 }, + { toAddress: addresses.btc[1], amount: 5000 } + ] + }); + const paypro = createPaypro('btc', { + instructions: [ + { toAddress: addresses.btc[0], amount: 6000 }, + { toAddress: addresses.btc[1], amount: 4000 } + ] + }); + Verifier.checkPaypro(txp, paypro).should.equal(false); + }); + + it('rejects an output total that differs from the transaction amount', function() { + const txp = createTxp('btc', { + outputs: [{ toAddress: addresses.btc[0], amount: amount + 1 }] + }); + Verifier.checkPaypro(txp, createPaypro('btc')).should.equal(false); + }); + + describe('account chains (EVM/XRP/SOL)', function() { + const evmAddress = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'; + const evmAddressUppercase = '0x' + evmAddress.slice(2).toUpperCase(); + const evmAddressLowercase = evmAddress.toLowerCase(); + const differentEvmAddress = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + // Same digits as evmAddress with one letter's case flipped - a valid + // EIP-55 checksum encodes case, so this is a different, invalid + // checksum rather than an equivalent encoding. + const invalidChecksumEvmAddress = '0x9858effD232B4033E47d90003D41EC34EcaEda94'; + const xrpAddress = 'rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh'; + const differentXrpAddress = 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe'; + const solAddress = '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d'; + const differentSolAddress = '7VHUFJHWu2CuExkJcJrzhQPJ2oygupTWkL2A2For4BiF'; + + const createAccountTxp = (chain, overrides = {}) => ({ + version: 3, + chain, + coin: chain, + amount, + outputs: [{ toAddress: evmAddress, amount }], + ...overrides + }); + const createAccountPaypro = (overrides = {}) => ({ + instructions: [{ toAddress: evmAddress, amount }], + ...overrides + }); + + const matchingAccountCases = [ + { description: 'accepts a matching ETH PayPro destination and amount', chain: 'eth' }, + { description: 'accepts a matching MATIC PayPro destination and amount', chain: 'matic' }, + { description: 'accepts a matching ARB PayPro destination and amount', chain: 'arb' }, + { description: 'accepts a matching BASE PayPro destination and amount', chain: 'base' }, + { description: 'accepts a matching OP PayPro destination and amount', chain: 'op' }, + { description: 'accepts a matching ARC PayPro destination and amount', chain: 'arc' } + ]; + for (const testCase of matchingAccountCases) { + it(testCase.description, function() { + Verifier.checkPaypro( + createAccountTxp(testCase.chain), + createAccountPaypro() + ).should.equal(true); + }); + } + + it('accepts a matching XRP PayPro destination and amount', function() { + const txp = createAccountTxp('xrp', { outputs: [{ toAddress: xrpAddress, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: xrpAddress, amount }] }); + Verifier.checkPaypro(txp, paypro).should.equal(true); + }); + + it('accepts a matching SOL PayPro destination and amount', function() { + const txp = createAccountTxp('sol', { outputs: [{ toAddress: solAddress, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: solAddress, amount }] }); + Verifier.checkPaypro(txp, paypro).should.equal(true); + }); + + it('accepts a matching USDC-on-ETH PayPro destination and amount', function() { + const txp = createAccountTxp('eth', { coin: 'usdc' }); + Verifier.checkPaypro(txp, createAccountPaypro()).should.equal(true); + }); + + it('accepts a matching token on a non-ETH EVM chain (USDC on MATIC)', function() { + const txp = createAccountTxp('matic', { coin: 'usdc' }); + Verifier.checkPaypro(txp, createAccountPaypro()).should.equal(true); + }); + + it('rejects an ETH PayPro destination mismatch with the correct amount', function() { + const txp = createAccountTxp('eth'); + const paypro = createAccountPaypro({ instructions: [{ toAddress: differentEvmAddress, amount }] }); + Verifier.checkPaypro(txp, paypro).should.equal(false); + }); + + it('rejects an XRP PayPro destination mismatch with the correct amount', function() { + const txp = createAccountTxp('xrp', { outputs: [{ toAddress: xrpAddress, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: differentXrpAddress, amount }] }); + Verifier.checkPaypro(txp, paypro).should.equal(false); + }); + + it('rejects a SOL PayPro destination mismatch with the correct amount', function() { + const txp = createAccountTxp('sol', { outputs: [{ toAddress: solAddress, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: differentSolAddress, amount }] }); + Verifier.checkPaypro(txp, paypro).should.equal(false); + }); + + it('accepts equivalent EVM checksum, lowercase, and uppercase destination forms', function() { + const txp = createAccountTxp('eth', { outputs: [{ toAddress: evmAddressLowercase, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: evmAddressUppercase, amount }] }); + Verifier.checkPaypro(txp, paypro).should.equal(true); + }); + + it('rejects an EVM destination with an invalid mixed-case checksum', function() { + const txp = createAccountTxp('eth', { outputs: [{ toAddress: invalidChecksumEvmAddress, amount }] }); + let result; + chai.expect(() => { + result = Verifier.checkPaypro(txp, createAccountPaypro()); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + + it('rejects identical malformed XRP destinations without throwing', function() { + const malformed = 'not-a-real-xrp-address'; + const txp = createAccountTxp('xrp', { outputs: [{ toAddress: malformed, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: malformed, amount }] }); + let result; + chai.expect(() => { + result = Verifier.checkPaypro(txp, paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + + it('rejects identical malformed SOL destinations without throwing', function() { + const malformed = 'not-a-real-sol-address'; + const txp = createAccountTxp('sol', { outputs: [{ toAddress: malformed, amount }] }); + const paypro = createAccountPaypro({ instructions: [{ toAddress: malformed, amount }] }); + let result; + chai.expect(() => { + result = Verifier.checkPaypro(txp, paypro); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + + it('accepts a known legacy ERC-20 coin fallback with no explicit chain', function() { + // Utils.getChain() maps Constants.BITPAY_SUPPORTED_ETH_ERC20 coins + // (e.g. 'usdc') to 'eth' for backwards compatibility when txp.chain + // is absent - old-style token proposals rely on this. + const txp = createAccountTxp('eth', { chain: undefined, coin: 'usdc' }); + Verifier.checkPaypro(txp, createAccountPaypro()).should.equal(true); + }); + + it('rejects an unknown chain-less coin instead of silently resolving it to ETH', function() { + // Utils.getChain() also maps every *unrecognized* coin to 'eth' as a + // catch-all. A PayPro-specific resolver must not inherit that + // fallback, or an unknown coin paired with an ETH-shaped instruction + // would be silently accepted once ETH support lands. + const txp = createAccountTxp('eth', { chain: undefined, coin: 'notarealcoin' }); + Verifier.checkPaypro(txp, createAccountPaypro()).should.equal(false); + }); + + // Destination and amount alone are not a complete account-chain + // instruction: EVM calldata, XRP's destination tag/invoice ID, SOL's + // invoice memo, and the signed PayPro metadata can all change what a + // proposal actually pays without changing its destination or amount. + // Every case below starts from a valid base case above and mutates + // only the field named in its description. `txp` is the untrusted + // side (sourced from BWS); `paypro` is the signed, verified side - so + // the security-relevant direction for every "one side omits it" case + // is the proposal dropping a field the signed instruction has. + describe('instruction semantics (data, ordering, tag, invoice ID, memo, metadata)', function() { + const nativeData = '0xa9059cbb0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda94000000000000000000000000000000000000000000000000000000000000989680'; + const differentNativeData = '0xa9059cbb0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda940000000000000000000000000000000000000000000000000000000000000001'; + // Same bytes as `nativeData`, differing only in hex-letter case. + const nativeDataUppercase = '0x' + nativeData.slice(2).toUpperCase(); + const malformedData = 'not-valid-hex-calldata'; + + const createNativeEvmTxp = data => + createAccountTxp('eth', { outputs: [{ toAddress: evmAddress, amount, data }] }); + const createNativeEvmPaypro = data => + createAccountPaypro({ instructions: [{ toAddress: evmAddress, amount, to: evmAddress, value: amount, data }] }); + + it('accepts a matching native EVM PayPro instruction with identical calldata', function() { + Verifier.checkPaypro(createNativeEvmTxp(nativeData), createNativeEvmPaypro(nativeData)).should.equal(true); + }); + + it('accepts a native EVM PayPro instruction when calldata differs only by hex letter case', function() { + Verifier.checkPaypro(createNativeEvmTxp(nativeData), createNativeEvmPaypro(nativeDataUppercase)).should.equal(true); + }); + + it('rejects a native EVM PayPro instruction when the calldata changes', function() { + Verifier.checkPaypro(createNativeEvmTxp(nativeData), createNativeEvmPaypro(differentNativeData)).should.equal(false); + }); + + it('rejects a native EVM PayPro instruction when the proposal adds calldata absent from the signed instruction', function() { + Verifier.checkPaypro(createNativeEvmTxp(nativeData), createNativeEvmPaypro(undefined)).should.equal(false); + }); + + it('rejects a native EVM PayPro instruction when the proposal omits the signed calldata', function() { + // `txp` is untrusted (BWS-sourced) + Verifier.checkPaypro(createNativeEvmTxp(undefined), createNativeEvmPaypro(nativeData)).should.equal(false); + }); + + // Utils.buildTx() (common/utils.ts) still applies a BWC <= 8.9.0 + // compatibility field, top-level `txp.data`, by overwriting + // `outputs[0].data` at sign time. checkPaypro only ever inspects + // `outputs[i].data`, so a proposal whose output already matches the + // signed instruction can still carry a top-level override that + // signs different calldata than what was just verified. + it('rejects a native EVM PayPro instruction when a top-level legacy txp.data override differs from the signed calldata', function() { + const txp = { ...createNativeEvmTxp(nativeData), data: differentNativeData }; + Verifier.checkPaypro(txp, createNativeEvmPaypro(nativeData)).should.equal(false); + }); + + it('accepts a native EVM PayPro instruction whose top-level legacy txp.data matches the signed calldata', function() { + const txp = { ...createNativeEvmTxp(nativeData), data: nativeData }; + Verifier.checkPaypro(txp, createNativeEvmPaypro(nativeData)).should.equal(true); + }); + + it('rejects identical malformed native EVM calldata without throwing', function() { + let result; + chai.expect(() => { + result = Verifier.checkPaypro(createNativeEvmTxp(malformedData), createNativeEvmPaypro(malformedData)); + }).not.to.throw(); + chai.expect(result).to.equal(false); + }); + + // Real ERC-20 PayPro requests contain an ordered `approve` call + // followed by BitPay's `pay` call, both with a zero visible amount - + // the actual token amount and recipient live in calldata. Addresses + // and calldata below are real: `tokenContractAddress` is mainnet + // USDC's actual contract address (crypto-wallet-core's token + // registry), and `approveData`/`payData` are produced by ABI-encoding + // `approve(address,uint256)` and BWS's real `Invoice.pay(...)` + // signature (chain/eth/abi-invoice.ts) with `ethers.Interface` - + // not hand-written hex - so their selectors and argument layout are + // genuine. `payContractAddress` is an arbitrary but validly + // EIP-55-checksummed placeholder for BitPay's invoice contract; it + // does not correspond to any deployed contract. + const tokenContractAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const payContractAddress = '0x1BA1E35A29E2A52a1b1A1e2c8dbB28B9B5B6f7C1'; + const approveData = '0x095ea7b30000000000000000000000001ba1e35a29e2a52a1b1a1e2c8dbb28b9b5b6f7c10000000000000000000000000000000000000000000000000000000000989680'; + const payData = '0xb6b4af05000000000000000000000000000000000000000000000000000000000098968000000000000000000000000000000000000000000000000000000009502f9000000000000000000000000000000000000000000000000000000001b8dac5b40000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000004000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + const differentPayData = '0xb6b4af05000000000000000000000000000000000000000000000000000000000098967f00000000000000000000000000000000000000000000000000000009502f9000000000000000000000000000000000000000000000000000000001b8dac5b40000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000001b00000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000004000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + + const createErc20Txp = (overrides = {}) => createAccountTxp('eth', { + coin: 'usdc', + amount: 0, + outputs: [ + { toAddress: tokenContractAddress, amount: 0, data: approveData }, + { toAddress: payContractAddress, amount: 0, data: payData } + ], + ...overrides + }); + const createErc20Paypro = (overrides = {}) => createAccountPaypro({ + instructions: [ + { toAddress: tokenContractAddress, amount: 0, to: tokenContractAddress, value: 0, data: approveData }, + { toAddress: payContractAddress, amount: 0, to: payContractAddress, value: 0, data: payData } + ], + ...overrides + }); + + it('accepts ordered ERC-20 approve and pay instructions with zero visible amounts', function() { + Verifier.checkPaypro(createErc20Txp(), createErc20Paypro()).should.equal(true); + }); + + it('rejects an ERC-20 proposal when the second (pay) instruction calldata changes', function() { + const paypro = createErc20Paypro({ + instructions: [ + { toAddress: tokenContractAddress, amount: 0, to: tokenContractAddress, value: 0, data: approveData }, + { toAddress: payContractAddress, amount: 0, to: payContractAddress, value: 0, data: differentPayData } + ] + }); + Verifier.checkPaypro(createErc20Txp(), paypro).should.equal(false); + }); + + it('rejects an ERC-20 proposal with the approve and pay instructions swapped', function() { + // Account-chain contract calls are ordered + // Swapping which call runs first changes what the + // transaction does even though the same two entries are present. + const paypro = createErc20Paypro({ + instructions: [ + { toAddress: payContractAddress, amount: 0, to: payContractAddress, value: 0, data: payData }, + { toAddress: tokenContractAddress, amount: 0, to: tokenContractAddress, value: 0, data: approveData } + ] + }); + Verifier.checkPaypro(createErc20Txp(), paypro).should.equal(false); + }); + + // The app copies `destinationTag`/`invoiceID` from the PayPro + // instruction's nested `outputs[0]` onto the top-level transaction + // proposal fields BWS persists (`txp.destinationTag`/`txp.invoiceID`). + const xrpTag = 12345; + const xrpInvoiceId = '0000000000000000000000000000000000000000000000000000000000000001'; + + const createXrpTxp = (destinationTag, invoiceID) => createAccountTxp('xrp', { + outputs: [{ toAddress: xrpAddress, amount }], + destinationTag, + invoiceID + }); + const createXrpPaypro = (destinationTag, invoiceID) => createAccountPaypro({ + instructions: [{ + toAddress: xrpAddress, + amount, + outputs: [{ address: xrpAddress, amount, destinationTag, invoiceID }] + }] + }); + + it('accepts a matching XRP PayPro instruction with a destination tag and invoice ID', function() { + Verifier.checkPaypro(createXrpTxp(xrpTag, xrpInvoiceId), createXrpPaypro(xrpTag, xrpInvoiceId)).should.equal(true); + }); + + it('rejects an XRP PayPro instruction when the destination tag changes', function() { + Verifier.checkPaypro(createXrpTxp(xrpTag, xrpInvoiceId), createXrpPaypro(xrpTag + 1, xrpInvoiceId)).should.equal(false); + }); + + it('rejects an XRP PayPro instruction when the invoice ID changes', function() { + const differentInvoiceId = '0000000000000000000000000000000000000000000000000000000000000002'; + Verifier.checkPaypro(createXrpTxp(xrpTag, xrpInvoiceId), createXrpPaypro(xrpTag, differentInvoiceId)).should.equal(false); + }); + + it('accepts an XRP PayPro instruction when the destination tag differs only in type', function() { + Verifier.checkPaypro(createXrpTxp('12345', xrpInvoiceId), createXrpPaypro(xrpTag, xrpInvoiceId)).should.equal(true); + }); + + it('rejects an XRP PayPro instruction when the proposal omits the signed destination tag', function() { + Verifier.checkPaypro(createXrpTxp(undefined, xrpInvoiceId), createXrpPaypro(xrpTag, xrpInvoiceId)).should.equal(false); + }); + + it('rejects an XRP PayPro instruction when the proposal adds a destination tag absent from the signed instruction', function() { + Verifier.checkPaypro(createXrpTxp(xrpTag, xrpInvoiceId), createXrpPaypro(undefined, xrpInvoiceId)).should.equal(false); + }); + + it('rejects an XRP PayPro instruction when the proposal omits the signed invoice ID', function() { + Verifier.checkPaypro(createXrpTxp(xrpTag, undefined), createXrpPaypro(xrpTag, xrpInvoiceId)).should.equal(false); + }); + + it('rejects an XRP PayPro instruction when the proposal adds an invoice ID absent from the signed instruction', function() { + Verifier.checkPaypro(createXrpTxp(xrpTag, xrpInvoiceId), createXrpPaypro(xrpTag, undefined)).should.equal(false); + }); + + // The app maps the PayPro instruction's `outputs[0].invoiceID` to + // `txp.memo` for SOL, which is serialized on-chain as a memo + // instruction. + const solMemo = 'SolInvoiceFixture1'; + const differentSolMemo = 'SolInvoiceFixture2'; + + const createSolTxp = memo => createAccountTxp('sol', { + outputs: [{ toAddress: solAddress, amount }], + memo + }); + const createSolPaypro = memo => createAccountPaypro({ + instructions: [{ + toAddress: solAddress, + amount, + outputs: [{ address: solAddress, amount, invoiceID: memo }] + }] + }); + + it('accepts a matching SOL PayPro instruction with an invoice memo', function() { + Verifier.checkPaypro(createSolTxp(solMemo), createSolPaypro(solMemo)).should.equal(true); + }); + + it('rejects a SOL PayPro instruction when the invoice memo changes', function() { + Verifier.checkPaypro(createSolTxp(solMemo), createSolPaypro(differentSolMemo)).should.equal(false); + }); + + it('rejects a SOL PayPro instruction when the proposal omits the signed invoice memo', function() { + Verifier.checkPaypro(createSolTxp(undefined), createSolPaypro(solMemo)).should.equal(false); + }); + + it('rejects a SOL PayPro instruction when the proposal adds a memo absent from the signed instruction', function() { + Verifier.checkPaypro(createSolTxp(solMemo), createSolPaypro(undefined)).should.equal(false); + }); + + // Fields from the signed PayPro response itself (chain/network/ + // currency), as promoted by PayProV2.processResponse, rather than + // fields on the unverified transaction proposal. + const createSignedPaypro = (overrides = {}) => createAccountPaypro({ + chain: 'eth', + network: 'livenet', + currency: 'ETH', + ...overrides + }); + + it('accepts a matching ETH PayPro proposal whose signed chain/network/currency agree', function() { + Verifier.checkPaypro(createAccountTxp('eth', { network: 'livenet' }), createSignedPaypro()).should.equal(true); + }); + + it('rejects a signed PayPro chain mismatch', function() { + const txp = createAccountTxp('eth', { network: 'livenet' }); + Verifier.checkPaypro(txp, createSignedPaypro({ chain: 'matic' })).should.equal(false); + }); + + it('rejects a signed PayPro network mismatch', function() { + const txp = createAccountTxp('eth', { network: 'livenet' }); + Verifier.checkPaypro(txp, createSignedPaypro({ network: 'testnet' })).should.equal(false); + }); + + it('rejects a signed PayPro currency mismatch', function() { + const txp = createAccountTxp('eth', { network: 'livenet', coin: 'eth' }); + Verifier.checkPaypro(txp, createSignedPaypro({ currency: 'USDC' })).should.equal(false); + }); + + // Currency comparison must resolve through + // Utils.getCurrencyCodeFromCoinAndChain, not a naive + // `coin.toUpperCase()`, which would both reject legitimate aliased + // proposals and accept ones that skip a required alias. + it('accepts a matching PayPro currency when the legacy POL/MATIC alias applies', function() { + const txp = createAccountTxp('matic', { network: 'livenet', coin: 'pol' }); + Verifier.checkPaypro(txp, createSignedPaypro({ chain: 'matic', currency: 'MATIC' })).should.equal(true); + }); + + it('accepts a matching PayPro currency when the legacy USDP/PAX alias applies', function() { + // PayProV2.selectPaymentOption rewrites an outgoing 'USDP' request + // to 'PAX' before it reaches the PayPro server (payproV2.ts), so a + // real signed response for a `coin: 'usdp'` proposal carries + // currency 'PAX', not 'USDP'. That request-time rewrite is exactly + // why this equivalence must be checked here, not a reason to skip it. + const txp = createAccountTxp('eth', { network: 'livenet', coin: 'usdp' }); + Verifier.checkPaypro(txp, createSignedPaypro({ chain: 'eth', currency: 'PAX' })).should.equal(true); + }); + + it('accepts a matching PayPro currency when the Matic USDC chain-suffix alias applies', function() { + const txp = createAccountTxp('matic', { network: 'livenet', coin: 'usdc' }); + Verifier.checkPaypro(txp, createSignedPaypro({ chain: 'matic', currency: 'USDCn_m' })).should.equal(true); + }); + + it('rejects a PayPro currency that skips the required Matic USDC chain-suffix alias', function() { + // getCurrencyCodeFromCoinAndChain('usdc', 'matic') is 'USDCn_m', + // not bare 'USDC' - an implementation that compares + // `coin.toUpperCase()` directly would wrongly accept this. + const txp = createAccountTxp('matic', { network: 'livenet', coin: 'usdc' }); + Verifier.checkPaypro(txp, createSignedPaypro({ chain: 'matic', currency: 'USDC' })).should.equal(false); + }); + }); + }); + }); + + describe('checkTxProposal PayPro boundary', function() { + const amount = 10000; + const merchantAddress = 'LQqWdV81RmiEzXoACvWDQPZEXXU1Q16suH'; + const substitutedAddress = 'Lcyaicjq2aFgcgRX5mDhhQkXN8RFvzWowa'; + const paypro = { + instructions: [{ toAddress: merchantAddress, amount }] + }; + const createTxp = toAddress => ({ + version: 3, + chain: 'ltc', + coin: 'ltc', + amount, + outputs: [{ toAddress, amount }] + }); + let checkTxProposalSignatureStub; + + beforeEach(function() { + checkTxProposalSignatureStub = sinon + .stub(Verifier, 'checkTxProposalSignature') + .returns(true); + }); + + afterEach(function() { + checkTxProposalSignatureStub.restore(); + }); + + it('accepts a matching LTC PayPro proposal through checkTxProposal', function() { + Verifier.checkTxProposal({}, createTxp(merchantAddress), { paypro }).should.equal(true); + sinon.assert.calledOnce(checkTxProposalSignatureStub); + }); + + it('rejects a substituted LTC PayPro destination through checkTxProposal', function() { + Verifier.checkTxProposal({}, createTxp(substitutedAddress), { paypro }).should.equal(false); + sinon.assert.calledOnce(checkTxProposalSignatureStub); + }); + + // Account-chain equivalent of the LTC boundary control above. + const evmAddress = '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'; + const differentEvmAddress = '0x37d7B3bBD88EFdE6a93cF74D2F5b0385D3E3B08A'; + const evmPaypro = { instructions: [{ toAddress: evmAddress, amount }] }; + const createEvmTxp = toAddress => ({ + version: 3, + chain: 'eth', + coin: 'eth', + amount, + outputs: [{ toAddress, amount }] + }); + + it('accepts a matching ETH PayPro proposal through checkTxProposal', function() { + Verifier.checkTxProposal({}, createEvmTxp(evmAddress), { paypro: evmPaypro }).should.equal(true); + sinon.assert.calledOnce(checkTxProposalSignatureStub); + }); + + it('rejects a substituted ETH PayPro destination through checkTxProposal', function() { + Verifier.checkTxProposal({}, createEvmTxp(differentEvmAddress), { paypro: evmPaypro }).should.equal(false); + sinon.assert.calledOnce(checkTxProposalSignatureStub); + }); + + // Same destination and amount, different calldata - the destination + // check alone is not be enough to accept an EVM PayPro proposal. + const evmData = '0xa9059cbb0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda94000000000000000000000000000000000000000000000000000000000000989680'; + const differentEvmData = '0xa9059cbb0000000000000000000000009858effd232b4033e47d90003d41ec34ecaeda940000000000000000000000000000000000000000000000000000000000000001'; + const evmDataPaypro = { instructions: [{ toAddress: evmAddress, amount, to: evmAddress, value: amount, data: evmData }] }; + const createEvmTxpWithData = data => ({ + version: 3, + chain: 'eth', + coin: 'eth', + amount, + outputs: [{ toAddress: evmAddress, amount, data }] + }); + + it('accepts a matching ETH PayPro proposal with identical calldata through checkTxProposal', function() { + Verifier.checkTxProposal({}, createEvmTxpWithData(evmData), { paypro: evmDataPaypro }).should.equal(true); + sinon.assert.calledOnce(checkTxProposalSignatureStub); + }); + + it('rejects an ETH PayPro proposal whose calldata was substituted through checkTxProposal', function() { + Verifier.checkTxProposal({}, createEvmTxpWithData(differentEvmData), { paypro: evmDataPaypro }).should.equal(false); + sinon.assert.calledOnce(checkTxProposalSignatureStub); + }); + }); + describe('checkProposalCreation', function() { const inputs = [ { diff --git a/packages/bitcore-wallet-service/test/integration/server.test.ts b/packages/bitcore-wallet-service/test/integration/server.test.ts index e2520dc478..ace32dc13a 100644 --- a/packages/bitcore-wallet-service/test/integration/server.test.ts +++ b/packages/bitcore-wallet-service/test/integration/server.test.ts @@ -1662,6 +1662,7 @@ describe('Wallet service', function() { message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 }, n: 1, copayerId: legitCopayerId, + version: 1.1, }); session.sharedPublicKey = 'dummy-shared-public-key'; await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id }); @@ -1728,6 +1729,7 @@ describe('Wallet service', function() { message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 }, n: 1, copayerId: ancillaryDerivedCopayerId, + version: 1.1, }); session.sharedPublicKey = 'dummy-shared-public-key'; await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id });