What happens
packages/bitcore-wallet-service/src/lib/server.ts, in the createTx flow:
opts.coin = opts.coin || wallet.coin; // coin defaulted from the wallet
// chain is not
and shortly after:
chain: opts.chain?.toLowerCase() || Utils.getChain(opts.coin),
A client that omits chain therefore falls through to Utils.getChain(coin):
if (Constants.BITPAY_SUPPORTED_ETH_ERC20.includes(normalizedChain) ||
!Constants.CHAINS.includes(normalizedChain)) {
normalizedChain = 'eth';
}
matic appears in both CHAINS and BITPAY_SUPPORTED_ETH_ERC20, and the ERC-20 branch is checked first, so getChain('matic') === 'eth'. The proposal is stored as chain: 'eth', coin: 'matic' on a wallet whose own record says chain: 'matic'.
matic is the only entry where this happens — every other chain in CHAINS returns itself — so the bug is invisible on btc, eth, arb, base, op, xrp and the rest.
Why it matters
Verifier.checkTxProposalSignature derives the creator's copayer id from the proposal's chain:
const chain = txp.chain?.toLowerCase() || Utils.getChain(txp.coin);
const creatorKeys = credentials.publicKeyRing.find(
item => Utils.xPubToCopayerId(chain, item.xPubKey) === txp.creatorId);
The copayer ids in the key ring were derived with matic, so the lookup with eth matches nothing, checkTxProposalSignature returns false, and the client raises:
bwc.ErrorSERVER_COMPROMISED: Server response could not be verified.
getTxProposals verifies every pending proposal and throws if any one fails, so a single mis-stamped proposal makes the wallet unusable rather than just that proposal unusable. In bitcore-cli this includes the main menu, so the wallet cannot be opened even to delete the offending proposal — it has to be removed through the API, or by waiting out DELETE_LOCKTIME if you are not its creator.
Reproduction
- Create a 2-of-2 TSS wallet on
matic.
- Create a proposal through a client that does not send
chain — bitcore-cli's txpParams in packages/bitcore-cli/src/commands/transaction.ts does not include it.
- Fetch it: the stored proposal has
chain: 'eth', coin: 'matic', and Verifier.checkTxProposal returns false.
Adding chain to that client's txpParams works around it, which confirms the path.
Suggested fixes
- Default it alongside
coin in createTx: opts.chain = opts.chain || wallet.chain; — other call sites in the same file already do the equivalent (opts.chain = opts.coin for stored clients, and opts.chain || opts.coin || Defaults.COIN elsewhere).
- Separately,
getChain should not treat a first-class chain as an ERC-20 — checking CHAINS before BITPAY_SUPPORTED_ETH_ERC20 would make getChain('matic') === 'matic', though that is a behaviour change worth considering on its own.
- Optionally, have
getTxProposals reject only the proposals that fail verification rather than the whole response, so one bad proposal cannot lock a client out of its wallet.
What happens
packages/bitcore-wallet-service/src/lib/server.ts, in thecreateTxflow:and shortly after:
A client that omits
chaintherefore falls through toUtils.getChain(coin):maticappears in bothCHAINSandBITPAY_SUPPORTED_ETH_ERC20, and the ERC-20 branch is checked first, sogetChain('matic') === 'eth'. The proposal is stored aschain: 'eth',coin: 'matic'on a wallet whose own record sayschain: 'matic'.maticis the only entry where this happens — every other chain inCHAINSreturns itself — so the bug is invisible onbtc,eth,arb,base,op,xrpand the rest.Why it matters
Verifier.checkTxProposalSignaturederives the creator's copayer id from the proposal's chain:The copayer ids in the key ring were derived with
matic, so the lookup withethmatches nothing,checkTxProposalSignaturereturns false, and the client raises:getTxProposalsverifies every pending proposal and throws if any one fails, so a single mis-stamped proposal makes the wallet unusable rather than just that proposal unusable. In bitcore-cli this includes the main menu, so the wallet cannot be opened even to delete the offending proposal — it has to be removed through the API, or by waiting outDELETE_LOCKTIMEif you are not its creator.Reproduction
matic.chain—bitcore-cli'stxpParamsinpackages/bitcore-cli/src/commands/transaction.tsdoes not include it.chain: 'eth',coin: 'matic', andVerifier.checkTxProposalreturns false.Adding
chainto that client'stxpParamsworks around it, which confirms the path.Suggested fixes
coinincreateTx:opts.chain = opts.chain || wallet.chain;— other call sites in the same file already do the equivalent (opts.chain = opts.coinfor stored clients, andopts.chain || opts.coin || Defaults.COINelsewhere).getChainshould not treat a first-class chain as an ERC-20 — checkingCHAINSbeforeBITPAY_SUPPORTED_ETH_ERC20would makegetChain('matic') === 'matic', though that is a behaviour change worth considering on its own.getTxProposalsreject only the proposals that fail verification rather than the whole response, so one bad proposal cannot lock a client out of its wallet.