mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-08-11 00:47:42 +00:00
Accept non-mainnet Bitcoin addresses in debug builds only
The CDK fakewallet backend settles onchain melts against a regtest chain, so refusing bcrt1... addresses everywhere made the payout rail impossible to exercise end to end. Debug builds now pay them; release builds still refuse, and there is no setting that changes that — the only way to pay a testnet address is to be running a debug bundle you built yourself. The flag is an explicit `allowNonMainnet` parameter defaulting to ALLOW_NON_MAINNET_PAY (= __DEV__), not a global read inside the check: it keeps the decision testable, and passing `true` from production code would be a visible thing to review. It relaxes the NETWORK only — a debug build is still not a build that pays typos, and the checksum is still verified. Both enforcement points keep the guard (parser and TransferOperationApi.prepare), and a non-mainnet payout is logged as a warning when it happens. Tests pin both sides of the rule explicitly rather than depending on whatever __DEV__ is under jest. 320/320, 87 tsc (unchanged baseline).
This commit is contained in:
@@ -127,31 +127,52 @@ describe('decodeBitcoinAddress', () => {
|
||||
})
|
||||
|
||||
describe('isPayableBitcoinAddress', () => {
|
||||
it('accepts mainnet addresses of every script type', () => {
|
||||
// The flag is passed explicitly rather than left to default, so these pin BOTH sides
|
||||
// of the rule regardless of what `__DEV__` happens to be under jest.
|
||||
const RELEASE = {allowNonMainnet: false}
|
||||
const DEBUG = {allowNonMainnet: true}
|
||||
|
||||
it('accepts mainnet addresses of every script type, in any build', () => {
|
||||
for (const address of [P2PKH, P2SH, P2WPKH, P2WSH, P2TR]) {
|
||||
expect(isPayableBitcoinAddress(address)).toBe(true)
|
||||
expect(isPayableBitcoinAddress(address, RELEASE)).toBe(true)
|
||||
expect(isPayableBitcoinAddress(address, DEBUG)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The loss vector this exists for: a CDK fakewallet topup hands the user a REGTEST
|
||||
* deposit address, it sits in their clipboard, and Pay auto-pastes it. A regtest
|
||||
* address is never a payment — it is a mistake, and an irreversible one if a mint
|
||||
* broadcasts against it.
|
||||
* deposit address, it sits in their clipboard, and Pay auto-pastes it. In a build a
|
||||
* user can install, that is never a payment — it is a mistake.
|
||||
*/
|
||||
it('refuses the CDK fakewallet regtest address', () => {
|
||||
expect(isPayableBitcoinAddress(FAKEWALLET_REGTEST)).toBe(false)
|
||||
it('refuses the CDK fakewallet regtest address in a release build', () => {
|
||||
expect(isPayableBitcoinAddress(FAKEWALLET_REGTEST, RELEASE)).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses every non-mainnet address', () => {
|
||||
expect(isPayableBitcoinAddress(TESTNET_P2WPKH)).toBe(false)
|
||||
expect(isPayableBitcoinAddress(TESTNET_P2PKH)).toBe(false)
|
||||
expect(isPayableBitcoinAddress('bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7k1234a')).toBe(false)
|
||||
/**
|
||||
* ...and accepts it in a debug build, because settling a melt against the fakewallet's
|
||||
* regtest chain is the only way to exercise the onchain payout rail end to end.
|
||||
*/
|
||||
it('accepts the CDK fakewallet regtest address in a debug build', () => {
|
||||
expect(isPayableBitcoinAddress(FAKEWALLET_REGTEST, DEBUG)).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses input that is not an address at all', () => {
|
||||
expect(isPayableBitcoinAddress('nope')).toBe(false)
|
||||
expect(isPayableBitcoinAddress('')).toBe(false)
|
||||
it('refuses every non-mainnet address in a release build', () => {
|
||||
expect(isPayableBitcoinAddress(TESTNET_P2WPKH, RELEASE)).toBe(false)
|
||||
expect(isPayableBitcoinAddress(TESTNET_P2PKH, RELEASE)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts testnet as well as regtest in a debug build', () => {
|
||||
expect(isPayableBitcoinAddress(TESTNET_P2WPKH, DEBUG)).toBe(true)
|
||||
expect(isPayableBitcoinAddress(TESTNET_P2PKH, DEBUG)).toBe(true)
|
||||
})
|
||||
|
||||
// The network flag relaxes the NETWORK, never the checksum. A debug build must not
|
||||
// become a build that pays typos.
|
||||
it('refuses input that is not an address at all, even in a debug build', () => {
|
||||
expect(isPayableBitcoinAddress('nope', DEBUG)).toBe(false)
|
||||
expect(isPayableBitcoinAddress('', DEBUG)).toBe(false)
|
||||
// Corrupted base58 checksum, mainnet prefix.
|
||||
expect(isPayableBitcoinAddress('1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3', DEBUG)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -134,26 +134,57 @@ export const decodeBitcoinAddress = (
|
||||
export const isBitcoinAddress = (address: string): boolean =>
|
||||
decodeBitcoinAddress(address) !== undefined
|
||||
|
||||
/**
|
||||
* Are non-mainnet addresses payable in this build?
|
||||
*
|
||||
* Debug builds only. Development is the one situation where paying a regtest address is
|
||||
* the POINT rather than a mistake: the CDK fakewallet backend settles onchain melts
|
||||
* against a regtest chain, so a release-only guard would make the whole rail
|
||||
* untestable end to end.
|
||||
*
|
||||
* A release build always refuses. The flag cannot be turned on by a user, and there is
|
||||
* no setting for it — the only way to pay a testnet address is to be running a debug
|
||||
* bundle you built yourself.
|
||||
*
|
||||
* `typeof` guarded because this module is also loaded by tests outside the React Native
|
||||
* environment, where `__DEV__` is not defined.
|
||||
*/
|
||||
export const ALLOW_NON_MAINNET_PAY =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__ === true
|
||||
|
||||
/**
|
||||
* Is this an address Minibits is allowed to pay?
|
||||
*
|
||||
* Mainnet only. The wallet holds mainnet-backed ecash and the mints melt to the real
|
||||
* chain, so a testnet or regtest address is never a payment — it is a mistake, and an
|
||||
* irreversible one if a mint broadcasts against it.
|
||||
* Mainnet only, in any build a user can install. The wallet holds mainnet-backed ecash
|
||||
* and the mints melt to the real chain, so a testnet or regtest address is not a
|
||||
* payment — it is a mistake, and an irreversible one if a mint broadcasts against it.
|
||||
*
|
||||
* This is not hypothetical. The CDK fakewallet backend hands out REGTEST deposit
|
||||
* addresses (`bcrt1q…`) for onchain topup quotes, so anyone testing this wallet ends
|
||||
* up with one in their clipboard. Pasting it back into Pay must fail loudly, not
|
||||
* quietly reach the mint.
|
||||
*
|
||||
* `allowNonMainnet` defaults to `ALLOW_NON_MAINNET_PAY` (debug builds) and is an
|
||||
* explicit parameter rather than a global read so that the decision is testable and so
|
||||
* that callers cannot accidentally widen it — passing `true` from production code would
|
||||
* be a visible thing to review.
|
||||
*
|
||||
* Kept separate from `decodeBitcoinAddress` on purpose: decoding tells you WHAT an
|
||||
* address is (and needs to recognise testnet in order to say so), while this decides
|
||||
* whether we are willing to send money to it. Collapsing the two would leave us
|
||||
* unable to tell "that is not an address" apart from "that is not OUR network", and
|
||||
* the second deserves its own error message.
|
||||
* whether we are willing to send money to it. Collapsing the two would leave us unable
|
||||
* to tell "that is not an address" apart from "that is not OUR network", and the second
|
||||
* deserves its own error message.
|
||||
*/
|
||||
export const isPayableBitcoinAddress = (address: string): boolean =>
|
||||
decodeBitcoinAddress(address)?.network === 'mainnet'
|
||||
export const isPayableBitcoinAddress = (
|
||||
address: string,
|
||||
options?: {allowNonMainnet?: boolean},
|
||||
): boolean => {
|
||||
const decoded = decodeBitcoinAddress(address)
|
||||
if (!decoded) return false
|
||||
if (decoded.network === 'mainnet') return true
|
||||
|
||||
return (options?.allowNonMainnet ?? ALLOW_NON_MAINNET_PAY) === true
|
||||
}
|
||||
|
||||
export type Bip21Data = {
|
||||
address: string
|
||||
@@ -244,6 +275,7 @@ export const findBitcoinAddress = (text: string): string | undefined => {
|
||||
}
|
||||
|
||||
export const BitcoinUtils = {
|
||||
ALLOW_NON_MAINNET_PAY,
|
||||
decodeBitcoinAddress,
|
||||
isBitcoinAddress,
|
||||
isPayableBitcoinAddress,
|
||||
|
||||
@@ -40,11 +40,15 @@ export type BtcAddressData = {
|
||||
/**
|
||||
* Refuse a Bitcoin address we are not willing to pay.
|
||||
*
|
||||
* Mainnet only, and checksum-verified. Both failures get their own message, because
|
||||
* "that is not a Bitcoin address" and "that is a Bitcoin address on the wrong network"
|
||||
* are completely different mistakes — and the second one is the likely one: the CDK
|
||||
* fakewallet hands out REGTEST addresses (`bcrt1q…`) for onchain topup quotes, so anyone
|
||||
* testing this wallet has one sitting in their clipboard.
|
||||
* Checksum-verified, and mainnet-only in any build a user can install. Both failures get
|
||||
* their own message, because "that is not a Bitcoin address" and "that is a Bitcoin
|
||||
* address on the wrong network" are completely different mistakes — and the second is
|
||||
* the likely one: the CDK fakewallet hands out REGTEST addresses (`bcrt1q…`) for onchain
|
||||
* topup quotes, so anyone testing this wallet has one sitting in their clipboard.
|
||||
*
|
||||
* Debug builds accept non-mainnet, because settling an onchain melt against the
|
||||
* fakewallet's regtest chain is the only way to exercise this rail end to end. See
|
||||
* `BitcoinUtils.ALLOW_NON_MAINNET_PAY`.
|
||||
*/
|
||||
const assertPayableBtcAddress = function (address: string): string {
|
||||
const decoded = BitcoinUtils.decodeBitcoinAddress(address)
|
||||
@@ -56,7 +60,7 @@ const assertPayableBtcAddress = function (address: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
if (decoded.network !== 'mainnet') {
|
||||
if (!BitcoinUtils.isPayableBitcoinAddress(decoded.address)) {
|
||||
throw new AppError(
|
||||
Err.VALIDATION_ERROR,
|
||||
`This is a ${decoded.network} Bitcoin address. Minibits can only pay to mainnet addresses.`,
|
||||
@@ -64,6 +68,12 @@ const assertPayableBtcAddress = function (address: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
if (decoded.network !== 'mainnet') {
|
||||
log.warn('[assertPayableBtcAddress] Paying a NON-MAINNET address (debug build)', {
|
||||
network: decoded.network,
|
||||
})
|
||||
}
|
||||
|
||||
return decoded.address
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,11 @@ async function prepare(input: PrepareTransferInput): Promise<PreparedTransferDat
|
||||
// non-mainnet addresses, but this is the last point before real money moves and an
|
||||
// onchain payment cannot be taken back — so the check lives here too, where every
|
||||
// caller (screen, NWC, a future one) must pass through it.
|
||||
//
|
||||
// Debug builds pass a non-mainnet address through (BitcoinUtils.ALLOW_NON_MAINNET_PAY):
|
||||
// the CDK fakewallet settles onchain melts against a regtest chain, and refusing its
|
||||
// addresses here would make this rail impossible to exercise end to end. A release
|
||||
// build always refuses, and there is no setting that changes that.
|
||||
if (method.method === 'onchain' && !BitcoinUtils.isPayableBitcoinAddress(resolved.paymentRequest)) {
|
||||
throw new ValidationError(
|
||||
'Not a mainnet Bitcoin address. Minibits will not pay to testnet or regtest addresses.',
|
||||
|
||||
Reference in New Issue
Block a user