Skip to content
Draft
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
32 changes: 32 additions & 0 deletions modules/abstract-utxo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ Concrete implementations are provided in the `src/impl/` directory:
- Support for multiple address formats (base58, bech32, cashaddr for BCH)
- Chain/index-based address generation for fixed script wallets

### Address Derivation Schemes

Fixed script wallets derive every address from the same three root keys (user, backup, BitGo) at
`<prefix>/<chain>/<index>`, applying the identical path to all three keys. Two schemes exist; the scheme of an address
is carried in the Address document as `coinSpecific.derivationPrefix`:

| Scheme | `derivationPrefix` | Prefix used | Notes |
| ------ | ------------------ | ----------- | ----- |
| Legacy | absent | `0/0` (the wallet's existing key path) | Every wallet created so far. A missing prefix always means legacy. |
| BIP44-style | `44/<coinType>/<h1>/<h2>` | the value itself | Per-wallet: two wallets sharing the same root keys get distinct addresses. |

For the BIP44-style scheme:

- `coinType` is the coin's `bip44CoinType` from the `@bitgo/statics` registry (btc 0, ltc 2, doge 3, dash 5, zec 133,
bch 145, btg 156, bsv 236, bcha 899; testnets inherit their mainnet's value). It is read from the coin instance
(`coin.getConfig().bip44CoinType`), never from the wasm coin name.
- `h1`/`h2` (the "account") are derived from a SHA-256 hash of the three neutered root keys
(`fixedScriptWallet.deriveWalletAccount`), and the whole prefix is produced by `fixedScriptWallet.deriveWalletPrefix`.
All components are non-hardened, so addresses can be derived from xpubs alone.
- The prefix is **never trusted**. `isWalletAddress` and `deriveAddress` recompute the canonical prefix from the three
keys and the coin type and reject any other value (strict in both directions: a legacy address presented with a
prefix fails, and a bip44 address presented without one fails). A coin without a BIP44 coin type throws when it is
given a prefix; `allowSkipVerifyAddress` cannot skip this check.
- PSBT outputs are classified from the full derivation path recorded in the PSBT (all components except the last two
are the prefix), under the same canonical-equality check, so bip44 change outputs are reported as change.
- SDK versions that predate this check throw `UnexpectedAddressError` for a bip44 address.

**This is not the Safes scheme.** Safes (`@bitgo/sdk-lib-safes`) derive a child key per wallet from the enterprise key
with hardened levels and a server-side counter, so each Safe has its own keys. Here the wallet keeps the same root
keys as any other wallet that uses them; only the non-hardened prefix differs, and the account comes from a hash of
the three xpubs rather than a counter. The two share only the coin-type registry in `@bitgo/statics`.

### Recovery Operations
- **Backup Key Recovery**: Recover funds using backup key when BitGo is unavailable
- **Cross-Chain Recovery**: Recover coins sent to wrong chain (e.g., BTC sent to LTC address)
Expand Down
8 changes: 8 additions & 0 deletions modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,12 +710,17 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici
throw new Error('missing required param keychains');
}

// A missing derivationPrefix means the legacy scheme. A present one is only accepted if it equals the canonical
// prefix recomputed from the three keys and this coin's bip44 coin type; this is enforced in generateAddress
// and cannot be skipped (an undefined coin type throws).
assertFixedScriptWalletAddress(this.wasmName, {
address,
keychains,
format: params.format ?? 'base58',
chain,
index,
derivationPrefix: params.coinSpecific?.derivationPrefix,
bip44CoinType: this.getConfig().bip44CoinType,
});

return true;
Expand Down Expand Up @@ -745,6 +750,9 @@ export abstract class AbstractUtxoCoin extends BaseCoin implements Musig2Partici
chain,
index,
format,
// coin type is resolved from the coin instance: `this.wasmName` is not a statics name (e.g. `tbtcreg`)
derivationPrefix: params.coinSpecific?.derivationPrefix,
bip44CoinType: this.getConfig().bip44CoinType,
});

return { address, chain, index };
Expand Down
73 changes: 73 additions & 0 deletions modules/abstract-utxo/src/address/derivationPrefix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Triple, UnexpectedAddressError } from '@bitgo/sdk-core';
import { fixedScriptWallet } from '@bitgo/wasm-utxo';

/**
* Prefix of the legacy scheme: every wallet key is derived at `m/0/0/<chain>/<index>`.
* An address without a `derivationPrefix` always uses this prefix.
*/
export const LEGACY_DERIVATION_PREFIX = '0/0';

/**
* Strip a single leading `m/` or `/` so prefixes recorded in PSBTs (`m/44/...`, `/44/...`)
* compare equal to the relative form used on addresses (`44/...`).
*/
export function normalizeDerivationPrefix(prefix: string): string {
if (prefix.startsWith('m/')) {
return prefix.slice(2);
}
if (prefix.startsWith('/')) {
return prefix.slice(1);
}
return prefix;
}

/**
* Extract the non-legacy derivation prefix from the full path recorded for a PSBT output:
* all components except the last two (`chain`, `index`), without a leading `m/` or `/`.
*
* Returns `undefined` when the path carries no prefix or the legacy `0/0` prefix, so callers
* treat the output exactly as before. Any other prefix is returned unvalidated: it must be
* checked with {@link assertCanonicalDerivationPrefix} before it is used for derivation.
*/
export function getDerivationPrefixFromPath(path: string | null | undefined): string | undefined {
if (!path) {
return undefined;
}
const components = normalizeDerivationPrefix(path).split('/');
if (components.length < 3) {
return undefined;
}
const prefix = components.slice(0, -2).join('/');
return prefix === LEGACY_DERIVATION_PREFIX ? undefined : prefix;
}

/**
* Assert that `derivationPrefix` is the canonical prefix of the wallet formed by `keys`:
* the one recomputed from the three root keys and the coin's BIP44 coin type. A server (or PSBT)
* supplied prefix is never trusted, so a different prefix is rejected as an unexpected address.
*
* @param keys the wallet's root xpubs `[user, backup, bitgo]`
* @param derivationPrefix the prefix to check; leading `m/` or `/` is ignored
* @param bip44CoinType the coin's BIP44 coin type (`coin.getConfig().bip44CoinType`)
* @returns the canonical prefix (relative, no leading `/`)
* @throws {Error} if the coin has no BIP44 coin type
* @throws {UnexpectedAddressError} if the prefix is not the canonical one
*/
export function assertCanonicalDerivationPrefix(
keys: Triple<string>,
derivationPrefix: string,
bip44CoinType: number | undefined
): string {
if (bip44CoinType === undefined) {
throw new Error(
`cannot verify derivationPrefix "${derivationPrefix}": coin has no bip44 coin type and does not support per-wallet derivation prefixes`
);
}
const expected = normalizeDerivationPrefix(fixedScriptWallet.deriveWalletPrefix(keys, bip44CoinType));
if (normalizeDerivationPrefix(derivationPrefix) !== expected) {
throw new UnexpectedAddressError(
`address validation failure: derivationPrefix "${derivationPrefix}" is not the canonical prefix "${expected}" of the wallet keys`
);
}
return expected;
}
48 changes: 38 additions & 10 deletions modules/abstract-utxo/src/address/fixedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import { fixedScriptWallet } from '@bitgo/wasm-utxo';

import { toWasmUtxoCoinName, UtxoCoinName, WasmUtxoCoinName } from '../names';

import { assertCanonicalDerivationPrefix } from './derivationPrefix';

type ScriptType2Of3 = fixedScriptWallet.OutputScriptType;
type ChainCode = fixedScriptWallet.ChainCode;

export interface FixedScriptAddressCoinSpecific {
outputScript?: string;
derivationPrefix?: string;
}

export interface GenerateAddressOptions {
Expand All @@ -33,6 +36,13 @@ export interface GenerateAddressOptions {
interface GenerateFixedScriptAddressOptions extends GenerateAddressOptions {
format?: CreateAddressFormat;
keychains: { pub: string }[];
/**
* Absent = legacy scheme (`0/0`). Present = the bip44 prefix `44/<coinType>/<h1>/<h2>`, which must equal the
* canonical prefix recomputed from `keychains` and `bip44CoinType`.
*/
derivationPrefix?: string;
/** BIP44 coin type of the coin (`coin.getConfig().bip44CoinType`); required when `derivationPrefix` is present. */
bip44CoinType?: number;
}

function supportsAddressType(coinName: UtxoCoinName | WasmUtxoCoinName, addressType: ScriptType2Of3): boolean {
Expand Down Expand Up @@ -68,7 +78,9 @@ export function generateAddressWithChainAndIndex(
* @param params.index {number} Derivation index
* @param params.segwit {boolean} Deprecated
* @param params.bech32 {boolean} Deprecated
* @returns {string} The generated address
* @param params.derivationPrefix {string} Absent = legacy `0/0` scheme. Present = must equal the canonical bip44
* prefix recomputed from `keychains` and `bip44CoinType`, else this throws
* @param params.bip44CoinType {number} Coin's BIP44 coin type; required when `derivationPrefix` is present
*/
export function generateAddress(
coinName: UtxoCoinName | WasmUtxoCoinName,
Expand All @@ -79,8 +91,14 @@ export function generateAddress(
derivationIndex = params.index as number;
}

const { keychains, chain, segwit = false, bech32 = false } = params as GenerateFixedScriptAddressOptions;

const {
keychains,
chain,
segwit = false,
bech32 = false,
derivationPrefix,
bip44CoinType,
} = params as GenerateFixedScriptAddressOptions;
if (_.isNumber(chain) && _.isInteger(chain) && !fixedScriptWallet.ChainCode.is(chain)) {
throw new InvalidAddressDerivationPropertyError(`address validation failure: unrecognised chain code (${chain})`);
}
Expand Down Expand Up @@ -131,13 +149,15 @@ export function generateAddress(
throw new Error('keychains must be a triple');
}

return generateAddressWithChainAndIndex(
coinName,
keychains.map((k) => k.pub) as Triple<string>,
derivationChain,
derivationIndex,
params.format
);
const keys = keychains.map((k) => k.pub) as Triple<string>;
let walletKeys: fixedScriptWallet.WalletKeysArg = keys;
if (derivationPrefix !== undefined) {
// never derive under a prefix that was merely supplied: it must be the canonical one for these keys
const canonicalPrefix = assertCanonicalDerivationPrefix(keys, derivationPrefix, bip44CoinType);
walletKeys = { triple: keys, derivationPrefixes: [canonicalPrefix, canonicalPrefix, canonicalPrefix] };
}

return generateAddressWithChainAndIndex(coinName, walletKeys, derivationChain, derivationIndex, params.format);
}

type Keychain = {
Expand All @@ -152,12 +172,18 @@ export function assertFixedScriptWalletAddress(
keychains,
format,
address,
derivationPrefix,
bip44CoinType,
}: {
chain: number | undefined;
index: number;
keychains: Keychain[];
format: CreateAddressFormat;
address: string;
/** Absent = legacy scheme. Present = must be the canonical bip44 prefix of `keychains`. */
derivationPrefix?: string;
/** BIP44 coin type of the coin; required when `derivationPrefix` is present. */
bip44CoinType?: number;
}
): void {
if ((_.isUndefined(chain) && _.isUndefined(index)) || !(_.isFinite(chain) && _.isFinite(index))) {
Expand All @@ -175,6 +201,8 @@ export function assertFixedScriptWalletAddress(
keychains,
chain,
index,
derivationPrefix,
bip44CoinType,
});

if (expectedAddress !== address) {
Expand Down
6 changes: 6 additions & 0 deletions modules/abstract-utxo/src/address/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,9 @@ export {
assertFixedScriptWalletAddress,
FixedScriptAddressCoinSpecific,
} from './fixedScript';
export {
LEGACY_DERIVATION_PREFIX,
assertCanonicalDerivationPrefix,
getDerivationPrefixFromPath,
normalizeDerivationPrefix,
} from './derivationPrefix';
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Triple } from '@bitgo/sdk-core';
import type { FixedScriptWalletOutput, Output, BitGoPsbt } from '../types';
import type { Bip322Message } from '../../abstractUtxoCoin';
import { AddressCodec } from '../recipient';
import { getDerivationPrefixFromPath } from '../../address/derivationPrefix';

import type { TransactionExplanationWasm } from './explainTransaction';
function scriptToAddress(script: Uint8Array): string {
Expand All @@ -22,12 +23,15 @@ function isParsedExternalOutput(output: ParsedWalletOutput | ParsedExternalOutpu
}

function toChangeOutputBigInt(output: ParsedWalletOutput): FixedScriptWalletOutput<bigint> {
// `derivationPath` is the full path recorded in the PSBT; everything but chain/index is the prefix (absent = legacy)
const derivationPrefix = getDerivationPrefixFromPath(output.derivationPath);
return {
address: output.address ?? scriptToAddress(output.script),
amount: output.value,
chain: output.scriptId.chain,
index: output.scriptId.index,
external: false,
...(derivationPrefix === undefined ? {} : { derivationPrefix }),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,11 +253,17 @@ export async function parseOutput({
// derivation information we have. Otherwise, if the address is external, which is the only remaining case, we
// can just return the current output as is without contacting the server.
if (isWalletOutput(currentOutput)) {
// The prefix comes from the derivation path recorded in the PSBT (absent = legacy `0/0`). It is never trusted:
// isWalletAddress only accepts it if it equals the canonical prefix recomputed from the wallet keys, so a
// bip44 change output is recognised as internal and a forged prefix is classified as external.
const res = await coin.isWalletAddress({
keychains: keychainArray,
address: currentAddress,
chain: currentOutput.chain,
index: currentOutput.index,
...(currentOutput.derivationPrefix === undefined
? {}
: { coinSpecific: { derivationPrefix: currentOutput.derivationPrefix } }),
});
if (!res) {
throw new UnexpectedAddressError();
Expand Down
5 changes: 5 additions & 0 deletions modules/abstract-utxo/src/transaction/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export interface FixedScriptWalletOutput<TAmount = string | number> extends Base
needsCustomChangeKeySignatureVerification?: boolean;
chain: number;
index: number;
/**
* Derivation prefix (all path components except `chain/index`) recorded in the PSBT for this output. Absent for
* the legacy `0/0` prefix. Unvalidated: consumers must check it against the canonical prefix of the wallet keys.
*/
derivationPrefix?: string;
}

export type Output<TAmount = string | number> = BaseOutput<TAmount> | FixedScriptWalletOutput<TAmount>;
Expand Down
Loading
Loading