Skip to content
64 changes: 53 additions & 11 deletions src/contracts/contractManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ export type RefreshVtxosOptions = {
scripts?: string[];
after?: number;
before?: number;
/**
* When true and `scripts` is not set, refresh every contract in
* the repository — including those marked `inactive` and those
* that have dropped out of the watcher's active set. Useful for
* "did anyone send funds to a stale rotated display address?"
* audits.
*
* Because this is a *superset* of the watcher's watched set, the
* cursor invariant still holds and the cursor advances normally
* (unless an explicit `after` / `before` window is also supplied).
*
* Ignored when `scripts` is set (the explicit list already
* specifies what to refresh, regardless of contract state).
*
* @defaultValue `false`
*/
includeInactive?: boolean;
};

export interface IContractManager extends Disposable {
Expand Down Expand Up @@ -636,9 +653,22 @@ export class ContractManager implements IContractManager {
/**
* Force refresh virtual outputs from the indexer.
*
* Without options, re-fetches every contract and advances the global cursor.
* With options, narrows the refresh to specific scripts and/or a time window.
* Subset refreshes (scripts filter) intentionally do not advance the cursor.
* Without options, re-fetches every contract in the watcher's
* watched set and advances the global cursor.
*
* `scripts` narrows the refresh to a specific list (subset query —
* cursor is not advanced because contracts outside the list may
* have data we'd skip).
*
* `includeInactive: true` (and no `scripts`) widens the refresh to
* every contract in the repository, including ones marked
* `inactive` and ones that have dropped out of the watcher's
* active set. This is a *superset* of the watched set, so the
* cursor invariant still holds and the cursor advances normally.
*
* `after` / `before` apply a caller-supplied time window. The
* cursor never advances on a windowed query because the window
* may skip data outside its bounds.
*/
async refreshVtxos(opts?: RefreshVtxosOptions): Promise<void> {
const contracts = opts?.scripts
Expand All @@ -655,6 +685,9 @@ export class ContractManager implements IContractManager {
opts?.after !== undefined || opts?.before !== undefined;
await this.syncContracts({
contracts,
// Scope-only widener; never set together with explicit
// `contracts` because `scripts` already names the exact set.
includeInactive: contracts ? false : opts?.includeInactive,
window: hasExplicitWindow
? { after: opts?.after, before: opts?.before }
: undefined,
Expand Down Expand Up @@ -785,24 +818,33 @@ export class ContractManager implements IContractManager {
pageSize?: number;
// Overrides the cursor-derived window.
window?: { after?: number; before?: number };
// When `contracts` is omitted: query every contract in the
// repository (active + inactive) instead of just the watcher's
// watched set. This is a superset of the watched set, so the
// cursor invariant still holds and the cursor still advances.
includeInactive?: boolean;
}): Promise<Map<string, ExtendedContractVtxo[]>> {
const cursor = await getSyncCursor(this.config.walletRepository);
const window = options.window ?? computeSyncWindow(cursor);

// Advance the global cursor only on full-scope, cursor-derived delta
// syncs. A caller-supplied window is targeted (e.g. `refreshVtxos`)
// and must not move the cursor — it may skip data outside its bounds.
// `<=` lets the bootstrap case (cursor=0, window.after=0) write the
// migration marker on first boot; otherwise the marker would never
// be written and every subsequent boot would treat the cursor as
// legacy and re-bootstrap.
// Advance the global cursor only on cursor-derived delta syncs
// whose contract scope covers at least the watcher's watched
// set. Targeted subset queries (caller-supplied `contracts`) and
// bounded-window queries must not move the cursor — they may
// skip data outside their bounds. `includeInactive` (with no
// `contracts`) widens the scope rather than narrowing it, so it
// is cursor-safe. `<=` lets the bootstrap case (cursor=0,
// window.after=0) write the migration marker on first boot.
const mustUpdateCursor =
options.contracts === undefined &&
options.window === undefined &&
(window.after ?? 0) <= cursor;

const contracts =
options.contracts ?? this.watcher.getWatchedContracts();
options.contracts ??
(options.includeInactive
? await this.config.contractRepository.getContracts({})
: this.watcher.getWatchedContracts());

const requestStartedAt = Date.now();
const result = await this.fetchContractVxosFromIndexer(
Expand Down
7 changes: 7 additions & 0 deletions src/identity/descriptorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export interface DescriptorSigningRequest {
* The provider has no read accessor for "current" — it is a pure descriptor
* allocator. "What addresses am I currently bound to?" is a question the
* contract repository answers, not the provider.
*
* Providers that want to participate in HD receive rotation can also
* implement the wallet-side `ReceiveRotatorFactory` interface (see
* `src/wallet/walletReceiveRotator.ts`). That extension is opt-in — the
* core `DescriptorProvider` contract intentionally stays free of
* wallet-specific concerns so HSM-backed and other minimal providers
* don't have to know about the receive-rotation lifecycle.
*/
export interface DescriptorProvider {
/**
Expand Down
21 changes: 21 additions & 0 deletions src/identity/hdCapableIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,24 @@ export interface HDCapableIdentity extends ReadonlyHDCapableIdentity, Identity {
signatureType?: "schnorr" | "ecdsa"
): Promise<Uint8Array>;
}

/**
* Structural type guard for {@link HDCapableIdentity}. Returns `true`
* when the value exposes the four members the HD wallet flow relies on:
* `descriptor`, `isOurs`, `signWithDescriptor`, and
* `signMessageWithDescriptor`. Used by callers that need to opt into
* the HD path (e.g. installing an `HDDescriptorProvider`) without
* coupling to a concrete identity class.
*/
export function isHDCapableIdentity(
value: unknown
): value is HDCapableIdentity {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.descriptor === "string" &&
typeof v.isOurs === "function" &&
typeof v.signWithDescriptor === "function" &&
typeof v.signMessageWithDescriptor === "function"
);
}
1 change: 1 addition & 0 deletions src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type {
HDCapableIdentity,
ReadonlyHDCapableIdentity,
} from "./hdCapableIdentity";
export { isHDCapableIdentity } from "./hdCapableIdentity";

// Static descriptor provider (wrapper for legacy Identity)
export { StaticDescriptorProvider } from "./staticDescriptorProvider";
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
IReadonlyWallet,
BaseWalletConfig,
WalletConfig,
WalletMode,
ReadonlyWalletConfig,
ProviderClass,
ArkTransaction,
Expand Down Expand Up @@ -436,6 +437,7 @@ export type {
IReadonlyWallet,
BaseWalletConfig,
WalletConfig,
WalletMode,
ReadonlyWalletConfig,
ProviderClass,
ArkTransaction,
Expand Down
41 changes: 40 additions & 1 deletion src/wallet/hdDescriptorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import {
} from "../repositories/walletRepository";
import { Transaction } from "../utils/transaction";
import { updateWalletState } from "../utils/syncCursors";
import {
ReceiveRotatorBoot,
ReceiveRotatorBootOpts,
ReceiveRotatorFactory,
WalletReceiveRotator,
} from "./walletReceiveRotator";

/**
* Persisted HD wallet state stored under {@link WalletState.settings}`.hd`.
Expand Down Expand Up @@ -62,7 +68,9 @@ const HD_SETTINGS_KEY = "hd";
* // next: tr([fp/86'/0'/0']xpub/0/1)
* ```
*/
export class HDDescriptorProvider implements DescriptorProvider {
export class HDDescriptorProvider
implements DescriptorProvider, ReceiveRotatorFactory
{
private constructor(
private readonly identity: HDCapableIdentity,
private readonly walletRepository: WalletRepository
Expand Down Expand Up @@ -99,6 +107,25 @@ export class HDDescriptorProvider implements DescriptorProvider {
});
}

/**
* Re-derive the descriptor at the most recently allocated index
* WITHOUT advancing — i.e. read the same descriptor
* `getNextSigningDescriptor` last returned. Returns `undefined`
* when no descriptor has ever been allocated on this repo.
*
* Used by the boot path to keep the wallet's display address
* stable across restarts: when no tagged display contract exists
* (e.g. a fresh wallet that hasn't rotated yet, or a wallet whose
* baseline-only repo carries no rotation history), the boot should
* re-derive the existing index rather than burn a new one.
*/
async getCurrentSigningDescriptor(): Promise<string | undefined> {
const state = await this.walletRepository.getWalletState();
const settings = this.parseSettings(state ?? ({} as WalletState));
if (settings.lastIndexUsed === undefined) return undefined;
return this.materializeAt(settings.lastIndexUsed);
}

/**
* Returns true when the given descriptor is derivable from this wallet's
* seed. Delegates to the underlying identity, which handles both HD and
Expand Down Expand Up @@ -132,6 +159,18 @@ export class HDDescriptorProvider implements DescriptorProvider {
);
}

/**
* HD providers participate in receive rotation. The default
* factory boot (contract-repo lookup → allocate fresh descriptor)
* is exactly what we want, so this just delegates to
* {@link WalletReceiveRotator.defaultBoot}.
*/
async createReceiveRotator(
opts: ReceiveRotatorBootOpts
): Promise<ReceiveRotatorBoot | undefined> {
return WalletReceiveRotator.defaultBoot(this, opts);
}

// ── internals ────────────────────────────────────────────────────

/**
Expand Down
32 changes: 32 additions & 0 deletions src/wallet/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Bytes } from "@scure/btc-signer/utils.js";
import { ArkProvider, Output, SettlementEvent } from "../providers/ark";
import { Identity, ReadonlyIdentity } from "../identity";
import { DescriptorProvider } from "../identity/descriptorProvider";
import { RelativeTimelock } from "../script/tapscript";
import { EncodedVtxoScript, TapLeafScript } from "../script/base";
import { RenewalConfig, SettlementConfig } from "./vtxo-manager";
Expand All @@ -17,6 +18,27 @@ export const DEFAULT_ARKADE_SERVER_URL = "https://arkade.computer" as const;
export const DEFAULT_ARKADE_HRP = "ark" as const;
export const DEFAULT_NETWORK_NAME = "bitcoin" as const;

/**
* Wallet receive-address strategy.
*
* - `'auto'` *(default)*: probe the identity. If it can produce a rangeable
* HD descriptor, rotate the receive address on every incoming VTXO. If
* not, behave like `'static'`. Failures during HD setup fall back to
* `'static'` silently — preserves backwards compatibility.
* - `'static'`: never rotate. The wallet uses one receive address derived
* from `identity.xOnlyPublicKey()`.
* - `'hd'`: must rotate, using the built-in HD provider derived from the
* identity. Throws at `Wallet.create` if the identity isn't HD-capable
* or its descriptor isn't rangeable — no silent fallback.
* - A {@link DescriptorProvider} instance: rotate via the supplied
* provider on every incoming VTXO. The wallet does not probe the
* identity; the caller is responsible for ensuring the identity can
* sign for whatever pubkey the provider returns. Errors thrown by the
* provider propagate — there is no silent fallback for an explicit
* provider.
*/
export type WalletMode = "auto" | "static" | "hd" | DescriptorProvider;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm seems to me a leaky abstraction to make people be even aware what HD is? What if the wallet is always in rotate mode, and advanced user may use lower level method to derive "static" address if ever needed?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree on the HD visibility part: "auto" | "static" | "dynamic" would communicate the mode without having to know what "hd" means


/**
* Base configuration options shared by all wallet types.
*
Expand Down Expand Up @@ -170,6 +192,16 @@ export interface WalletConfig extends ReadonlyWalletConfig {
* @see SettlementConfig
*/
settlementConfig?: SettlementConfig | false;

/**
* Receive-address strategy. Pass `'static'`, `'hd'`, or a
* {@link DescriptorProvider} instance to drive rotation; omit (or
* pass `'auto'`) for the built-in auto-detect behaviour. See
* {@link WalletMode}.
*
* @defaultValue `'auto'`
*/
walletMode?: WalletMode;
}

/**
Expand Down
6 changes: 5 additions & 1 deletion src/wallet/unroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,11 @@ export async function prepareUnrollTransaction(
if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
feeRate = Wallet.MIN_FEE_RATE;
}
const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
// Esplora returns a `number` and bitcoind regtest sometimes reports
// fractional sat/vB (e.g. 1.006). `BigInt(1.006)` throws RangeError
// — round up so we always pay AT LEAST the advertised rate and
// satisfy BigInt's integer requirement.
const feeAmount = txWeightEstimator.vsize().fee(BigInt(Math.ceil(feeRate)));
if (feeAmount > totalAmount) {
throw new Error("fee amount is greater than the total amount");
}
Expand Down
Loading
Loading