Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/bitcoin-wallet-snap/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,9 @@ ESPLORA_REGTEST=
# Default: https://price.api.cx.metamask.io
# Required: false
PRICE_API_URL=

# Optional fallback Esplora endpoint for bitcoin mainnet reads (stale-balances fix). Used only when
# the primary endpoint keeps failing at the transport level (e.g. the Infura empty-404/no-CORS
# failure under Chrome HTTP/2 connection coalescing). Unset by default: sending wallet addresses
# to a third-party endpoint has privacy implications.
# ESPLORA_BITCOIN_FALLBACK=https://blockstream.info/api
1 change: 1 addition & 0 deletions packages/bitcoin-wallet-snap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"ts-jest": "^29.4.14",
"typescript": "~5.8.3",
"uuid": "^14.0.0",
"webpack": "5.107.2",
"wif": "^5.0.0"
},
"engines": {
Expand Down
14 changes: 14 additions & 0 deletions packages/bitcoin-wallet-snap/snap.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/* eslint-disable import-x/no-nodejs-modules -- Snap configuration executes in Node.js. */
import type { SnapConfig } from '@metamask/snaps-cli';
import { merge } from '@metamask/snaps-cli';
import { config as dotenv } from 'dotenv';
import { resolve } from 'path';
import { ProvidePlugin } from 'webpack';

dotenv();

Expand All @@ -16,6 +19,7 @@ const config: SnapConfig = {
LOG_LEVEL: process.env.LOG_LEVEL,
DEFAULT_ADDRESS_TYPE: process.env.DEFAULT_ADDRESS_TYPE,
ESPLORA_BITCOIN: process.env.ESPLORA_BITCOIN,
ESPLORA_BITCOIN_FALLBACK: process.env.ESPLORA_BITCOIN_FALLBACK,
ESPLORA_TESTNET: process.env.ESPLORA_TESTNET,
ESPLORA_TESTNET4: process.env.ESPLORA_TESTNET4,
ESPLORA_SIGNET: process.env.ESPLORA_SIGNET,
Expand All @@ -29,6 +33,16 @@ const config: SnapConfig = {
},
polyfills: true,
experimental: { wasm: true },
// `createFetchPolicy` (cockatiel) times calls with `performance.now()`,
// which snaps don't have.
customizeWebpackConfig: (webpackConfig) =>
merge(webpackConfig, {
plugins: [
new ProvidePlugin({
performance: '@metamask/snap-networks-utils/performance-shim',
}),
],
}),
preinstalled: {
removable: false,
hidden: true,
Expand Down
16 changes: 16 additions & 0 deletions packages/bitcoin-wallet-snap/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
enums,
number,
object,
optional,
string,
} from '@metamask/superstruct';

Expand Down Expand Up @@ -42,6 +43,17 @@ export const ConfigStruct = object({
signet: defaultedUrlStruct('https://mutinynet.com/api'),
regtest: defaultedUrlStruct('http://localhost:8094/regtest/api'),
}),
/**
* Optional fallback Esplora endpoints, used only when the primary fails
* at the transport level (see `withEndpointFallback`). Empty disables it.
*/
fallbackUrl: optional(
object({
bitcoin: coerce(optional(string()), string(), (value: string) =>
value === '' ? undefined : value,
),
}),
),
explorerUrl: object({
bitcoin: defaultedUrlStruct('https://mempool.space'),
testnet: defaultedUrlStruct('https://mempool.space/testnet'),
Expand Down Expand Up @@ -82,6 +94,10 @@ export const ENVIRONMENT = {
signet: process.env.ESPLORA_SIGNET,
regtest: process.env.ESPLORA_REGTEST,
},
// Only set when configured, so the default config is unchanged.
...(process.env.ESPLORA_BITCOIN_FALLBACK
? { fallbackUrl: { bitcoin: process.env.ESPLORA_BITCOIN_FALLBACK } }
: {}),
explorerUrl: {
bitcoin: process.env.BITCOIN_EXPLORER,
testnet: process.env.TESTNET_EXPLORER,
Expand Down
38 changes: 38 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type { Inscription } from './meta-protocols';
import type { AccountStateSnapshot } from './snap';
import type { TransactionBuilder } from './transaction';

/** A BIP-32 keychain of an account: receive or change addresses. */
export type Keychain = 'external' | 'internal';

/**
* A Bitcoin account.
*/
Expand Down Expand Up @@ -82,6 +85,32 @@ export type BitcoinAccount = {
*/
peekAddress(index: number): AddressInfo;

/**
* Get the address at a given index of a keychain, without revealing it.
*
* @param keychain - 'external' (receive) or 'internal' (change).
* @param index - derivation index.
* @returns the address
*/
peekKeychainAddress(keychain: Keychain, index: number): AddressInfo;

/**
* Highest derivation index of the keychain that received an output the
* wallet knows of (the keychain's last active index for a gap scan).
*
* @param keychain - 'external' (receive) or 'internal' (change).
* @returns the index, or undefined if the keychain has no known output
*/
lastUsedIndex(keychain: Keychain): number | undefined;

/**
* Highest revealed derivation index of the keychain.
*
* @param keychain - 'external' (receive) or 'internal' (change).
* @returns the index, or undefined if no address is revealed
*/
lastRevealedIndex(keychain: Keychain): number | undefined;

/**
* Get the next unused address. This will reveal a new address if there is no unused address.
* Note that the account needs to be persisted for this operation to be idempotent.
Expand Down Expand Up @@ -126,6 +155,15 @@ export type BitcoinAccount = {
*/
applyUpdate(update: Update): void;

/**
* Whether the account's wallet has applied at least one chain update (sync
* or full scan). A wallet that never synced still sits on the genesis
* checkpoint (height 0), and its zero balance means "unknown", not "empty".
*
* @returns true if the wallet has synchronized with the chain at least once
*/
hasSynchronized(): boolean;

/**
* Extract the change set if it exists.
*
Expand Down
12 changes: 12 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ export type BlockchainClient = {
*/
fullScan(account: BitcoinAccount, mode?: 'discovery' | 'scan'): Promise<void>;

/**
* Complete a discovery-gap full scan that found activity to the standard
* gap. Only the addresses the standard gap adds beyond what the discovery
* scan already fetched are checked; the standard-gap full scan runs only if
* one of them has history or the check fails, so a used account keeps its
* full address coverage without refetching the addresses already scanned.
* Note that this operation may modify the account in place.
*
* @param account - the account the discovery-gap scan was applied to.
*/
extendDiscoveryScan(account: BitcoinAccount): Promise<void>;

/**
* Perform a sync operation on the account.
* Note that this operation modifies the account in place.
Expand Down
3 changes: 3 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export type ChainConfig = {
url: {
[network in Network]: string;
};
fallbackUrl?: {
[network in Network]?: string | undefined;
};
explorerUrl: {
[network in Network]: string;
};
Expand Down
118 changes: 118 additions & 0 deletions packages/bitcoin-wallet-snap/src/entities/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,124 @@ export class ExternalServiceError extends BaseError {
}
}

/**
* Message of {@link BalanceNotSynchronizedError}. It is the stable marker of
* that expected state: the keyring SDK's `handleKeyringRequest` re-creates
* every non-`SnapError` as a plain `Error` carrying only the message, so the
* class, name and data are lost before the handler middleware sees it.
*/
export const BALANCE_NOT_SYNCHRONIZED_MESSAGE =
'Account balance unavailable: the account has not been synchronized yet';

/**
* Thrown when an account's balance is requested before the account completed
* its first synchronization (full scan). This is an expected, transient state
* (e.g. right after onboarding, or while the network is unreachable), not a
* failure: it reaches the client as a `DisconnectedError` so the balance is
* shown as unavailable, but it is never reported to Sentry.
*/
export class BalanceNotSynchronizedError extends ExternalServiceError {
constructor(data?: Record<string, Json>) {
super(BALANCE_NOT_SYNCHRONIZED_MESSAGE, data);
this.name = 'BalanceNotSynchronizedError';
}
}

/**
* Message of {@link BalanceStaleError}, its stable marker (see
* {@link BALANCE_NOT_SYNCHRONIZED_MESSAGE}).
*/
export const BALANCE_STALE_MESSAGE =
'Account balance unavailable: synchronization has been failing since the last successful sync';

/**
* Thrown when an account's balance is requested while its synchronization has
* been failing for a while (see `AccountUseCases.isBalanceStale`): the stored
* balance may be outdated, so it is not reported as current. Like
* {@link BalanceNotSynchronizedError}, an expected, transient state: the
* client shows the balance as unavailable, it is never reported to Sentry,
* and the next successful sync clears it.
*/
export class BalanceStaleError extends ExternalServiceError {
constructor(data?: Record<string, Json>) {
super(BALANCE_STALE_MESSAGE, data);
this.name = 'BalanceStaleError';
}
}

/**
* Whether an error is (or wraps) one of the given expected-state errors, even
* after it was re-created, wrapped or serialized along the way: matches the
* class, then the stable message marker on the error, its `cause` chain or a
* serialized error object.
*
* @param error - The error to check.
* @param classes - The error classes.
* @param markers - The stable message markers.
* @returns `true` if the error matches.
*/
function matchesExpectedState(
error: unknown,
classes: (abstract new (...args: never[]) => Error)[],
markers: string[],
): boolean {
const hasMarker = (text: string): boolean =>
markers.some((marker) => text.includes(marker));
let current: unknown = error;
for (let depth = 0; depth < 5 && current; depth += 1) {
const candidate = current;
if (classes.some((errorClass) => candidate instanceof errorClass)) {
return true;
}
if (typeof current === 'string') {
return hasMarker(current);
}
if (typeof current !== 'object') {
return false;
}
const { message, cause } = current as {
message?: unknown;
cause?: unknown;
};
if (typeof message === 'string' && hasMarker(message)) {
return true;
}
current = cause;
}
return false;
}

/**
* Whether an error is (or wraps) a {@link BalanceNotSynchronizedError}, even
* after it was re-created, wrapped or serialized along the way.
*
* @param error - The error to check.
* @returns `true` for the expected "not synchronized yet" state.
*/
export function isBalanceNotSynchronizedError(error: unknown): boolean {
return matchesExpectedState(
error,
[BalanceNotSynchronizedError],
[BALANCE_NOT_SYNCHRONIZED_MESSAGE],
);
}

/**
* Whether an error is (or wraps) an expected "balance unavailable" state:
* {@link BalanceNotSynchronizedError} or {@link BalanceStaleError}. These are
* returned to the client but never reported to Sentry.
*
* @param error - The error to check.
* @returns `true` for an expected balance-unavailable state.
*/
export function isBalanceUnavailableError(error: unknown): boolean {
return matchesExpectedState(
error,
[BalanceNotSynchronizedError, BalanceStaleError],
[BALANCE_NOT_SYNCHRONIZED_MESSAGE, BALANCE_STALE_MESSAGE],
);
}

/**
* Error thrown for wallet-specific failures.
* Useful for signaling wallet operation errors.
Expand Down
Loading
Loading