Tenth plugin out, and the one whose extraction turned on a permission decision rather than a mechanical move. The wallet permission was kind: 'admin' — owner only, not grantable, invisible to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts forces it and the manifest has no field for anything else, deliberately, so that "can a plugin escalate itself" is a question which cannot be asked. So extraction could not preserve admin, and that was raised as a blocker before any code moved. It was the right change anyway, and the objection inverts. Every wallet query is scoped by user_id, and that id comes from X-Officer-User which the platform injects from the authenticated session — a caller cannot name someone else's. So granting this gives an account ITS OWN wallet. Not through the API and not with root either does it hand over anyone else's, because their seed is sealed under a passphrase the server never stores. kind: 'admin' was single-user thinking; grantable is how a second person gets a wallet at all. Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically, and the appName drives the route, the API prefix, the table prefix and the install key together. Tables renamed to bitcoin_wallet_* accordingly — the prefix guard normalises the hyphen. Free only because no wallet table existed on any server, which was checked before deciding: db:push treats a rename as a drop plus a create. The three custody test files moved with it and still run — 72 tests over scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by nine, so that trap was already closed. 68 palette classes converted, five status vocabularies hoisted into shared.ts. Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all read as warning, and the bitcoin and lightning rails no longer differ by hue. Deferred on purpose: watch-only data (xpubs, balances, history) stays unencrypted so a locked wallet still shows a balance. Anyone with the database can therefore SEE any user's balances; they can never move a coin.
472 lines
20 KiB
TypeScript
472 lines
20 KiB
TypeScript
// The NBXplorer chain source: the owner's own bitcoind, read through NBXplorer's wallet-level index.
|
|
//
|
|
// The contrast with chain-source-esplora.ts is the whole reason the WalletChainSource seam exists. There
|
|
// is no gap-limit walk here and no address fan-out, because NBXplorer already knows what the wallet owns:
|
|
// the account xpub is registered once, and after that a scan is three calls per account regardless of how
|
|
// many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide,
|
|
// so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths.
|
|
//
|
|
// REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from
|
|
// the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every
|
|
// call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the
|
|
// answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds
|
|
// coins regardless of when the account was registered or how far back the node is pruned.
|
|
//
|
|
// What a rescan does NOT recover is spent-transaction history predating registration; that is a block
|
|
// rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error.
|
|
// Esplora stays available for the owner who wants the full history back.
|
|
|
|
import * as bitcoin from 'bitcoinjs-lib';
|
|
import {
|
|
type AddressEntry,
|
|
type ChainIndex,
|
|
type RescanHandle,
|
|
type ScanAccount,
|
|
type ScanContext,
|
|
type ScannedAddress,
|
|
type ScanResult,
|
|
type WalletChainSource,
|
|
} from './chain-source';
|
|
import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer';
|
|
import type { SpendableUtxo } from './psbt';
|
|
import { networkFor } from './psbt';
|
|
import {
|
|
BackendError,
|
|
type AddressType,
|
|
type BitcoinNetwork,
|
|
type FeeEstimates,
|
|
type OnchainTx,
|
|
type RescanOptions,
|
|
type RescanState,
|
|
} from './types';
|
|
|
|
/** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */
|
|
const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const;
|
|
|
|
/** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */
|
|
const SCAN_POLL_MS = 2_000;
|
|
|
|
/** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */
|
|
const SCAN_TIMEOUT_MS = 10 * 60_000;
|
|
|
|
/** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */
|
|
const SCAN_NULL_GRACE = 5;
|
|
|
|
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
|
|
export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string };
|
|
|
|
export class NbxplorerChainSource implements WalletChainSource {
|
|
readonly label: string;
|
|
|
|
/**
|
|
* A scan reports up to NBXplorer's unused mark, but NBXplorer itself watches a gap beyond it
|
|
* (`MinGapSize`, 20 by default) and extends as addresses are used, so an address a little past the
|
|
* mark is still seen. Deliberately below that default rather than equal to it, because the setting
|
|
* belongs to the node operator and is not readable from here — and unlike Esplora, a miss on this
|
|
* source is recoverable: `utxos/scan` walks the node's whole UTXO set to a gap limit of 1000.
|
|
*/
|
|
readonly issueAhead = 10;
|
|
|
|
private readonly chain: NbxplorerChain;
|
|
private readonly btcNetwork: bitcoin.Network;
|
|
|
|
/**
|
|
* Accounts seen by the most recent scan.
|
|
*
|
|
* `getTxHex` is the reason this exists: NBXplorer has no global transaction lookup, only a per-scheme
|
|
* one, but the WalletChainSource signature carries a txid and nothing else. Every caller of getTxHex
|
|
* runs inside sendCoins, which forces a fresh scan before it selects a single coin, so the map is
|
|
* always populated by the time it is read. It is a memo of the last scan, never a source of truth.
|
|
*/
|
|
private accounts: readonly ScanAccount[] = [];
|
|
|
|
/** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */
|
|
private rescan: RescanState | null = null;
|
|
private rescanRun: Promise<RescanState> | null = null;
|
|
|
|
constructor(opts: NbxplorerChainSourceOptions) {
|
|
this.chain = opts.chain;
|
|
this.btcNetwork = networkFor(opts.network);
|
|
this.label = opts.label;
|
|
}
|
|
|
|
async getTipHeight(): Promise<number> {
|
|
const status = await this.chain.getStatus();
|
|
return status.chainHeight;
|
|
}
|
|
|
|
/**
|
|
* NBXplorer answers one confirmation target per call, so the five tiers are five requests.
|
|
*
|
|
* In a quiet mempool every target returns the same number and the tiers collapse to one value. That is
|
|
* the node telling the truth, not a bug — there is genuinely no fee premium worth paying when the next
|
|
* block will clear the queue. The clamp only enforces that a slower target is never quoted higher than
|
|
* a faster one, which estimatesmartfee can otherwise produce at the boundaries.
|
|
*/
|
|
async getFeeEstimates(): Promise<FeeEstimates> {
|
|
const entries = Object.entries(FEE_TARGETS) as [keyof FeeEstimates, number][];
|
|
const rates = await Promise.all(entries.map(([, blocks]) => this.chain.getFeeRate(blocks)));
|
|
|
|
const out = {} as FeeEstimates;
|
|
let ceiling = Number.POSITIVE_INFINITY;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const entry = entries[i];
|
|
const rate = rates[i];
|
|
if (!entry || rate === undefined) continue;
|
|
ceiling = Math.min(ceiling, rate);
|
|
out[entry[0]] = ceiling;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async getTxHex(txid: string): Promise<string> {
|
|
if (this.accounts.length === 0) {
|
|
throw new BackendError('nbxplorer source was asked for a transaction before any scan', 500, 'NO_SCAN');
|
|
}
|
|
// Which account funded the input is not known here, and asking the wrong one 404s rather than
|
|
// returning something wrong, so trying each in turn is safe. In practice the first one answers.
|
|
let last: unknown = null;
|
|
for (const account of this.accounts) {
|
|
try {
|
|
return await this.chain.getTxHex(account.accountXpub, account.type, txid);
|
|
} catch (err) {
|
|
last = err;
|
|
}
|
|
}
|
|
throw last instanceof Error ? last : new BackendError(`no account knows transaction ${txid}`, 502);
|
|
}
|
|
|
|
async broadcast(rawHex: string): Promise<string> {
|
|
await this.chain.broadcast(rawHex);
|
|
// NBXplorer's broadcast returns only success/failure, so the txid is computed locally. It is a hash
|
|
// of the bytes we just sent, so it cannot disagree with what was accepted.
|
|
return bitcoin.Transaction.fromHex(rawHex).getId();
|
|
}
|
|
|
|
async scan(ctx: ScanContext): Promise<ScanResult> {
|
|
this.accounts = ctx.accounts;
|
|
|
|
const perAccount = await Promise.all(ctx.accounts.map((account) => this.scanAccount(ctx, account)));
|
|
|
|
const tipHeight = Math.max(0, ...perAccount.map((r) => r.tipHeight));
|
|
const addresses = perAccount.flatMap((r) => r.addresses);
|
|
const utxos = perAccount.flatMap((r) => r.utxos);
|
|
|
|
const byScript = new Map<string, ScannedAddress>();
|
|
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
|
|
|
|
const txs = perAccount
|
|
.flatMap((r) => r.txs)
|
|
.map((tx) => this.toOnchainTx(tx, byScript, tipHeight))
|
|
.sort((a, b) => {
|
|
// Unconfirmed first (they have no height), then newest block, then newest timestamp.
|
|
const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
|
const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER;
|
|
if (ah !== bh) return bh - ah;
|
|
return (b.timestamp ?? 0) - (a.timestamp ?? 0);
|
|
});
|
|
|
|
return { tipHeight, addresses, utxos, txs };
|
|
}
|
|
|
|
rescanState(): RescanState | null {
|
|
return this.rescan;
|
|
}
|
|
|
|
/**
|
|
* Run `scantxoutset` over every account variant, one at a time.
|
|
*
|
|
* ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can
|
|
* tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit
|
|
* costs one wasted scan, whereas skipping it costs the owner their money. The node serialises
|
|
* `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for;
|
|
* doing it explicitly is what makes `done`/`total` mean something on screen.
|
|
*
|
|
* A second call while one is running returns the same handle rather than queueing a duplicate.
|
|
*/
|
|
startRescan(accounts: readonly ScanAccount[], opts: RescanOptions = {}): RescanHandle {
|
|
const existing = this.rescan;
|
|
if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun };
|
|
|
|
const state: RescanState = {
|
|
running: true,
|
|
done: 0,
|
|
total: accounts.length,
|
|
current: accounts[0]?.type ?? null,
|
|
found: 0,
|
|
startedAt: Date.now(),
|
|
finishedAt: null,
|
|
error: null,
|
|
};
|
|
this.rescan = state;
|
|
this.rescanRun = this.runRescan(accounts, state, opts);
|
|
return { state, done: this.rescanRun };
|
|
}
|
|
|
|
/**
|
|
* Pick up a scan that survived a restart of this sidecar.
|
|
*
|
|
* `scantxoutset` runs on the node and outlives us; only the handle to it was ever in memory. So the
|
|
* upstream is asked directly, and if anything is Queued or Pending the run is rebuilt around the same
|
|
* polling loop with starting suppressed — a duplicate POST would sit behind the real scan for its whole
|
|
* duration, since the node runs one at a time.
|
|
*
|
|
* `found` restarts from zero and a variant that finished before the restart contributes nothing to it:
|
|
* its result has expired upstream and there is no way to recover the count. The number under-reports
|
|
* for that one run, which is the honest answer — and the refresh chained on the end is what actually
|
|
* puts the coins on screen.
|
|
*/
|
|
async adoptRescan(accounts: readonly ScanAccount[]): Promise<RescanHandle | null> {
|
|
if (this.rescan?.running && this.rescanRun) return { state: this.rescan, done: this.rescanRun };
|
|
|
|
const live = await Promise.all(
|
|
accounts.map(async (account) => {
|
|
// A probe failing is not an answer either way, and must not be reported as "a scan is running".
|
|
const status = await this.chain.getUtxoScanStatus(account.accountXpub, account.type).catch(() => null);
|
|
return status?.status === 'Queued' || status?.status === 'Pending';
|
|
}),
|
|
);
|
|
if (!live.some(Boolean)) return null;
|
|
|
|
const state: RescanState = {
|
|
running: true,
|
|
done: 0,
|
|
total: accounts.length,
|
|
current: accounts[0]?.type ?? null,
|
|
found: 0,
|
|
startedAt: Date.now(),
|
|
finishedAt: null,
|
|
error: null,
|
|
};
|
|
this.rescan = state;
|
|
this.rescanRun = this.runRescan(accounts, state, null);
|
|
return { state, done: this.rescanRun };
|
|
}
|
|
|
|
/** `opts` null means adopt: poll the variants without ever queueing one. */
|
|
private async runRescan(
|
|
accounts: readonly ScanAccount[],
|
|
state: RescanState,
|
|
opts: RescanOptions | null,
|
|
): Promise<RescanState> {
|
|
try {
|
|
for (const account of accounts) {
|
|
state.current = account.type;
|
|
state.found += opts ? await this.scanVariant(account, opts) : await this.awaitVariant(account);
|
|
state.done += 1;
|
|
}
|
|
} catch (err) {
|
|
// One variant failing stops the run, and the partial `found` stays on the state: coins already
|
|
// pulled in by an earlier variant are really there, and reporting zero would understate the wallet.
|
|
state.error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
state.running = false;
|
|
state.current = null;
|
|
state.finishedAt = Date.now();
|
|
}
|
|
return state;
|
|
}
|
|
|
|
/** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */
|
|
private async scanVariant(account: ScanAccount, opts: RescanOptions): Promise<number> {
|
|
await this.chain.startUtxoScan(account.accountXpub, account.type, { gapLimit: opts.gapLimit });
|
|
return this.awaitVariant(account);
|
|
}
|
|
|
|
/**
|
|
* Wait out a scan that is already queued upstream, without starting one.
|
|
*
|
|
* Split out of scanVariant for adoption: a scan this process did not start must never be re-queued,
|
|
* because scantxoutset is single-threaded node-wide and a duplicate would sit behind the real one for
|
|
* its whole duration. An account that already finished answers 404 or Complete and returns at once.
|
|
*/
|
|
private async awaitVariant(account: ScanAccount): Promise<number> {
|
|
const { accountXpub, type } = account;
|
|
|
|
// The timeout bounds how long a scan may RUN, not how long it may wait its turn. scantxoutset is
|
|
// single-threaded node-wide, so four variants queue behind each other and the last one could spend
|
|
// most of its ten minutes not started — timing out while the node was never asked to do anything
|
|
// wrong. The deadline is therefore pushed forward for as long as the status reads Queued.
|
|
let deadline = Date.now() + SCAN_TIMEOUT_MS;
|
|
let found = 0;
|
|
let seen = false;
|
|
let nulls = 0;
|
|
|
|
while (Date.now() < deadline) {
|
|
await sleep(SCAN_POLL_MS);
|
|
const status = await this.chain.getUtxoScanStatus(accountXpub, type);
|
|
|
|
// 404 means "no scan is running" — which before the first sighting is a race with the queue, and
|
|
// after it means the result has expired. Only the second reading is a finish, hence the flag; the
|
|
// grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes.
|
|
if (!status) {
|
|
if (seen) return found;
|
|
if (++nulls >= SCAN_NULL_GRACE) return found;
|
|
continue;
|
|
}
|
|
|
|
seen = true;
|
|
nulls = 0;
|
|
found = status.progress?.found ?? found;
|
|
|
|
if (status.status === 'Queued') deadline = Date.now() + SCAN_TIMEOUT_MS;
|
|
if (status.status === 'Complete') return found;
|
|
if (status.status === 'Error') {
|
|
throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN');
|
|
}
|
|
}
|
|
|
|
throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT');
|
|
}
|
|
|
|
private async scanAccount(ctx: ScanContext, account: ScanAccount) {
|
|
const { accountXpub, type } = account;
|
|
// track() is idempotent and every one of these calls makes it first, so registration cannot be
|
|
// skipped — which matters because an unregistered scheme answers 200-with-zeros, not 404.
|
|
const [utxoChanges, history, unusedDeposit, unusedChange] = await Promise.all([
|
|
this.chain.getUtxos(accountXpub, type),
|
|
this.chain.getTransactions(accountXpub, type),
|
|
this.chain.getUnusedAddress(accountXpub, type, 'Deposit'),
|
|
this.chain.getUnusedAddress(accountXpub, type, 'Change'),
|
|
]);
|
|
|
|
const tipHeight = utxoChanges.currentHeight;
|
|
|
|
// A coin already being spent by something in the mempool is not spendable again. NBXplorer still
|
|
// lists it as a confirmed UTXO, so it has to be excluded explicitly or coin selection would build a
|
|
// transaction that double-spends the owner's own unconfirmed one.
|
|
const spentUnconfirmed = new Set(utxoChanges.spentUnconfirmed.map((u) => u.outpoint));
|
|
|
|
const confirmed = utxoChanges.confirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint));
|
|
const unconfirmed = utxoChanges.unconfirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint));
|
|
|
|
// The per-address view the rest of the wallet works in. NBXplorer's own answer to "what is unused" is
|
|
// taken as authoritative for where each chain ends — everything below that index has been seen, which
|
|
// is what makes it unsafe to hand out again.
|
|
const highest: Record<ChainIndex, number> = {
|
|
0: indexOf(unusedDeposit.keyPath),
|
|
1: indexOf(unusedChange.keyPath),
|
|
};
|
|
for (const u of [...confirmed, ...unconfirmed, ...utxoChanges.spentUnconfirmed]) {
|
|
const at = parseKeyPath(u.keyPath);
|
|
if (at) highest[at.chain] = Math.max(highest[at.chain], at.index);
|
|
}
|
|
|
|
const sats = new Map<string, { confirmed: number; unconfirmed: number }>();
|
|
const bump = (u: NbxUtxo, field: 'confirmed' | 'unconfirmed', sign: 1 | -1) => {
|
|
const cur = sats.get(u.keyPath) ?? { confirmed: 0, unconfirmed: 0 };
|
|
cur[field] += sign * u.value;
|
|
sats.set(u.keyPath, cur);
|
|
};
|
|
for (const u of confirmed) bump(u, 'confirmed', 1);
|
|
for (const u of unconfirmed) bump(u, 'unconfirmed', 1);
|
|
// Matches Esplora's signed mempool delta: an unconfirmed spend of a confirmed coin reads negative,
|
|
// which is exactly what the Balances contract wants in onchainUnconfirmed.
|
|
for (const u of utxoChanges.spentUnconfirmed) bump(u, 'unconfirmed', -1);
|
|
|
|
const addresses: ScannedAddress[] = [];
|
|
for (const chain of [0, 1] as ChainIndex[]) {
|
|
for (let index = 0; index <= highest[chain]; index++) {
|
|
const entry = ctx.derive(type, chain, index);
|
|
const held = sats.get(`${chain}/${index}`);
|
|
addresses.push({
|
|
...entry,
|
|
confirmedSats: held?.confirmed ?? 0,
|
|
unconfirmedSats: held?.unconfirmed ?? 0,
|
|
// Below the unused mark means NBXplorer has seen this address; the mark itself has not.
|
|
txCount: index < highest[chain] ? 1 : 0,
|
|
used: index < highest[chain],
|
|
});
|
|
}
|
|
}
|
|
|
|
const utxos = [...confirmed, ...unconfirmed].flatMap<SpendableUtxo>((u) => {
|
|
const at = parseKeyPath(u.keyPath);
|
|
if (!at) return [];
|
|
const entry = ctx.derive(type, at.chain, at.index);
|
|
return [
|
|
{
|
|
txid: u.transactionHash,
|
|
vout: u.index,
|
|
amountSats: u.value,
|
|
address: entry.address,
|
|
addressType: entry.type,
|
|
confirmations: Math.max(0, u.confirmations),
|
|
derivationPath: entry.path,
|
|
frozen: false,
|
|
scriptPubKeyHex: entry.scriptPubKeyHex,
|
|
pubkeyHex: entry.pubkeyHex,
|
|
},
|
|
];
|
|
});
|
|
|
|
const txs = [...history.confirmedTransactions.transactions, ...history.unconfirmedTransactions.transactions];
|
|
return { tipHeight, addresses, utxos, txs };
|
|
}
|
|
|
|
/**
|
|
* NBXplorer already scores the transaction for us — `balanceChange` is signed and wallet-wide, which is
|
|
* precisely what the Esplora source computes by hand from credits and debits.
|
|
*
|
|
* The fee is NOT recoverable here, and reporting a wrong one would be worse than reporting none: the
|
|
* response carries no input values, and fetching every prevout to derive it would reintroduce the
|
|
* per-transaction fan-out this source exists to avoid. The contract permits null.
|
|
*/
|
|
private toOnchainTx(tx: NbxTransaction, byScript: Map<string, ScannedAddress>, tipHeight: number): OnchainTx {
|
|
const amount = tx.balanceChange;
|
|
const ours: string[] = [];
|
|
const theirs: string[] = [];
|
|
|
|
const hex = tx.transaction?.trim();
|
|
if (hex) {
|
|
try {
|
|
for (const out of bitcoin.Transaction.fromHex(hex).outs) {
|
|
const mine = byScript.get(out.script.toString('hex'));
|
|
if (mine) {
|
|
ours.push(mine.address);
|
|
continue;
|
|
}
|
|
try {
|
|
theirs.push(bitcoin.address.fromOutputScript(out.script, this.btcNetwork));
|
|
} catch {
|
|
// OP_RETURN and other unspendable scripts have no address. Not an error, just not a payee.
|
|
}
|
|
}
|
|
} catch {
|
|
// A transaction we cannot parse still has a correct amount and height; only the payee list is
|
|
// lost, and an empty one renders as "unknown" rather than as something false.
|
|
}
|
|
}
|
|
|
|
const height = tx.confirmations > 0 ? (tx.height ?? null) : null;
|
|
return {
|
|
txid: tx.transactionId,
|
|
amount,
|
|
feeSats: null,
|
|
blockHeight: height,
|
|
timestamp: tx.timestamp,
|
|
confirmations: Math.max(0, tx.confirmations),
|
|
label: null,
|
|
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
|
|
rawHex: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** `"0/7"` → `{ chain: 0, index: 7 }`. Anything else is not an address this wallet derives. */
|
|
function parseKeyPath(keyPath: string): { chain: ChainIndex; index: number } | null {
|
|
const parts = keyPath.split('/');
|
|
const chain = Number(parts.at(-2));
|
|
const index = Number(parts.at(-1));
|
|
if ((chain !== 0 && chain !== 1) || !Number.isInteger(index) || index < 0) return null;
|
|
return { chain, index };
|
|
}
|
|
|
|
/** The address index out of a key path, or 0 when it is unreadable. */
|
|
function indexOf(keyPath: string): number {
|
|
return parseKeyPath(keyPath)?.index ?? 0;
|
|
}
|