From 6e4af2fe3f67ed213b3e16ec3f5af68222a47f44 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 21 Jul 2026 18:24:45 +0530 Subject: [PATCH 01/34] Add coherent wallet snapshot refresh --- CHANGELOG.md | 5 + README.md | 14 +- index-bare.js | 2 + index-node.js | 2 + index.d.ts | 172 ++++++++- src/errors.js | 30 +- src/wallet-account-rgb-lightning.js | 181 +++++++++- src/wallet-snapshot-contract.js | 437 ++++++++++++++++++++++ tests/errors.test.js | 20 +- tests/types-contract.ts | 14 + tests/wallet-snapshot-contract.test.js | 477 +++++++++++++++++++++++++ 11 files changed, 1347 insertions(+), 7 deletions(-) create mode 100644 src/wallet-snapshot-contract.js create mode 100644 tests/wallet-snapshot-contract.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e948915..722ab41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ while pre-`1.0`. ## [0.1.0-beta.15] — 2026-07-23 ### Added +- **Versioned wallet refresh contract:** `account.refreshWalletSnapshot()` + serializes/coalesces refreshes, explicitly FullSyncs or recovery FullScans + both native keychains, validates bounded BigInt-safe snapshot DTOs, retries + one moving-tip capture, and reports partial sync, native, contract, and + coherence failures through `WalletSyncError` / `WalletSnapshotError`. - **First-class read-only account:** exported `WalletAccountReadOnlyRgbLightning extends WalletAccountReadOnly`, with all seven mandatory WDK reads plus node, channel, peer, invoice, payment, diff --git a/README.md b/README.md index eee157d..f4df490 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ are async and forward to the active binding. | Group | Methods | |-------|---------| | Lifecycle | `unlock(request)`, `getBootstrap()`, `shutdown()`, `dispose()` | -| Node info | `getNodeInfo()`, `getNetworkInfo()`, `sync()`, `getAddress()`, `getAddressState()`, `rotateAddress()` | +| Node info | `getNodeInfo()`, `getNetworkInfo()`, `refreshWalletSnapshot(options?)`, `sync()` (legacy), `getAddress()`, `getAddressState()`, `rotateAddress()` | | Peers | `connectPeer(pubkey@host:port)`, `disconnectPeer(request)`, `listPeers()` | | Channels | `openChannel(request)`, `closeChannel(request)`, `listChannels()`, `getChannelId(tempIdHex)` | | Invoices | `createInvoice(request)`, `createLightningInvoice(request)`, `decodeInvoice(invoice)`, `getInvoiceStatus(invoice)` | @@ -188,6 +188,18 @@ are async and forward to the active binding. Notes: +- **`refreshWalletSnapshot()` is the production balance/history refresh.** It + serializes native refreshes, coalesces identical requests, FullSyncs both + Vanilla and Colored keychains in `routine` mode, and FullScans both only in + explicit `recovery` mode. It validates the complete version-1 response, + preserves all monetary values as decimal strings, and retries one capture + if the chain tip changes between its before/after observations. The legacy + `sync()` remains for compatibility but only performs RLN's old Colored + FastSync and must not drive portfolio state. +- **Lightning claimable value is not routing capacity.** The snapshot keeps + aggregate/per-channel claimable satoshis separate from inbound and outbound + capacity. Consumers must not relabel either capacity as wallet-owned value. + - **`createInvoice` / `createLightningInvoice`** accept either RLN's native snake_case request or a camelCase convenience shape (`{ amountMsat?, expirySec, assetId?, assetAmount?, paymentHash?, diff --git a/index-bare.js b/index-bare.js index 8901951..76d7ced 100644 --- a/index-bare.js +++ b/index-bare.js @@ -28,6 +28,8 @@ export { VssError, VssNotConfiguredError, ApayError, + WalletSyncError, + WalletSnapshotError, NotImplementedError } from './src/errors.js' diff --git a/index-node.js b/index-node.js index a86fc5e..8bbe19e 100644 --- a/index-node.js +++ b/index-node.js @@ -30,6 +30,8 @@ export { VssError, VssNotConfiguredError, ApayError, + WalletSyncError, + WalletSnapshotError, NotImplementedError } from './src/errors.js' diff --git a/index.d.ts b/index.d.ts index b699849..7b23c13 100644 --- a/index.d.ts +++ b/index.d.ts @@ -21,6 +21,169 @@ import WalletManager, { WalletAccountReadOnly } from '@tetherto/wdk-wallet' export type Network = 'mainnet' | 'testnet' | 'regtest' | 'signet' +/** Integer encoded as base-10 text so values never cross JS's safe-number boundary. */ +export type DecimalString = `${bigint}` + +export type WalletSyncMode = 'routine' | 'recovery' + +export type WalletSyncKeychainResult = + | { status: 'succeeded' } + | { status: 'failed'; error_code: string } + +export interface WalletSyncResponse { + contract_version: 1 + mode: WalletSyncMode + vanilla: WalletSyncKeychainResult + colored: WalletSyncKeychainResult +} + +export interface WalletSnapshotOptions { + mode?: WalletSyncMode + assetIds?: string[] + maxAssets?: number + maxChannels?: number + maxActivityItems?: number + includeActivity?: boolean +} + +export interface WalletSnapshotNetwork { + network: string + height: number +} + +export interface WalletSnapshotBalance { + settled: DecimalString + future: DecimalString + spendable: DecimalString +} + +export interface WalletSnapshotBtc { + vanilla: WalletSnapshotBalance + colored: WalletSnapshotBalance +} + +export interface WalletSnapshotAssetBalance extends WalletSnapshotBalance { + offchain_outbound: DecimalString + offchain_inbound: DecimalString +} + +export interface WalletSnapshotAsset { + asset_id: string + ticker: string + name: string + precision: number + balance: WalletSnapshotAssetBalance +} + +export interface WalletSnapshotNode { + pubkey: string + num_channels: DecimalString + num_usable_channels: DecimalString + claimable_onchain_sat: DecimalString + eventual_close_fees_sat: DecimalString + pending_outbound_payments_sat: DecimalString + num_peers: DecimalString + latest_rgs_snapshot_timestamp: DecimalString | null +} + +export interface WalletSnapshotChannel { + channel_id: string + peer_pubkey: string + status: 'Opening' | 'Opened' | 'Closing' + ready: boolean + capacity_sat: DecimalString + claimable_onchain_sat: DecimalString + outbound_capacity_msat: DecimalString + inbound_capacity_msat: DecimalString + next_outbound_htlc_limit_msat: DecimalString + next_outbound_htlc_minimum_msat: DecimalString + is_usable: boolean + public: boolean + funding_txid: string | null + peer_alias: string | null + short_channel_id: DecimalString | null + asset_id: string | null + asset_local_amount: DecimalString | null + asset_remote_amount: DecimalString | null + virtual_open_mode: string | null +} + +export interface WalletSnapshotBlockTime { + height: number + timestamp: DecimalString +} + +export interface WalletSnapshotTransaction { + transaction_type: 'RgbSend' | 'Drain' | 'CreateUtxos' | 'SendBtc' | 'Incoming' + txid: string + received: DecimalString + sent: DecimalString + fee: DecimalString + confirmation_time: WalletSnapshotBlockTime | null +} + +export interface WalletSnapshotPayment { + amt_msat: DecimalString | null + asset_amount: DecimalString | null + asset_id: string | null + payment_hash: string + payment_type: 'Outbound' | 'InboundAutoClaim' | 'InboundHodl' + status: 'Pending' | 'Claimable' | 'Claiming' | 'Succeeded' | 'Cancelled' | 'Failed' + created_at: DecimalString + updated_at: DecimalString + payee_pubkey: string +} + +export interface WalletSnapshotTransferEndpoint { + endpoint: string + transport_type: string + used: boolean +} + +export interface WalletSnapshotTransfer { + idx: number + created_at: DecimalString + updated_at: DecimalString + status: string + requested_assignment: string | null + assignments: string[] + kind: string + txid: string | null + recipient_id: string | null + receive_utxo: string | null + change_utxo: string | null + expiration: DecimalString | null + transport_endpoints: WalletSnapshotTransferEndpoint[] +} + +export interface WalletSnapshotAssetTransfers { + asset_id: string + transfers: WalletSnapshotTransfer[] +} + +export interface WalletSnapshotResponse { + contract_version: 1 + native_source: 'rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1' + capture_sequence: DecimalString + started_at_ms: DecimalString + completed_at_ms: DecimalString + network_before: WalletSnapshotNetwork + network_after: WalletSnapshotNetwork + node: WalletSnapshotNode + btc: WalletSnapshotBtc + assets: WalletSnapshotAsset[] + channels: WalletSnapshotChannel[] + transactions?: WalletSnapshotTransaction[] + payments?: WalletSnapshotPayment[] + transfers?: WalletSnapshotAssetTransfers[] +} + +export interface WalletRefreshResult { + contractVersion: 1 + sync: WalletSyncResponse + snapshot: WalletSnapshotResponse +} + export interface Transaction { to: string value: number | bigint @@ -450,7 +613,9 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning // Node info / network / sync getNodeInfo(): Promise getNetworkInfo(): Promise + /** @deprecated Uses the legacy Colored-only FastSync. */ sync(): Promise<{ ok: true }> + refreshWalletSnapshot(options?: WalletSnapshotOptions): Promise // Channels openChannel(request: OpenChannelRequest | object): Promise @@ -526,16 +691,19 @@ export default class WalletManagerRgbLightning extends WalletManager { // ─────────────────────────────────────────────────────────────────── export class RgbLightningError extends Error { - constructor(message: string, opts?: { code?: string; cause?: unknown }) + constructor(message: string, opts?: { code?: string; cause?: unknown; details?: unknown }) code: string cause?: unknown - toJSON(): { name: string; code: string; message: string; cause: unknown } + details?: unknown + toJSON(): { name: string; code: string; message: string; details: unknown; cause: unknown } } export class UnlockError extends RgbLightningError {} export class AccountLockedError extends RgbLightningError {} export class VssError extends RgbLightningError {} export class VssNotConfiguredError extends VssError {} export class ApayError extends RgbLightningError {} +export class WalletSyncError extends RgbLightningError {} +export class WalletSnapshotError extends RgbLightningError {} export class NotImplementedError extends RgbLightningError {} // ─────────────────────────────────────────────────────────────────── diff --git a/src/errors.js b/src/errors.js index 10c8500..eb9df88 100644 --- a/src/errors.js +++ b/src/errors.js @@ -30,8 +30,8 @@ export class RgbLightningError extends Error { * Create an error for an RLN-backed account operation. * * @param {string} message - Human-readable failure description. - * @param {{ code?: string, cause?: unknown }} [opts] - Optional stable error - * code and originating failure. + * @param {{ code?: string, cause?: unknown, details?: unknown }} [opts] - + * Optional stable error code, originating failure, and structured details. */ constructor (message, opts = {}) { super(message) @@ -39,6 +39,7 @@ export class RgbLightningError extends Error { /** Stable, machine-readable error code. */ this.code = opts.code ?? 'RGB_LIGHTNING_ERROR' if (opts.cause !== undefined) this.cause = opts.cause + if (opts.details !== undefined) this.details = opts.details } toJSON () { @@ -46,6 +47,7 @@ export class RgbLightningError extends Error { name: this.name, code: this.code, message: this.message, + details: this.details ?? null, cause: this.cause instanceof Error ? { name: this.cause.name, message: this.cause.message } : (this.cause ?? null) @@ -112,6 +114,30 @@ export class ApayError extends RgbLightningError { } } +/** Raised when either native wallet keychain fails an explicit sync. */ +export class WalletSyncError extends RgbLightningError { + constructor (message, opts = {}) { + super(message, { + code: opts.code ?? 'WALLET_SYNC_FAILED', + cause: opts.cause, + details: opts.details + }) + this.name = 'WalletSyncError' + } +} + +/** Raised when the native snapshot is unavailable, malformed, or incoherent. */ +export class WalletSnapshotError extends RgbLightningError { + constructor (message, opts = {}) { + super(message, { + code: opts.code ?? 'WALLET_SNAPSHOT_FAILED', + cause: opts.cause, + details: opts.details + }) + this.name = 'WalletSnapshotError' + } +} + /** * Raised by surface that is intentionally not implemented in this * module (currently `signTransaction`) because the underlying C-FFI diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 551ae5d..e177fa2 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -28,9 +28,20 @@ import { VssError, VssNotConfiguredError, ApayError, + WalletSyncError, + WalletSnapshotError, NotImplementedError, wrapError } from './errors.js' +import { + WALLET_SNAPSHOT_CONTRACT_VERSION, + WalletSnapshotContractError, + isCoherentWalletSnapshot, + normalizeWalletSnapshotOptions, + validateWalletSnapshotResponse, + validateWalletSyncResponse, + walletSnapshotRequestKey +} from './wallet-snapshot-contract.js' export { PENDING_ADDRESS } @@ -56,6 +67,10 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL /** @private */ this._binding = bindings.binding /** @private @type {WalletAccountReadOnlyRgbLightning | null} */ this._readOnlyAccount = null + /** @private @type {Promise} */ + this._walletSnapshotQueue = Promise.resolve() + /** @private @type {Map>} */ + this._walletSnapshotInFlight = new Map() } /** @private */ @@ -344,12 +359,176 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL // Node lifecycle — read methods are inherited from the read-only account // ========================================================================== - /** Force a sync of the on-chain wallet. */ + /** + * Force the legacy Colored-only FastSync. + * @deprecated Use `refreshWalletSnapshot()` so both keychains are synced. + */ async sync () { this._node.sync() return { ok: true } } + /** + * Synchronize both native wallet keychains and capture one versioned, + * bounded snapshot. Identical concurrent requests coalesce, while different + * requests serialize so FullSync and FullScan cannot race each other. + * + * A snapshot whose before/after chain tip differs is captured once more. + * The method fails closed if the retry is also incoherent. + * + * @param {object} [options] + * @returns {Promise} + */ + refreshWalletSnapshot (options) { + const normalized = normalizeWalletSnapshotOptions(options) + const key = walletSnapshotRequestKey(normalized) + const current = this._walletSnapshotInFlight.get(key) + if (current) return current + + const operation = this._walletSnapshotQueue + .then(() => this._refreshWalletSnapshot(normalized)) + this._walletSnapshotQueue = operation.then( + () => undefined, + () => undefined + ) + this._walletSnapshotInFlight.set(key, operation) + operation.then( + () => this._clearWalletSnapshotFlight(key, operation), + () => this._clearWalletSnapshotFlight(key, operation) + ) + return operation + } + + /** @private */ + _clearWalletSnapshotFlight (key, operation) { + if (this._walletSnapshotInFlight.get(key) === operation) { + this._walletSnapshotInFlight.delete(key) + } + } + + /** @private */ + async _refreshWalletSnapshot (options) { + const node = this._node + if ( + typeof node.syncWallet !== 'function' || + typeof node.walletSnapshot !== 'function' + ) { + throw new WalletSnapshotError( + 'The installed RGB Lightning native binding does not support wallet snapshot contract v1.', + { code: 'WALLET_SNAPSHOT_UNSUPPORTED_BINDING' } + ) + } + + let sync + try { + sync = validateWalletSyncResponse( + await node.syncWallet({ mode: options.mode }), + options.mode + ) + } catch (error) { + const contractFailure = error instanceof WalletSnapshotContractError + throw new WalletSyncError( + contractFailure + ? 'The native wallet sync response does not match contract v1.' + : 'The native wallet synchronization failed.', + { + code: contractFailure + ? 'WALLET_SYNC_CONTRACT_MISMATCH' + : 'WALLET_SYNC_NATIVE_FAILURE', + cause: error, + details: Object.freeze({ mode: options.mode }) + } + ) + } + + if (sync.vanilla.status !== 'succeeded' || sync.colored.status !== 'succeeded') { + throw new WalletSyncError( + 'The native wallet synchronization did not complete for both keychains.', + { + code: 'WALLET_SYNC_PARTIAL_FAILURE', + details: Object.freeze({ + mode: options.mode, + vanilla: sync.vanilla, + colored: sync.colored + }) + } + ) + } + + const first = await this._captureWalletSnapshot(node, options) + if (isCoherentWalletSnapshot(first)) { + return Object.freeze({ + contractVersion: WALLET_SNAPSHOT_CONTRACT_VERSION, + sync, + snapshot: first + }) + } + + const retry = await this._captureWalletSnapshot(node, options) + if (BigInt(retry.capture_sequence) <= BigInt(first.capture_sequence)) { + throw new WalletSnapshotError( + 'The native wallet snapshot retry did not advance its capture sequence.', + { + code: 'WALLET_SNAPSHOT_CONTRACT_MISMATCH', + details: Object.freeze({ + firstCaptureSequence: first.capture_sequence, + retryCaptureSequence: retry.capture_sequence + }) + } + ) + } + if (!isCoherentWalletSnapshot(retry)) { + throw new WalletSnapshotError( + 'The native wallet snapshot changed chain tip during both capture attempts.', + { + code: 'WALLET_SNAPSHOT_INCOHERENT', + details: Object.freeze({ + first: Object.freeze({ + before: first.network_before, + after: first.network_after, + captureSequence: first.capture_sequence + }), + retry: Object.freeze({ + before: retry.network_before, + after: retry.network_after, + captureSequence: retry.capture_sequence + }) + }) + } + ) + } + + return Object.freeze({ + contractVersion: WALLET_SNAPSHOT_CONTRACT_VERSION, + sync, + snapshot: retry + }) + } + + /** @private */ + async _captureWalletSnapshot (node, options) { + try { + return validateWalletSnapshotResponse( + await node.walletSnapshot(options.nativeRequest), + options + ) + } catch (error) { + if (error instanceof WalletSnapshotError) throw error + const contractFailure = error instanceof WalletSnapshotContractError + throw new WalletSnapshotError( + contractFailure + ? 'The native wallet snapshot does not match contract v1.' + : 'The native wallet snapshot could not be captured.', + { + code: contractFailure + ? 'WALLET_SNAPSHOT_CONTRACT_MISMATCH' + : 'WALLET_SNAPSHOT_NATIVE_FAILURE', + cause: error + } + ) + } + } + // ========================================================================== // Channels // ========================================================================== diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js new file mode 100644 index 0000000..d142c77 --- /dev/null +++ b/src/wallet-snapshot-contract.js @@ -0,0 +1,437 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +'use strict' + +export const WALLET_SNAPSHOT_CONTRACT_VERSION = 1 +export const WALLET_SNAPSHOT_NATIVE_SOURCE = 'rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1' + +const NATIVE_LIMITS = Object.freeze({ + assets: 128, + channels: 512, + activityItems: 5000 +}) + +export const DEFAULT_WALLET_SNAPSHOT_OPTIONS = Object.freeze({ + mode: 'routine', + assetIds: Object.freeze([]), + maxAssets: NATIVE_LIMITS.assets, + maxChannels: NATIVE_LIMITS.channels, + maxActivityItems: 1000, + includeActivity: false +}) + +const DECIMAL_TEXT = /^(0|[1-9][0-9]*)$/ +const HAS_OWN = (value, key) => Object.prototype.hasOwnProperty.call(value, key) + +export class WalletSnapshotContractError extends Error { + constructor (path, expectation) { + super(`${path} ${expectation}`) + this.name = 'WalletSnapshotContractError' + this.path = path + } +} + +function fail (path, expectation) { + throw new WalletSnapshotContractError(path, expectation) +} + +function record (value, path) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'must be an object') + } + return value +} + +function exactKeys (value, required, optional, path) { + const allowed = new Set([...required, ...optional]) + for (const key of required) { + if (!HAS_OWN(value, key)) fail(`${path}.${key}`, 'is required') + } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) fail(`${path}.${key}`, 'is not part of contract v1') + } +} + +function text (value, path, maxLength) { + if (typeof value !== 'string' || value.length === 0 || value.length > maxLength) { + fail(path, `must be non-empty text no longer than ${maxLength} characters`) + } + return value +} + +function nullableText (value, path, maxLength) { + if (value === null) return null + return text(value, path, maxLength) +} + +function decimal (value, path) { + if (typeof value !== 'string' || !DECIMAL_TEXT.test(value)) { + fail(path, 'must be an unsigned base-10 integer string') + } + return value +} + +function nullableDecimal (value, path) { + if (value === null) return null + return decimal(value, path) +} + +function boolean (value, path) { + if (typeof value !== 'boolean') fail(path, 'must be a boolean') + return value +} + +function integer (value, path, minimum, maximum) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + fail(path, `must be an integer between ${minimum} and ${maximum}`) + } + return value +} + +function oneOf (value, values, path) { + if (!values.includes(value)) fail(path, `must be one of: ${values.join(', ')}`) + return value +} + +function array (value, path, maximum) { + if (!Array.isArray(value) || value.length > maximum) { + fail(path, `must be an array with at most ${maximum} entries`) + } + return value +} + +function optionalObjectInput (value) { + if (value === undefined) return {} + return record(value, 'options') +} + +function inputLimit (value, fallback, maximum, path) { + if (value === undefined) return fallback + return integer(value, path, 1, maximum) +} + +function uniqueTextArray (value, maximum, path) { + if (value === undefined) return [] + const rows = array(value, path, maximum) + const seen = new Set() + return rows.map((row, index) => { + const item = text(row, `${path}[${index}]`, 256) + if (seen.has(item)) fail(`${path}[${index}]`, 'must not duplicate another entry') + seen.add(item) + return item + }) +} + +export function normalizeWalletSnapshotOptions (value) { + const input = optionalObjectInput(value) + exactKeys( + input, + [], + ['mode', 'assetIds', 'maxAssets', 'maxChannels', 'maxActivityItems', 'includeActivity'], + 'options' + ) + + const mode = input.mode === undefined + ? DEFAULT_WALLET_SNAPSHOT_OPTIONS.mode + : oneOf(input.mode, ['routine', 'recovery'], 'options.mode') + const maxAssets = inputLimit( + input.maxAssets, + DEFAULT_WALLET_SNAPSHOT_OPTIONS.maxAssets, + NATIVE_LIMITS.assets, + 'options.maxAssets' + ) + const maxChannels = inputLimit( + input.maxChannels, + DEFAULT_WALLET_SNAPSHOT_OPTIONS.maxChannels, + NATIVE_LIMITS.channels, + 'options.maxChannels' + ) + const maxActivityItems = inputLimit( + input.maxActivityItems, + DEFAULT_WALLET_SNAPSHOT_OPTIONS.maxActivityItems, + NATIVE_LIMITS.activityItems, + 'options.maxActivityItems' + ) + const assetIds = uniqueTextArray(input.assetIds, maxAssets, 'options.assetIds') + const includeActivity = input.includeActivity === undefined + ? DEFAULT_WALLET_SNAPSHOT_OPTIONS.includeActivity + : boolean(input.includeActivity, 'options.includeActivity') + + return Object.freeze({ + mode, + assetIds: Object.freeze(assetIds), + maxAssets, + maxChannels, + maxActivityItems, + includeActivity, + nativeRequest: Object.freeze({ + asset_ids: Object.freeze([...assetIds]), + max_assets: maxAssets, + max_channels: maxChannels, + max_activity_items: maxActivityItems, + include_activity: includeActivity + }) + }) +} + +function syncKeychain (value, path) { + const item = record(value, path) + exactKeys(item, ['status'], ['error_code'], path) + oneOf(item.status, ['succeeded', 'failed'], `${path}.status`) + if (item.status === 'succeeded' && HAS_OWN(item, 'error_code')) { + fail(`${path}.error_code`, 'must be omitted after a successful sync') + } + if (item.status === 'failed') { + text(item.error_code, `${path}.error_code`, 128) + } +} + +export function validateWalletSyncResponse (value, expectedMode) { + const response = record(value, 'sync') + exactKeys(response, ['contract_version', 'mode', 'vanilla', 'colored'], [], 'sync') + if (response.contract_version !== WALLET_SNAPSHOT_CONTRACT_VERSION) { + fail('sync.contract_version', `must equal ${WALLET_SNAPSHOT_CONTRACT_VERSION}`) + } + if (response.mode !== expectedMode) fail('sync.mode', `must equal ${expectedMode}`) + syncKeychain(response.vanilla, 'sync.vanilla') + syncKeychain(response.colored, 'sync.colored') + return deepFreeze(response) +} + +function network (value, path) { + const item = record(value, path) + exactKeys(item, ['network', 'height'], [], path) + text(item.network, `${path}.network`, 32) + integer(item.height, `${path}.height`, 0, 0xffffffff) +} + +function balance (value, path, includeOffchain) { + const item = record(value, path) + const fields = includeOffchain + ? ['settled', 'future', 'spendable', 'offchain_outbound', 'offchain_inbound'] + : ['settled', 'future', 'spendable'] + exactKeys(item, fields, [], path) + for (const field of fields) decimal(item[field], `${path}.${field}`) +} + +function snapshotNode (value) { + const path = 'snapshot.node' + const item = record(value, path) + const fields = [ + 'pubkey', + 'num_channels', + 'num_usable_channels', + 'claimable_onchain_sat', + 'eventual_close_fees_sat', + 'pending_outbound_payments_sat', + 'num_peers', + 'latest_rgs_snapshot_timestamp' + ] + exactKeys(item, fields, [], path) + text(item.pubkey, `${path}.pubkey`, 130) + for (const field of fields.slice(1, -1)) decimal(item[field], `${path}.${field}`) + nullableDecimal(item.latest_rgs_snapshot_timestamp, `${path}.latest_rgs_snapshot_timestamp`) +} + +function snapshotAsset (value, path) { + const item = record(value, path) + exactKeys(item, ['asset_id', 'ticker', 'name', 'precision', 'balance'], [], path) + text(item.asset_id, `${path}.asset_id`, 256) + text(item.ticker, `${path}.ticker`, 32) + text(item.name, `${path}.name`, 256) + integer(item.precision, `${path}.precision`, 0, 255) + balance(item.balance, `${path}.balance`, true) +} + +function snapshotChannel (value, path) { + const item = record(value, path) + const fields = [ + 'channel_id', 'peer_pubkey', 'status', 'ready', 'capacity_sat', + 'claimable_onchain_sat', 'outbound_capacity_msat', 'inbound_capacity_msat', + 'next_outbound_htlc_limit_msat', 'next_outbound_htlc_minimum_msat', + 'is_usable', 'public', 'funding_txid', 'peer_alias', 'short_channel_id', + 'asset_id', 'asset_local_amount', 'asset_remote_amount', 'virtual_open_mode' + ] + exactKeys(item, fields, [], path) + text(item.channel_id, `${path}.channel_id`, 128) + text(item.peer_pubkey, `${path}.peer_pubkey`, 130) + oneOf(item.status, ['Opening', 'Opened', 'Closing'], `${path}.status`) + boolean(item.ready, `${path}.ready`) + for (const field of fields.slice(4, 10)) decimal(item[field], `${path}.${field}`) + boolean(item.is_usable, `${path}.is_usable`) + boolean(item.public, `${path}.public`) + nullableText(item.funding_txid, `${path}.funding_txid`, 128) + nullableText(item.peer_alias, `${path}.peer_alias`, 256) + nullableDecimal(item.short_channel_id, `${path}.short_channel_id`) + nullableText(item.asset_id, `${path}.asset_id`, 256) + nullableDecimal(item.asset_local_amount, `${path}.asset_local_amount`) + nullableDecimal(item.asset_remote_amount, `${path}.asset_remote_amount`) + nullableText(item.virtual_open_mode, `${path}.virtual_open_mode`, 64) +} + +function blockTime (value, path) { + if (value === null) return + const item = record(value, path) + exactKeys(item, ['height', 'timestamp'], [], path) + integer(item.height, `${path}.height`, 0, 0xffffffff) + decimal(item.timestamp, `${path}.timestamp`) +} + +function snapshotTransaction (value, path) { + const item = record(value, path) + exactKeys(item, ['transaction_type', 'txid', 'received', 'sent', 'fee', 'confirmation_time'], [], path) + oneOf(item.transaction_type, ['RgbSend', 'Drain', 'CreateUtxos', 'SendBtc', 'Incoming'], `${path}.transaction_type`) + text(item.txid, `${path}.txid`, 128) + decimal(item.received, `${path}.received`) + decimal(item.sent, `${path}.sent`) + decimal(item.fee, `${path}.fee`) + blockTime(item.confirmation_time, `${path}.confirmation_time`) +} + +function snapshotPayment (value, path) { + const item = record(value, path) + const fields = [ + 'amt_msat', 'asset_amount', 'asset_id', 'payment_hash', 'payment_type', + 'status', 'created_at', 'updated_at', 'payee_pubkey' + ] + exactKeys(item, fields, [], path) + nullableDecimal(item.amt_msat, `${path}.amt_msat`) + nullableDecimal(item.asset_amount, `${path}.asset_amount`) + nullableText(item.asset_id, `${path}.asset_id`, 256) + text(item.payment_hash, `${path}.payment_hash`, 128) + oneOf(item.payment_type, ['Outbound', 'InboundAutoClaim', 'InboundHodl'], `${path}.payment_type`) + oneOf(item.status, ['Pending', 'Claimable', 'Claiming', 'Succeeded', 'Cancelled', 'Failed'], `${path}.status`) + decimal(item.created_at, `${path}.created_at`) + decimal(item.updated_at, `${path}.updated_at`) + text(item.payee_pubkey, `${path}.payee_pubkey`, 130) +} + +function transferEndpoint (value, path) { + const item = record(value, path) + exactKeys(item, ['endpoint', 'transport_type', 'used'], [], path) + text(item.endpoint, `${path}.endpoint`, 4096) + text(item.transport_type, `${path}.transport_type`, 64) + boolean(item.used, `${path}.used`) +} + +function snapshotTransfer (value, path) { + const item = record(value, path) + const fields = [ + 'idx', 'created_at', 'updated_at', 'status', 'requested_assignment', + 'assignments', 'kind', 'txid', 'recipient_id', 'receive_utxo', + 'change_utxo', 'expiration', 'transport_endpoints' + ] + exactKeys(item, fields, [], path) + integer(item.idx, `${path}.idx`, 0, 0x7fffffff) + decimal(item.created_at, `${path}.created_at`) + decimal(item.updated_at, `${path}.updated_at`) + text(item.status, `${path}.status`, 64) + nullableText(item.requested_assignment, `${path}.requested_assignment`, 1024) + array(item.assignments, `${path}.assignments`, 1024).forEach((entry, index) => { + text(entry, `${path}.assignments[${index}]`, 1024) + }) + text(item.kind, `${path}.kind`, 64) + nullableText(item.txid, `${path}.txid`, 128) + nullableText(item.recipient_id, `${path}.recipient_id`, 1024) + nullableText(item.receive_utxo, `${path}.receive_utxo`, 256) + nullableText(item.change_utxo, `${path}.change_utxo`, 256) + nullableDecimal(item.expiration, `${path}.expiration`) + array(item.transport_endpoints, `${path}.transport_endpoints`, 64).forEach((entry, index) => { + transferEndpoint(entry, `${path}.transport_endpoints[${index}]`) + }) +} + +function snapshotTransfers (value, path, options) { + const item = record(value, path) + exactKeys(item, ['asset_id', 'transfers'], [], path) + const assetId = text(item.asset_id, `${path}.asset_id`, 256) + if (!options.assetIds.includes(assetId)) { + fail(`${path}.asset_id`, 'must have been requested explicitly') + } + array(item.transfers, `${path}.transfers`, options.maxActivityItems) + .forEach((entry, index) => snapshotTransfer(entry, `${path}.transfers[${index}]`)) +} + +function assertUnique (rows, key, path) { + const seen = new Set() + rows.forEach((row, index) => { + if (seen.has(row[key])) fail(`${path}[${index}].${key}`, 'must be unique') + seen.add(row[key]) + }) +} + +export function validateWalletSnapshotResponse (value, options) { + const snapshot = record(value, 'snapshot') + const required = [ + 'contract_version', 'native_source', 'capture_sequence', 'started_at_ms', + 'completed_at_ms', 'network_before', 'network_after', 'node', 'btc', + 'assets', 'channels' + ] + const optional = ['transactions', 'payments', 'transfers'] + exactKeys(snapshot, required, optional, 'snapshot') + if (snapshot.contract_version !== WALLET_SNAPSHOT_CONTRACT_VERSION) { + fail('snapshot.contract_version', `must equal ${WALLET_SNAPSHOT_CONTRACT_VERSION}`) + } + if (snapshot.native_source !== WALLET_SNAPSHOT_NATIVE_SOURCE) { + fail('snapshot.native_source', `must equal ${WALLET_SNAPSHOT_NATIVE_SOURCE}`) + } + decimal(snapshot.capture_sequence, 'snapshot.capture_sequence') + if (BigInt(snapshot.capture_sequence) === 0n) fail('snapshot.capture_sequence', 'must be greater than zero') + decimal(snapshot.started_at_ms, 'snapshot.started_at_ms') + decimal(snapshot.completed_at_ms, 'snapshot.completed_at_ms') + if (BigInt(snapshot.completed_at_ms) < BigInt(snapshot.started_at_ms)) { + fail('snapshot.completed_at_ms', 'must not precede started_at_ms') + } + network(snapshot.network_before, 'snapshot.network_before') + network(snapshot.network_after, 'snapshot.network_after') + snapshotNode(snapshot.node) + + const btc = record(snapshot.btc, 'snapshot.btc') + exactKeys(btc, ['vanilla', 'colored'], [], 'snapshot.btc') + balance(btc.vanilla, 'snapshot.btc.vanilla', false) + balance(btc.colored, 'snapshot.btc.colored', false) + + const assets = array(snapshot.assets, 'snapshot.assets', options.maxAssets) + assets.forEach((entry, index) => snapshotAsset(entry, `snapshot.assets[${index}]`)) + assertUnique(assets, 'asset_id', 'snapshot.assets') + + const channels = array(snapshot.channels, 'snapshot.channels', options.maxChannels) + channels.forEach((entry, index) => snapshotChannel(entry, `snapshot.channels[${index}]`)) + assertUnique(channels, 'channel_id', 'snapshot.channels') + + if (options.includeActivity) { + for (const field of optional) { + if (!HAS_OWN(snapshot, field)) fail(`snapshot.${field}`, 'is required when includeActivity is true') + } + array(snapshot.transactions, 'snapshot.transactions', options.maxActivityItems) + .forEach((entry, index) => snapshotTransaction(entry, `snapshot.transactions[${index}]`)) + array(snapshot.payments, 'snapshot.payments', options.maxActivityItems) + .forEach((entry, index) => snapshotPayment(entry, `snapshot.payments[${index}]`)) + const transfers = array(snapshot.transfers, 'snapshot.transfers', options.maxAssets) + transfers.forEach((entry, index) => snapshotTransfers(entry, `snapshot.transfers[${index}]`, options)) + assertUnique(transfers, 'asset_id', 'snapshot.transfers') + } else { + for (const field of optional) { + if (HAS_OWN(snapshot, field)) fail(`snapshot.${field}`, 'must be omitted when includeActivity is false') + } + } + + return deepFreeze(snapshot) +} + +export function isCoherentWalletSnapshot (snapshot) { + return snapshot.network_before.network === snapshot.network_after.network && + snapshot.network_before.height === snapshot.network_after.height +} + +export function walletSnapshotRequestKey (options) { + return JSON.stringify([options.mode, options.nativeRequest]) +} + +function deepFreeze (value) { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value + for (const child of Object.values(value)) deepFreeze(child) + return Object.freeze(value) +} diff --git a/tests/errors.test.js b/tests/errors.test.js index 8a9e379..d0ae751 100644 --- a/tests/errors.test.js +++ b/tests/errors.test.js @@ -13,6 +13,8 @@ import { VssError, VssNotConfiguredError, ApayError, + WalletSyncError, + WalletSnapshotError, NotImplementedError, wrapError } from '../src/errors.js' @@ -25,11 +27,21 @@ describe('error hierarchy', () => { expect(new VssError('m')).toMatchObject({ name: 'VssError', code: 'VSS_ERROR' }) expect(new VssNotConfiguredError()).toMatchObject({ name: 'VssNotConfiguredError', code: 'VSS_NOT_CONFIGURED' }) expect(new ApayError('m')).toMatchObject({ name: 'ApayError', code: 'APAY_ERROR' }) + expect(new WalletSyncError('m')).toMatchObject({ name: 'WalletSyncError', code: 'WALLET_SYNC_FAILED' }) + expect(new WalletSnapshotError('m')).toMatchObject({ name: 'WalletSnapshotError', code: 'WALLET_SNAPSHOT_FAILED' }) expect(new NotImplementedError('m')).toMatchObject({ name: 'NotImplementedError', code: 'NOT_IMPLEMENTED' }) }) it('keeps every subclass an instanceof the base (and Error)', () => { - for (const E of [UnlockError, AccountLockedError, VssError, ApayError, NotImplementedError]) { + for (const E of [ + UnlockError, + AccountLockedError, + VssError, + ApayError, + WalletSyncError, + WalletSnapshotError, + NotImplementedError + ]) { const e = new E('x') expect(e).toBeInstanceOf(Error) expect(e).toBeInstanceOf(RgbLightningError) @@ -48,6 +60,7 @@ describe('error hierarchy', () => { name: 'UnlockError', code: 'UNLOCK_FAILED', message: 'bad creds', + details: null, cause: { name: 'Error', message: 'root cause' } }) }) @@ -55,6 +68,11 @@ describe('error hierarchy', () => { it('toJSON reports a null cause when none was provided', () => { expect(new VssError('x').toJSON().cause).toBeNull() }) + + it('serializes structured error details without dropping them', () => { + const details = { vanilla: { status: 'failed' } } + expect(new WalletSyncError('x', { details }).toJSON().details).toBe(details) + }) }) describe('wrapError', () => { diff --git a/tests/types-contract.ts b/tests/types-contract.ts index 62eb6ce..9478e23 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -13,6 +13,8 @@ import type { LnurlPayOptions, LspLiquidityTimeoutError, PayAddressOptions, + WalletRefreshResult, + WalletSnapshotOptions, WalletAccountReadOnlyRgbLightning, WalletAccountRgbLightning } from '../index.js' @@ -50,6 +52,17 @@ const lnurlError = new LnurlPayError('request failed', { }) const lnurlStatus: number | undefined = lnurlError.status const lnurlBody: string | undefined = lnurlError.body +const walletSnapshotOptions: WalletSnapshotOptions = { + mode: 'recovery', + assetIds: ['rgb:asset'], + includeActivity: true +} +const refreshed: Promise = account.refreshWalletSnapshot(walletSnapshotOptions) + +// @ts-expect-error recovery mode is explicit; arbitrary sync strategies are rejected. +account.refreshWalletSnapshot({ mode: 'fast' }) +// @ts-expect-error read-only accounts cannot mutate native sync state. +readOnlyAccount.refreshWalletSnapshot() binding.ensureNode() // @ts-expect-error IRgbLightningBinding exposes ensureNode(), not a node property. @@ -62,3 +75,4 @@ void nodeHealth void bareHealth void lnurlStatus void lnurlBody +void refreshed diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js new file mode 100644 index 0000000..80e54e6 --- /dev/null +++ b/tests/wallet-snapshot-contract.test.js @@ -0,0 +1,477 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +import { jest } from '@jest/globals' + +import WalletAccountRgbLightning from '../src/wallet-account-rgb-lightning.js' +import { + WalletSnapshotError, + WalletSyncError +} from '../src/errors.js' +import { + normalizeWalletSnapshotOptions, + validateWalletSnapshotResponse, + validateWalletSyncResponse +} from '../src/wallet-snapshot-contract.js' + +function syncResult (overrides = {}) { + return { + contract_version: 1, + mode: 'routine', + vanilla: { status: 'succeeded' }, + colored: { status: 'succeeded' }, + ...overrides + } +} + +function snapshot (overrides = {}) { + return { + contract_version: 1, + native_source: 'rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1', + capture_sequence: '1', + started_at_ms: '1000', + completed_at_ms: '1001', + network_before: { network: 'regtest', height: 100 }, + network_after: { network: 'regtest', height: 100 }, + node: { + pubkey: '02abc', + num_channels: '1', + num_usable_channels: '1', + claimable_onchain_sat: '9007199254740993', + eventual_close_fees_sat: '10', + pending_outbound_payments_sat: '0', + num_peers: '1', + latest_rgs_snapshot_timestamp: null + }, + btc: { + vanilla: { settled: '42', future: '45', spendable: '40' }, + colored: { settled: '5', future: '5', spendable: '5' } + }, + assets: [{ + asset_id: 'asset-1', + ticker: 'USDT', + name: 'Tether USD', + precision: 2, + balance: { + settled: '100', + future: '100', + spendable: '80', + offchain_outbound: '20', + offchain_inbound: '30' + } + }], + channels: [{ + channel_id: 'channel-1', + peer_pubkey: '03def', + status: 'Opened', + ready: true, + capacity_sat: '100000', + claimable_onchain_sat: '60000', + outbound_capacity_msat: '59000000', + inbound_capacity_msat: '39000000', + next_outbound_htlc_limit_msat: '58000000', + next_outbound_htlc_minimum_msat: '1000', + is_usable: true, + public: false, + funding_txid: null, + peer_alias: null, + short_channel_id: null, + asset_id: 'asset-1', + asset_local_amount: '20', + asset_remote_amount: '30', + virtual_open_mode: 'trusted_no_broadcast' + }], + ...overrides + } +} + +function activitySnapshot (overrides = {}) { + return snapshot({ + transactions: [{ + transaction_type: 'Incoming', + txid: 'txid-1', + received: '42', + sent: '0', + fee: '0', + confirmation_time: { height: 100, timestamp: '1000' } + }], + payments: [{ + amt_msat: '1000', + asset_amount: null, + asset_id: null, + payment_hash: 'hash-1', + payment_type: 'InboundAutoClaim', + status: 'Succeeded', + created_at: '1000', + updated_at: '1001', + payee_pubkey: '02abc' + }], + transfers: [{ + asset_id: 'asset-1', + transfers: [{ + idx: 1, + created_at: '1000', + updated_at: '1001', + status: 'Settled', + requested_assignment: null, + assignments: ['100'], + kind: 'ReceiveWitness', + txid: 'txid-1', + recipient_id: null, + receive_utxo: null, + change_utxo: null, + expiration: null, + transport_endpoints: [] + }] + }], + ...overrides + }) +} + +function accountWith (node) { + return new WalletAccountRgbLightning({ + binding: { + ensureNode: jest.fn(() => node), + bootstrap: jest.fn(() => ({})), + vssStatus: jest.fn(() => ({ configured: false })), + shutdown: jest.fn() + } + }) +} + +describe('wallet snapshot option contract', () => { + it('normalizes bounded defaults and exact native names', () => { + const options = normalizeWalletSnapshotOptions() + expect(options).toMatchObject({ + mode: 'routine', + maxAssets: 128, + maxChannels: 512, + maxActivityItems: 1000, + includeActivity: false, + assetIds: [] + }) + expect(options.nativeRequest).toEqual({ + asset_ids: [], + max_assets: 128, + max_channels: 512, + max_activity_items: 1000, + include_activity: false + }) + expect(Object.isFrozen(options.nativeRequest)).toBe(true) + }) + + it.each([ + [{ typo: true }, 'options.typo'], + [{ mode: 'fast' }, 'options.mode'], + [{ maxAssets: 0 }, 'options.maxAssets'], + [{ maxChannels: 513 }, 'options.maxChannels'], + [{ assetIds: ['same', 'same'] }, 'options.assetIds[1]'] + ])('rejects invalid options without silently applying defaults', (input, path) => { + expect(() => normalizeWalletSnapshotOptions(input)).toThrow(path) + }) +}) + +describe('wallet snapshot response contract', () => { + it('accepts exact decimal strings beyond Number.MAX_SAFE_INTEGER', () => { + const options = normalizeWalletSnapshotOptions() + const result = validateWalletSnapshotResponse(snapshot(), options) + expect(result.node.claimable_onchain_sat).toBe('9007199254740993') + expect(Object.isFrozen(result.channels[0])).toBe(true) + }) + + it('rejects unsafe JSON numbers and additive v1 fields', () => { + const options = normalizeWalletSnapshotOptions() + expect(() => validateWalletSnapshotResponse(snapshot({ + btc: { + vanilla: { settled: Number.MAX_SAFE_INTEGER + 2, future: '45', spendable: '40' }, + colored: { settled: '5', future: '5', spendable: '5' } + } + }), options)).toThrow('snapshot.btc.vanilla.settled') + expect(() => validateWalletSnapshotResponse(snapshot({ extra: true }), options)) + .toThrow('snapshot.extra') + }) + + it('requires the bounded activity envelope only when requested', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + expect(() => validateWalletSnapshotResponse(snapshot(), options)) + .toThrow('snapshot.transactions') + expect(validateWalletSnapshotResponse(activitySnapshot(), options).payments) + .toHaveLength(1) + }) + + it('accepts transfer endpoint metadata and nullable transfer fields', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + const value = activitySnapshot() + value.transfers[0].transfers[0].transport_endpoints = [{ + endpoint: 'rpc://127.0.0.1:3000/json-rpc', + transport_type: 'JsonRpc', + used: true + }] + + expect(validateWalletSnapshotResponse(value, options).transfers[0] + .transfers[0].transport_endpoints[0].used).toBe(true) + }) + + it('accepts an unconfirmed transaction without block-time metadata', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + const value = activitySnapshot() + value.transactions[0].confirmation_time = null + + expect(validateWalletSnapshotResponse(value, options).transactions[0] + .confirmation_time).toBeNull() + }) + + it.each([ + [null, 'snapshot'], + [snapshot({ node: { ...snapshot().node, pubkey: '' } }), 'snapshot.node.pubkey'], + [snapshot({ node: { ...snapshot().node, pubkey: 42 } }), 'snapshot.node.pubkey'], + [snapshot({ node: { ...snapshot().node, pubkey: 'a'.repeat(131) } }), 'snapshot.node.pubkey'], + [snapshot({ assets: {} }), 'snapshot.assets'], + [snapshot({ contract_version: 2 }), 'snapshot.contract_version'], + [snapshot({ native_source: 'untrusted-native' }), 'snapshot.native_source'], + [snapshot({ started_at_ms: '1002', completed_at_ms: '1001' }), 'snapshot.completed_at_ms'], + [snapshot({ capture_sequence: '0' }), 'snapshot.capture_sequence'], + [snapshot({ channels: [{ ...snapshot().channels[0], ready: 'true' }] }), 'snapshot.channels[0].ready'], + [snapshot({ assets: [snapshot().assets[0], snapshot().assets[0]] }), 'snapshot.assets[1].asset_id'] + ])('rejects malformed snapshot contract evidence', (value, path) => { + const options = normalizeWalletSnapshotOptions() + expect(() => validateWalletSnapshotResponse(value, options)).toThrow(path) + }) + + it('rejects activity for an asset that was not requested', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + const value = activitySnapshot() + value.transfers[0].asset_id = 'asset-2' + + expect(() => validateWalletSnapshotResponse(value, options)) + .toThrow('snapshot.transfers[0].asset_id') + }) + + it('requires activity fields to be absent when activity was not requested', () => { + const options = normalizeWalletSnapshotOptions() + expect(() => validateWalletSnapshotResponse(snapshot({ transactions: [] }), options)) + .toThrow('snapshot.transactions') + }) + + it('requires every top-level snapshot field', () => { + const options = normalizeWalletSnapshotOptions() + const value = snapshot() + delete value.node + + expect(() => validateWalletSnapshotResponse(value, options)).toThrow('snapshot.node') + }) + + it.each([ + [syncResult({ contract_version: 2 }), 'sync.contract_version'], + [syncResult({ vanilla: { status: 'succeeded', error_code: 'IMPOSSIBLE' } }), 'sync.vanilla.error_code'], + [null, 'sync'] + ])('rejects malformed sync contract evidence', (value, path) => { + expect(() => validateWalletSyncResponse(value, 'routine')).toThrow(path) + }) + + it('rejects a sync response for a different requested mode', () => { + expect(() => validateWalletSyncResponse(syncResult(), 'recovery')) + .toThrow('sync.mode') + }) +}) + +describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { + it('syncs both keychains then captures an immutable coherent snapshot', async () => { + const node = { + syncWallet: jest.fn(() => syncResult()), + walletSnapshot: jest.fn(() => snapshot()) + } + const account = accountWith(node) + + const result = await account.refreshWalletSnapshot() + + expect(node.syncWallet).toHaveBeenCalledWith({ mode: 'routine' }) + expect(node.walletSnapshot).toHaveBeenCalledWith({ + asset_ids: [], + max_assets: 128, + max_channels: 512, + max_activity_items: 1000, + include_activity: false + }) + expect(result.contractVersion).toBe(1) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result.snapshot.btc)).toBe(true) + }) + + it('preserves structured evidence when only one keychain fails', async () => { + const node = { + syncWallet: jest.fn(() => syncResult({ + vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' } + })), + walletSnapshot: jest.fn() + } + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + + expect(error).toBeInstanceOf(WalletSyncError) + expect(error.code).toBe('WALLET_SYNC_PARTIAL_FAILURE') + expect(error.details.vanilla).toEqual({ + status: 'failed', + error_code: 'FAILED_BDK_SYNC' + }) + expect(node.walletSnapshot).not.toHaveBeenCalled() + }) + + it('uses FullScan recovery mode only when explicitly selected', async () => { + const node = { + syncWallet: jest.fn(() => syncResult({ mode: 'recovery' })), + walletSnapshot: jest.fn(() => snapshot()) + } + await accountWith(node).refreshWalletSnapshot({ mode: 'recovery' }) + expect(node.syncWallet).toHaveBeenCalledWith({ mode: 'recovery' }) + }) + + it('retries one incoherent capture and returns the coherent retry', async () => { + const node = { + syncWallet: jest.fn(() => syncResult()), + walletSnapshot: jest.fn() + .mockReturnValueOnce(snapshot({ + capture_sequence: '7', + network_after: { network: 'regtest', height: 101 } + })) + .mockReturnValueOnce(snapshot({ capture_sequence: '8' })) + } + const result = await accountWith(node).refreshWalletSnapshot() + expect(node.walletSnapshot).toHaveBeenCalledTimes(2) + expect(result.snapshot.capture_sequence).toBe('8') + }) + + it('fails closed when both capture attempts cross a chain tip', async () => { + const node = { + syncWallet: jest.fn(() => syncResult()), + walletSnapshot: jest.fn() + .mockReturnValueOnce(snapshot({ + capture_sequence: '7', + network_after: { network: 'regtest', height: 101 } + })) + .mockReturnValueOnce(snapshot({ + capture_sequence: '8', + network_after: { network: 'regtest', height: 101 } + })) + } + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + expect(error).toBeInstanceOf(WalletSnapshotError) + expect(error.code).toBe('WALLET_SNAPSHOT_INCOHERENT') + }) + + it('fails closed when an incoherent retry does not advance its capture sequence', async () => { + const node = { + syncWallet: jest.fn(() => syncResult()), + walletSnapshot: jest.fn(() => snapshot({ + capture_sequence: '7', + network_after: { network: 'regtest', height: 101 } + })) + } + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + + expect(error).toBeInstanceOf(WalletSnapshotError) + expect(error.code).toBe('WALLET_SNAPSHOT_CONTRACT_MISMATCH') + expect(error.details).toEqual({ + firstCaptureSequence: '7', + retryCaptureSequence: '7' + }) + }) + + it.each([ + [new Error('native sync failed'), 'WALLET_SYNC_NATIVE_FAILURE'], + [syncResult({ contract_version: 2 }), 'WALLET_SYNC_CONTRACT_MISMATCH'] + ])('classifies native and contract sync failures', async (outcome, code) => { + const node = { + syncWallet: jest.fn(() => { + if (outcome instanceof Error) throw outcome + return outcome + }), + walletSnapshot: jest.fn() + } + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + + expect(error).toBeInstanceOf(WalletSyncError) + expect(error.code).toBe(code) + }) + + it.each([ + [new Error('native snapshot failed'), 'WALLET_SNAPSHOT_NATIVE_FAILURE'], + [snapshot({ contract_version: 2 }), 'WALLET_SNAPSHOT_CONTRACT_MISMATCH'], + [new WalletSnapshotError('native typed failure', { code: 'NATIVE_TYPED_FAILURE' }), 'NATIVE_TYPED_FAILURE'] + ])('classifies native, contract, and typed snapshot failures', async (outcome, code) => { + const node = { + syncWallet: jest.fn(() => syncResult()), + walletSnapshot: jest.fn(() => { + if (outcome instanceof Error) throw outcome + return outcome + }) + } + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + + expect(error).toBeInstanceOf(WalletSnapshotError) + expect(error.code).toBe(code) + }) + + it('coalesces identical refreshes and serializes different modes', async () => { + let releaseRoutine + const routine = new Promise((resolve) => { releaseRoutine = resolve }) + const node = { + syncWallet: jest.fn(({ mode }) => mode === 'routine' + ? routine + : syncResult({ mode: 'recovery' })), + walletSnapshot: jest.fn(() => snapshot()) + } + const account = accountWith(node) + const first = account.refreshWalletSnapshot() + const duplicate = account.refreshWalletSnapshot() + const recovery = account.refreshWalletSnapshot({ mode: 'recovery' }) + + expect(duplicate).toBe(first) + await Promise.resolve() + expect(node.syncWallet).toHaveBeenCalledTimes(1) + releaseRoutine(syncResult()) + await first + await recovery + expect(node.syncWallet.mock.calls.map(([request]) => request.mode)) + .toEqual(['routine', 'recovery']) + }) + + it('keeps the serialized refresh queue usable after a failed request', async () => { + const node = { + syncWallet: jest.fn() + .mockReturnValueOnce(syncResult({ contract_version: 2 })) + .mockReturnValueOnce(syncResult({ mode: 'recovery' })), + walletSnapshot: jest.fn(() => snapshot()) + } + const account = accountWith(node) + const failed = account.refreshWalletSnapshot() + const recovery = account.refreshWalletSnapshot({ mode: 'recovery' }) + + await expect(failed).rejects.toMatchObject({ code: 'WALLET_SYNC_CONTRACT_MISMATCH' }) + await expect(recovery).resolves.toMatchObject({ + sync: { mode: 'recovery' }, + snapshot: { capture_sequence: '1' } + }) + }) + + it('reports old native packages as an explicit compatibility error', async () => { + const error = await accountWith({ sync: jest.fn() }) + .refreshWalletSnapshot() + .catch((reason) => reason) + expect(error).toBeInstanceOf(WalletSnapshotError) + expect(error.code).toBe('WALLET_SNAPSHOT_UNSUPPORTED_BINDING') + }) +}) From 7afaecce6e86e16ae130320b14ceb877ce609154 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 21 Jul 2026 19:35:46 +0530 Subject: [PATCH 02/34] Harden wallet snapshot coherence --- index.d.ts | 2 +- src/wallet-account-rgb-lightning.js | 80 +++++++++++++++----------- src/wallet-snapshot-contract.js | 8 ++- tests/wallet-snapshot-contract.test.js | 53 +++++++++++++++++ 4 files changed, 106 insertions(+), 37 deletions(-) diff --git a/index.d.ts b/index.d.ts index 7b23c13..1dbff95 100644 --- a/index.d.ts +++ b/index.d.ts @@ -47,7 +47,7 @@ export interface WalletSnapshotOptions { } export interface WalletSnapshotNetwork { - network: string + network: Network height: number } diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index e177fa2..78bcdbb 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -419,41 +419,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL ) } - let sync - try { - sync = validateWalletSyncResponse( - await node.syncWallet({ mode: options.mode }), - options.mode - ) - } catch (error) { - const contractFailure = error instanceof WalletSnapshotContractError - throw new WalletSyncError( - contractFailure - ? 'The native wallet sync response does not match contract v1.' - : 'The native wallet synchronization failed.', - { - code: contractFailure - ? 'WALLET_SYNC_CONTRACT_MISMATCH' - : 'WALLET_SYNC_NATIVE_FAILURE', - cause: error, - details: Object.freeze({ mode: options.mode }) - } - ) - } - - if (sync.vanilla.status !== 'succeeded' || sync.colored.status !== 'succeeded') { - throw new WalletSyncError( - 'The native wallet synchronization did not complete for both keychains.', - { - code: 'WALLET_SYNC_PARTIAL_FAILURE', - details: Object.freeze({ - mode: options.mode, - vanilla: sync.vanilla, - colored: sync.colored - }) - } - ) - } + let sync = await this._synchronizeWalletForSnapshot(node, options.mode) const first = await this._captureWalletSnapshot(node, options) if (isCoherentWalletSnapshot(first)) { @@ -464,6 +430,9 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL }) } + // A moving chain tip can leave the first wallet sync behind the retry + // capture. Synchronize both keychains again before accepting new-tip data. + sync = await this._synchronizeWalletForSnapshot(node, options.mode) const retry = await this._captureWalletSnapshot(node, options) if (BigInt(retry.capture_sequence) <= BigInt(first.capture_sequence)) { throw new WalletSnapshotError( @@ -505,6 +474,47 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL }) } + /** @private */ + async _synchronizeWalletForSnapshot (node, mode) { + let sync + try { + sync = validateWalletSyncResponse( + await node.syncWallet({ mode }), + mode + ) + } catch (error) { + const contractFailure = error instanceof WalletSnapshotContractError + throw new WalletSyncError( + contractFailure + ? 'The native wallet sync response does not match contract v1.' + : 'The native wallet synchronization failed.', + { + code: contractFailure + ? 'WALLET_SYNC_CONTRACT_MISMATCH' + : 'WALLET_SYNC_NATIVE_FAILURE', + cause: error, + details: Object.freeze({ mode }) + } + ) + } + + if (sync.vanilla.status !== 'succeeded' || sync.colored.status !== 'succeeded') { + throw new WalletSyncError( + 'The native wallet synchronization did not complete for both keychains.', + { + code: 'WALLET_SYNC_PARTIAL_FAILURE', + details: Object.freeze({ + mode, + vanilla: sync.vanilla, + colored: sync.colored + }) + } + ) + } + + return sync + } + /** @private */ async _captureWalletSnapshot (node, options) { try { diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js index d142c77..d96d7bb 100644 --- a/src/wallet-snapshot-contract.js +++ b/src/wallet-snapshot-contract.js @@ -23,6 +23,7 @@ export const DEFAULT_WALLET_SNAPSHOT_OPTIONS = Object.freeze({ }) const DECIMAL_TEXT = /^(0|[1-9][0-9]*)$/ +const U64_MAX = 18_446_744_073_709_551_615n const HAS_OWN = (value, key) => Object.prototype.hasOwnProperty.call(value, key) export class WalletSnapshotContractError extends Error { @@ -70,6 +71,7 @@ function decimal (value, path) { if (typeof value !== 'string' || !DECIMAL_TEXT.test(value)) { fail(path, 'must be an unsigned base-10 integer string') } + if (BigInt(value) > U64_MAX) fail(path, 'must fit in an unsigned 64-bit integer') return value } @@ -203,7 +205,7 @@ export function validateWalletSyncResponse (value, expectedMode) { function network (value, path) { const item = record(value, path) exactKeys(item, ['network', 'height'], [], path) - text(item.network, `${path}.network`, 32) + oneOf(item.network, ['mainnet', 'testnet', 'regtest', 'signet'], `${path}.network`) integer(item.height, `${path}.height`, 0, 0xffffffff) } @@ -412,6 +414,10 @@ export function validateWalletSnapshotResponse (value, options) { const transfers = array(snapshot.transfers, 'snapshot.transfers', options.maxAssets) transfers.forEach((entry, index) => snapshotTransfers(entry, `snapshot.transfers[${index}]`, options)) assertUnique(transfers, 'asset_id', 'snapshot.transfers') + const transferCount = transfers.reduce((count, entry) => count + entry.transfers.length, 0) + if (transferCount > options.maxActivityItems) { + fail('snapshot.transfers', `must contain at most ${options.maxActivityItems} aggregate entries`) + } } else { for (const field of optional) { if (HAS_OWN(snapshot, field)) fail(`snapshot.${field}`, 'must be omitted when includeActivity is false') diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index 80e54e6..cdeaa58 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -193,6 +193,23 @@ describe('wallet snapshot response contract', () => { .toThrow('snapshot.extra') }) + it('rejects decimal text outside the native u64 domain and unknown networks', () => { + const options = normalizeWalletSnapshotOptions() + expect(() => validateWalletSnapshotResponse(snapshot({ + btc: { + vanilla: { + settled: '18446744073709551616', + future: '45', + spendable: '40' + }, + colored: { settled: '5', future: '5', spendable: '5' } + } + }), options)).toThrow('snapshot.btc.vanilla.settled') + expect(() => validateWalletSnapshotResponse(snapshot({ + network_before: { network: 'bitcoin', height: 100 } + }), options)).toThrow('snapshot.network_before.network') + }) + it('requires the bounded activity envelope only when requested', () => { const options = normalizeWalletSnapshotOptions({ includeActivity: true, @@ -261,6 +278,22 @@ describe('wallet snapshot response contract', () => { .toThrow('snapshot.transfers[0].asset_id') }) + it('bounds RGB transfers across every requested asset', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1', 'asset-2'], + maxActivityItems: 1 + }) + const value = activitySnapshot() + value.transfers.push({ + asset_id: 'asset-2', + transfers: [{ ...value.transfers[0].transfers[0], idx: 2 }] + }) + + expect(() => validateWalletSnapshotResponse(value, options)) + .toThrow('snapshot.transfers') + }) + it('requires activity fields to be absent when activity was not requested', () => { const options = normalizeWalletSnapshotOptions() expect(() => validateWalletSnapshotResponse(snapshot({ transactions: [] }), options)) @@ -350,10 +383,30 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { .mockReturnValueOnce(snapshot({ capture_sequence: '8' })) } const result = await accountWith(node).refreshWalletSnapshot() + expect(node.syncWallet).toHaveBeenCalledTimes(2) expect(node.walletSnapshot).toHaveBeenCalledTimes(2) expect(result.snapshot.capture_sequence).toBe('8') }) + it('fails before the retry capture when re-synchronization fails', async () => { + const node = { + syncWallet: jest.fn() + .mockReturnValueOnce(syncResult()) + .mockReturnValueOnce(syncResult({ + vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' } + })), + walletSnapshot: jest.fn(() => snapshot({ + capture_sequence: '7', + network_after: { network: 'regtest', height: 101 } + })) + } + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + + expect(error).toBeInstanceOf(WalletSyncError) + expect(error.code).toBe('WALLET_SYNC_PARTIAL_FAILURE') + expect(node.walletSnapshot).toHaveBeenCalledTimes(1) + }) + it('fails closed when both capture attempts cross a chain tip', async () => { const node = { syncWallet: jest.fn(() => syncResult()), From 5ce1f6cedf91cbfb5dfa127886165ee583ac9f12 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 22 Jul 2026 12:47:30 +0530 Subject: [PATCH 03/34] fix: activate RGB account before address discovery --- README.md | 5 +- index.d.ts | 26 +++++++- src/node-unlock-request.js | 60 +++++++++++++++++++ src/wallet-account-rgb-lightning.js | 86 +++++++++++++++++++++++---- src/wallet-manager-rgb-lightning.js | 11 +++- tests/wallet-account-surface.test.js | 89 +++++++++++++++++++++++++++- tests/wallet-manager.test.js | 34 +++++++++++ 7 files changed, 291 insertions(+), 20 deletions(-) create mode 100644 src/node-unlock-request.js diff --git a/README.md b/README.md index f4df490..a5431a2 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,7 @@ and a regtest stack via Docker Compose — lives in | `virtualPeerPubkeys` | — | Trust list of peer node_ids allowed to open `trusted_no_broadcast` virtual channels (the LSP's node_id for APay). | | `permissiveSignerPolicy` | `true` | Loosen the VLS policy filter for in-process single-user use. | | `nodeSeedDerivation` | `auto` | New nodes use WDK's normalized BIP-39 seed directly; existing beta nodes retry the legacy identity only on an exact persisted-identity mismatch. Use `wdk-seed-v2` or `legacy-v1` to disable auto-detection. | +| `autoUnlockRequest` | — | Optional typed node request for integrations that load `getAddress()` before exposing account extensions, including WDK React Native Core. Concurrent activation is coalesced and only the real native address is returned. Omit it for explicit/manual unlock. | | `vssUrl` / `vssAllowHttp` / `vssAllowEmptyRestore` | — | VSS cloud backup; see [below](#vss-cloud-backup). | | `lspBaseUrl` / `lspBearerToken` | — | LSP wiring for APay and the LSP client; see [below](#lsp-integration). | @@ -216,7 +217,9 @@ Notes: it rejects with `AccountLockedError`; UI loaders can call `getAddressState()` for `{ status: 'locked', address: null }`. The WDK bindings initialize RLN with address reuse enabled so reads stay stable; - `rotateAddress()` is the explicit mutating operation for advancing it. + `rotateAddress()` is the explicit mutating operation for advancing it. A full + account configured with `autoUnlockRequest` first coalesces native activation + and then retries the real address; the read-only account contract is unchanged. - **`sendTransaction()` uses WDK's `{ to, value, feeRate?, confirmationTarget? }` input and `{ hash, fee }` result.** `sendBtc()` is the explicit low-level escape hatch for RLN's native request format. diff --git a/index.d.ts b/index.d.ts index 1dbff95..10d21ce 100644 --- a/index.d.ts +++ b/index.d.ts @@ -388,6 +388,12 @@ export interface RgbLightningWalletConfig extends RgbLightningBindingConfig { * the legacy beta derivation only for an existing signer-identity mismatch. */ nodeSeedDerivation?: 'auto' | 'wdk-seed-v2' | 'legacy-v1' + /** + * Optional node request used to activate the full account when an integration + * (including WDK React Native Core) loads its address before exposing account + * extension methods. The account returns only the real native address. + */ + autoUnlockRequest?: RgbLightningNodeUnlockRequest bitcoindRpcUsername?: string bitcoindRpcPassword?: string bitcoindRpcHost?: string @@ -398,6 +404,17 @@ export interface RgbLightningWalletConfig extends RgbLightningBindingConfig { announceAlias?: string } +export interface RgbLightningNodeUnlockRequest { + bitcoind_rpc_username: string + bitcoind_rpc_password: string + bitcoind_rpc_host: string + bitcoind_rpc_port: number + indexer_url: string + proxy_endpoint: string + announce_addresses: string[] + announce_alias: string +} + // ─────────────────────────────────────────────────────────────────── // Bindings (low-level; usually not constructed directly) // ─────────────────────────────────────────────────────────────────── @@ -405,7 +422,7 @@ export interface RgbLightningWalletConfig extends RgbLightningBindingConfig { export interface IRgbLightningBinding { ensureNode(): unknown attachExternalSigner(seedHex: string, fallbackSeedHex?: string): void - unlock(unlockRequest: object): void + unlock(unlockRequest: RgbLightningNodeUnlockRequest): void bootstrap(): object clearVssFence(password: string): void vssBackup(): { version: number } @@ -574,14 +591,17 @@ export class WalletAccountReadOnlyRgbLightning extends WalletAccountReadOnly { } export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning { - constructor(bindings: { binding: IRgbLightningBinding }) + constructor(bindings: { + binding: IRgbLightningBinding + autoUnlockRequest?: RgbLightningNodeUnlockRequest + }) readonly index: 0 readonly path: 'm' readonly keyPair: KeyPair // Lifecycle - unlock(unlockRequest: object): Promise<{ ok: true }> + unlock(unlockRequest: RgbLightningNodeUnlockRequest): Promise<{ ok: true }> getBootstrap(): Promise shutdown(): Promise<{ ok: true }> diff --git a/src/node-unlock-request.js b/src/node-unlock-request.js new file mode 100644 index 0000000..8b42f5a --- /dev/null +++ b/src/node-unlock-request.js @@ -0,0 +1,60 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +'use strict' + +const REQUIRED_STRING_FIELDS = Object.freeze([ + 'bitcoind_rpc_username', + 'bitcoind_rpc_password', + 'bitcoind_rpc_host', + 'indexer_url', + 'proxy_endpoint', + 'announce_alias' +]) + +/** + * Validate and defensively copy the request retained for automatic account + * activation. Error messages identify fields without echoing credential data. + * + * @param {unknown} value + * @returns {object | undefined} + */ +export function normalizeAutoUnlockRequest (value) { + if (value === undefined) return undefined + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('autoUnlockRequest must be an object') + } + + for (const field of REQUIRED_STRING_FIELDS) { + if (typeof value[field] !== 'string' || value[field].length === 0) { + throw new TypeError(`autoUnlockRequest.${field} must be a non-empty string`) + } + } + + if ( + !Number.isInteger(value.bitcoind_rpc_port) || + value.bitcoind_rpc_port < 1 || + value.bitcoind_rpc_port > 65_535 + ) { + throw new TypeError('autoUnlockRequest.bitcoind_rpc_port must be a valid TCP port') + } + + if ( + !Array.isArray(value.announce_addresses) || + !value.announce_addresses.every((address) => typeof address === 'string' && address.length > 0) + ) { + throw new TypeError('autoUnlockRequest.announce_addresses must be an array of non-empty strings') + } + + return Object.freeze({ + bitcoind_rpc_username: value.bitcoind_rpc_username, + bitcoind_rpc_password: value.bitcoind_rpc_password, + bitcoind_rpc_host: value.bitcoind_rpc_host, + bitcoind_rpc_port: value.bitcoind_rpc_port, + indexer_url: value.indexer_url, + proxy_endpoint: value.proxy_endpoint, + announce_addresses: Object.freeze([...value.announce_addresses]), + announce_alias: value.announce_alias + }) +} diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 78bcdbb..31f85c9 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -19,11 +19,13 @@ import { } from './lsp-helpers.js' import { LspClient } from './lsp-client.js' import { UtexoLsp } from './utexo-lsp.js' +import { normalizeAutoUnlockRequest } from './node-unlock-request.js' import WalletAccountReadOnlyRgbLightning, { createReadOnlyRgbLightningAdapter, PENDING_ADDRESS } from './wallet-account-read-only-rgb-lightning.js' import { + AccountLockedError, UnlockError, VssError, VssNotConfiguredError, @@ -45,6 +47,28 @@ import { export { PENDING_ADDRESS } +function sameUnlockRequest (left, right) { + if (left === right) return true + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false + + const leftKeys = Object.keys(left).sort() + const rightKeys = Object.keys(right).sort() + if (leftKeys.length !== rightKeys.length) return false + + return leftKeys.every((key, index) => { + if (key !== rightKeys[index]) return false + const leftValue = left[key] + const rightValue = right[key] + if (Array.isArray(leftValue) || Array.isArray(rightValue)) { + return Array.isArray(leftValue) && + Array.isArray(rightValue) && + leftValue.length === rightValue.length && + leftValue.every((value, valueIndex) => value === rightValue[valueIndex]) + } + return leftValue === rightValue + }) +} + /** * Seed-isolated via RLN's `NativeExternalSigner`: the WDK secret manager * owns the BIP-39 mnemonic; the manager derives a 32-byte VLS node @@ -57,7 +81,7 @@ export { PENDING_ADDRESS } */ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning { /** - * @param {{ binding: BareRgbLightningBinding }} bindings + * @param {{ binding: BareRgbLightningBinding, autoUnlockRequest?: object }} bindings */ constructor (bindings) { if (!bindings || !bindings.binding) { @@ -65,6 +89,9 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } super(createReadOnlyRgbLightningAdapter(bindings.binding)) /** @private */ this._binding = bindings.binding + /** @private */ this._autoUnlockRequest = normalizeAutoUnlockRequest(bindings.autoUnlockRequest) + /** @private @type {{ request: object, promise: Promise<{ ok: true }> } | null} */ + this._unlockInFlight = null /** @private @type {WalletAccountReadOnlyRgbLightning | null} */ this._readOnlyAccount = null /** @private @type {Promise} */ @@ -90,19 +117,54 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL * @param {Object} unlockRequest */ async unlock (unlockRequest) { + if (this._unlockInFlight) { + if (!sameUnlockRequest(this._unlockInFlight.request, unlockRequest)) { + throw new UnlockError('A different RGB Lightning unlock request is already in progress.') + } + return this._unlockInFlight.promise + } + + const operation = Promise.resolve().then(() => { + try { + this._binding.unlock(unlockRequest) + } catch (e) { + // Wrap into a typed UnlockError so callers can branch on + // `err.name === 'UnlockError'` / `err.code` instead of + // substring-matching the RLN message. The original message is + // preserved verbatim and attached as `cause`. + throw wrapError(e, UnlockError) + } + // Return something non-undefined so the worklet's `safeStringify` + // produces a real string. The RN-side response schema rejects + // null/undefined results (see wdk-react-native-core schemas). + return { ok: true } + }) + + this._unlockInFlight = { request: unlockRequest, promise: operation } try { - this._binding.unlock(unlockRequest) - } catch (e) { - // Wrap into a typed UnlockError so callers can branch on - // `err.name === 'UnlockError'` / `err.code` instead of - // substring-matching the RLN message. The original message is - // preserved verbatim and attached as `cause`. - throw wrapError(e, UnlockError) + return await operation + } finally { + if (this._unlockInFlight?.promise === operation) this._unlockInFlight = null } - // Return something non-undefined so the worklet's `safeStringify` - // produces a real string. The RN-side response schema rejects - // null/undefined results (see wdk-react-native-core schemas). - return { ok: true } + } + + /** + * WDK React Native Core discovers an account by loading its address before + * exposing extension methods. When explicitly configured, activate the full + * RGB node at that boundary and then return only the real native address. + * Standalone and read-only consumers retain the ordinary locked error. + */ + async getAddress () { + try { + return await super.getAddress() + } catch (error) { + if (!(error instanceof AccountLockedError) || !this._autoUnlockRequest) { + throw error + } + } + + await this.unlock(this._autoUnlockRequest) + return super.getAddress() } /** Idempotent shutdown. */ diff --git a/src/wallet-manager-rgb-lightning.js b/src/wallet-manager-rgb-lightning.js index 5ac8b38..797c1db 100644 --- a/src/wallet-manager-rgb-lightning.js +++ b/src/wallet-manager-rgb-lightning.js @@ -6,6 +6,7 @@ import WalletManager from '@tetherto/wdk-wallet' import { mnemonicToSeedSync } from 'bip39' +import { normalizeAutoUnlockRequest } from './node-unlock-request.js' import WalletAccountRgbLightning from './wallet-account-rgb-lightning.js' const MEMPOOL_SPACE_URL = 'https://mempool.space' @@ -24,6 +25,7 @@ const MEMPOOL_SPACE_URL = 'https://mempool.space' * proxyEndpoint?: string, * announceAddresses?: string[], * announceAlias?: string, + * autoUnlockRequest?: object, * nodeSeedDerivation?: 'auto'|'wdk-seed-v2'|'legacy-v1' * }} RgbLightningWalletConfig */ @@ -98,7 +100,8 @@ export default class WalletManagerRgbLightning extends WalletManager { * @param {RgbLightningWalletConfig} config */ constructor (seed, config = {}) { - super(seed, config) + const { autoUnlockRequest, ...managerConfig } = config + super(seed, managerConfig) if (!config.network) throw new Error('network configuration is required.') if (!config.dataDir) { @@ -109,6 +112,7 @@ export default class WalletManagerRgbLightning extends WalletManager { } /** @private */ this._network = config.network + /** @private */ this._autoUnlockRequest = normalizeAutoUnlockRequest(autoUnlockRequest) /** @private @type {IRgbLightningBinding | null} */ this._binding = null } @@ -174,7 +178,10 @@ export default class WalletManagerRgbLightning extends WalletManager { ) } - this._accounts[index] = new WalletAccountRgbLightning({ binding }) + this._accounts[index] = new WalletAccountRgbLightning({ + binding, + autoUnlockRequest: this._autoUnlockRequest + }) } return this._accounts[index] } diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 290bc94..b728c90 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -24,6 +24,17 @@ import { UnlockError, AccountLockedError, ApayError } from '../src/errors.js' import { LspClient } from '../src/lsp-client.js' import { UtexoLsp } from '../src/utexo-lsp.js' +const AUTO_UNLOCK_REQUEST = Object.freeze({ + bitcoind_rpc_username: 'user', + bitcoind_rpc_password: 'password', + bitcoind_rpc_host: '127.0.0.1', + bitcoind_rpc_port: 18443, + indexer_url: 'tcp://127.0.0.1:50001', + proxy_endpoint: 'rpc://127.0.0.1:3000/json-rpc', + announce_addresses: Object.freeze([]), + announce_alias: 'wallet-test' +}) + // Build a fake RLN node whose methods are jest.fn returning canned // values. Every method the account forwards to is present so we can // assert forwarding + arg pass-through. @@ -100,8 +111,11 @@ function makeBinding (overrides = {}) { return binding } -function makeAccount (bindingOverrides = {}) { - return new WalletAccountRgbLightning({ binding: makeBinding(bindingOverrides) }) +function makeAccount (bindingOverrides = {}, options = {}) { + return new WalletAccountRgbLightning({ + binding: makeBinding(bindingOverrides), + ...options + }) } describe('construction', () => { @@ -156,6 +170,39 @@ describe('lifecycle', () => { expect(err.code).toBe('UNLOCK_FAILED') }) + it('coalesces concurrent unlock calls at the account boundary', async () => { + const unlock = jest.fn() + const account = makeAccount({ unlock }) + + await expect(Promise.all([ + account.unlock(AUTO_UNLOCK_REQUEST), + account.unlock(AUTO_UNLOCK_REQUEST) + ])).resolves.toEqual([{ ok: true }, { ok: true }]) + + expect(unlock).toHaveBeenCalledTimes(1) + }) + + it('rejects a conflicting concurrent unlock request', async () => { + let release + const pending = new Promise((resolve) => { release = resolve }) + const unlock = jest.fn(() => pending) + const account = makeAccount({ unlock }) + const first = account.unlock(AUTO_UNLOCK_REQUEST) + const conflicting = account.unlock({ + ...AUTO_UNLOCK_REQUEST, + bitcoind_rpc_host: 'other-host' + }) + + await expect(conflicting).rejects.toMatchObject({ + name: 'UnlockError', + code: 'UNLOCK_FAILED', + message: 'A different RGB Lightning unlock request is already in progress.' + }) + release() + await expect(first).resolves.toEqual({ ok: true }) + expect(unlock).toHaveBeenCalledTimes(1) + }) + it('getBootstrap returns the binding bootstrap dictionary verbatim', async () => { const boot = { node_id: 'aa'.repeat(33), @@ -260,6 +307,44 @@ describe('getAddress', () => { await expect(account.getAddress()).rejects.toBeInstanceOf(AccountLockedError) }) + it('coalesces configured activation and returns only the real native address', async () => { + let unlocked = false + const address = jest.fn(() => { + if (!unlocked) throw new Error('SdkNode not created — call unlock() first') + return { address: 'tb1qactivated' } + }) + const unlock = jest.fn(() => { unlocked = true }) + const account = makeAccount( + { node: makeNode({ address }), unlock }, + { autoUnlockRequest: AUTO_UNLOCK_REQUEST } + ) + + await expect(Promise.all([ + account.getAddress(), + account.getAddress() + ])).resolves.toEqual(['tb1qactivated', 'tb1qactivated']) + + expect(unlock).toHaveBeenCalledTimes(1) + expect(unlock).toHaveBeenCalledWith(AUTO_UNLOCK_REQUEST) + expect(address).toHaveBeenCalledTimes(4) + }) + + it('does not hide an automatic activation failure behind an address marker', async () => { + const account = makeAccount( + { + node: makeNode({ address: () => { throw new Error('LockedNode') } }), + unlock: () => { throw new Error('indexer unavailable') } + }, + { autoUnlockRequest: AUTO_UNLOCK_REQUEST } + ) + + await expect(account.getAddress()).rejects.toMatchObject({ + name: 'UnlockError', + code: 'UNLOCK_FAILED', + message: 'indexer unavailable' + }) + }) + it('returns a non-throwing locked state for pre-unlock UI loaders', async () => { const account = makeAccount({ node: makeNode({ address: () => { throw new Error('LockedNode') } }) }) await expect(account.getAddressState()).resolves.toEqual({ status: 'locked', address: null }) diff --git a/tests/wallet-manager.test.js b/tests/wallet-manager.test.js index d5f1a97..a01fd67 100644 --- a/tests/wallet-manager.test.js +++ b/tests/wallet-manager.test.js @@ -14,6 +14,16 @@ const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abando const WDK_SEED_HEX = '5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4' const NODE_SEED_V2 = WDK_SEED_HEX.slice(0, 64) const NODE_SEED_V1 = 'd6560f02547828d8d76fc84ea68e74dcccea5599e735cee1fa5f2742289cda58' +const AUTO_UNLOCK_REQUEST = { + bitcoind_rpc_username: 'user', + bitcoind_rpc_password: 'password', + bitcoind_rpc_host: '127.0.0.1', + bitcoind_rpc_port: 18443, + indexer_url: 'tcp://127.0.0.1:50001', + proxy_endpoint: 'rpc://127.0.0.1:3000/json-rpc', + announce_addresses: [], + announce_alias: 'wallet-test' +} class FakeBinding { static instances = [] @@ -70,6 +80,30 @@ describe('WalletManagerRgbLightning', () => { expect(FakeBinding.instances).toHaveLength(1) }) + it('validates and isolates the optional automatic activation request', async () => { + const manager = new TestManager(MNEMONIC, { + network: 'regtest', + dataDir: '/wallet', + autoUnlockRequest: AUTO_UNLOCK_REQUEST + }) + const account = await manager.getAccount() + const binding = FakeBinding.instances[0] + + expect(account._autoUnlockRequest).toEqual(AUTO_UNLOCK_REQUEST) + expect(account._autoUnlockRequest).not.toBe(AUTO_UNLOCK_REQUEST) + expect(Object.isFrozen(account._autoUnlockRequest)).toBe(true) + expect(Object.isFrozen(account._autoUnlockRequest.announce_addresses)).toBe(true) + expect(binding._config.autoUnlockRequest).toBeUndefined() + }) + + it('rejects malformed automatic activation requests without exposing values', () => { + expect(() => new TestManager(MNEMONIC, { + network: 'regtest', + dataDir: '/wallet', + autoUnlockRequest: { ...AUTO_UNLOCK_REQUEST, bitcoind_rpc_port: 0 } + })).toThrow('autoUnlockRequest.bitcoind_rpc_port must be a valid TCP port') + }) + it('supports explicit v2-only and legacy-only modes', async () => { const v2 = new TestManager(MNEMONIC, { network: 'regtest', From c5441b2185448d50490c904e9bcba0d4dcd8fbfb Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 22 Jul 2026 12:56:53 +0530 Subject: [PATCH 04/34] fix: expose safe snapshot contract diagnostics --- src/wallet-account-rgb-lightning.js | 19 +++++++++++++++---- src/wallet-snapshot-contract.js | 1 + tests/wallet-snapshot-contract.test.js | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 31f85c9..d14fd76 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -548,14 +548,19 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL const contractFailure = error instanceof WalletSnapshotContractError throw new WalletSyncError( contractFailure - ? 'The native wallet sync response does not match contract v1.' + ? `The native wallet sync response does not match contract v1: ${error.message}.` : 'The native wallet synchronization failed.', { code: contractFailure ? 'WALLET_SYNC_CONTRACT_MISMATCH' : 'WALLET_SYNC_NATIVE_FAILURE', cause: error, - details: Object.freeze({ mode }) + details: Object.freeze({ + mode, + ...(contractFailure + ? { contractPath: error.path, contractExpectation: error.expectation } + : {}) + }) } ) } @@ -589,13 +594,19 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL const contractFailure = error instanceof WalletSnapshotContractError throw new WalletSnapshotError( contractFailure - ? 'The native wallet snapshot does not match contract v1.' + ? `The native wallet snapshot does not match contract v1: ${error.message}.` : 'The native wallet snapshot could not be captured.', { code: contractFailure ? 'WALLET_SNAPSHOT_CONTRACT_MISMATCH' : 'WALLET_SNAPSHOT_NATIVE_FAILURE', - cause: error + cause: error, + details: contractFailure + ? Object.freeze({ + contractPath: error.path, + contractExpectation: error.expectation + }) + : undefined } ) } diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js index d96d7bb..4fdefe9 100644 --- a/src/wallet-snapshot-contract.js +++ b/src/wallet-snapshot-contract.js @@ -31,6 +31,7 @@ export class WalletSnapshotContractError extends Error { super(`${path} ${expectation}`) this.name = 'WalletSnapshotContractError' this.path = path + this.expectation = expectation } } diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index cdeaa58..3a823a0 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -458,6 +458,14 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { expect(error).toBeInstanceOf(WalletSyncError) expect(error.code).toBe(code) + if (code === 'WALLET_SYNC_CONTRACT_MISMATCH') { + expect(error.message).toContain('sync.contract_version must equal 1') + expect(error.details).toEqual({ + mode: 'routine', + contractPath: 'sync.contract_version', + contractExpectation: 'must equal 1' + }) + } }) it.each([ @@ -476,6 +484,13 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { expect(error).toBeInstanceOf(WalletSnapshotError) expect(error.code).toBe(code) + if (code === 'WALLET_SNAPSHOT_CONTRACT_MISMATCH') { + expect(error.message).toContain('snapshot.contract_version must equal 1') + expect(error.details).toEqual({ + contractPath: 'snapshot.contract_version', + contractExpectation: 'must equal 1' + }) + } }) it('coalesces identical refreshes and serializes different modes', async () => { From fcbbfa1f6a748a7e11c528cc99df0cb66e62b6a3 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 22 Jul 2026 13:42:53 +0530 Subject: [PATCH 05/34] fix: normalize legacy snapshot network casing --- CHANGELOG.md | 4 ++++ src/wallet-snapshot-contract.js | 31 ++++++++++++++++++++++++-- tests/wallet-snapshot-contract.test.js | 23 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 722ab41..4d0de2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ while pre-`1.0`. identity mismatch, preserving existing node identities. ### Fixed +- Wallet snapshot validation now canonicalizes only recognized legacy native + network casing (for example, `Regtest` to `regtest`) before enforcing the + strict v1 contract. This keeps source-PR installs compatible with published + beta.14 native prebuilds while unknown network names still fail closed. - WDK conformance for `index`, `path`, `keyPair`, `sign()`, `getBalance()`, `getTokenBalance()`, `sendTransaction()`, quotes, and confirmed receipt semantics. Balance failures are no longer silently converted to zero unless diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js index 4fdefe9..9e57f7a 100644 --- a/src/wallet-snapshot-contract.js +++ b/src/wallet-snapshot-contract.js @@ -25,6 +25,7 @@ export const DEFAULT_WALLET_SNAPSHOT_OPTIONS = Object.freeze({ const DECIMAL_TEXT = /^(0|[1-9][0-9]*)$/ const U64_MAX = 18_446_744_073_709_551_615n const HAS_OWN = (value, key) => Object.prototype.hasOwnProperty.call(value, key) +const CANONICAL_NETWORKS = Object.freeze(['mainnet', 'testnet', 'regtest', 'signet']) export class WalletSnapshotContractError extends Error { constructor (path, expectation) { @@ -206,10 +207,33 @@ export function validateWalletSyncResponse (value, expectedMode) { function network (value, path) { const item = record(value, path) exactKeys(item, ['network', 'height'], [], path) - oneOf(item.network, ['mainnet', 'testnet', 'regtest', 'signet'], `${path}.network`) + oneOf(item.network, CANONICAL_NETWORKS, `${path}.network`) integer(item.height, `${path}.height`, 0, 0xffffffff) } +function canonicalNetworkName (value) { + if (typeof value !== 'string') return value + const canonical = value.toLowerCase() + return CANONICAL_NETWORKS.includes(canonical) ? canonical : value +} + +function normalizeLegacyNetworkNames (value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + + let normalized = value + for (const field of ['network_before', 'network_after']) { + const networkInfo = value[field] + if (!networkInfo || typeof networkInfo !== 'object' || Array.isArray(networkInfo)) continue + + const networkName = canonicalNetworkName(networkInfo.network) + if (networkName === networkInfo.network) continue + + if (normalized === value) normalized = { ...value } + normalized[field] = { ...networkInfo, network: networkName } + } + return normalized +} + function balance (value, path, includeOffchain) { const item = record(value, path) const fields = includeOffchain @@ -366,7 +390,10 @@ function assertUnique (rows, key, path) { } export function validateWalletSnapshotResponse (value, options) { - const snapshot = record(value, 'snapshot') + // Native beta.14 artifacts serialize Rust's Network Debug value (for + // example, "Regtest"). Normalize only recognized names at this boundary; + // strict contract validation below still rejects every unknown value. + const snapshot = record(normalizeLegacyNetworkNames(value), 'snapshot') const required = [ 'contract_version', 'native_source', 'capture_sequence', 'started_at_ms', 'completed_at_ms', 'network_before', 'network_after', 'node', 'btc', diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index 3a823a0..20070fa 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -210,6 +210,29 @@ describe('wallet snapshot response contract', () => { }), options)).toThrow('snapshot.network_before.network') }) + it('canonicalizes recognized legacy native network casing without mutating input', () => { + const options = normalizeWalletSnapshotOptions() + const value = snapshot({ + network_before: { network: 'Regtest', height: 100 }, + network_after: { network: 'REGTEST', height: 100 } + }) + + const result = validateWalletSnapshotResponse(value, options) + + expect(result.network_before.network).toBe('regtest') + expect(result.network_after.network).toBe('regtest') + expect(value.network_before.network).toBe('Regtest') + expect(value.network_after.network).toBe('REGTEST') + }) + + it('does not reinterpret an unknown mixed-case network', () => { + const options = normalizeWalletSnapshotOptions() + + expect(() => validateWalletSnapshotResponse(snapshot({ + network_before: { network: 'Bitcoin', height: 100 } + }), options)).toThrow('snapshot.network_before.network') + }) + it('requires the bounded activity envelope only when requested', () => { const options = normalizeWalletSnapshotOptions({ includeActivity: true, From 7e94f44ca263b3a2e0747e89c6f7368d305c76f0 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Mon, 27 Jul 2026 15:48:06 +0530 Subject: [PATCH 06/34] fix: preserve signer through manager disposal --- src/wallet-manager-rgb-lightning.js | 29 ++++++++++++++++++++++++--- tests/wallet-manager.test.js | 31 +++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/wallet-manager-rgb-lightning.js b/src/wallet-manager-rgb-lightning.js index 797c1db..8549d80 100644 --- a/src/wallet-manager-rgb-lightning.js +++ b/src/wallet-manager-rgb-lightning.js @@ -217,10 +217,33 @@ export default class WalletManagerRgbLightning extends WalletManager { } dispose () { - if (this._binding) { - this._binding.shutdown() + let accountDisposalError + let bindingShutdownError + + // The base manager inspects account.keyPair while clearing its account + // cache. RGB Lightning derives that public identity from the external + // signer, so the signer must remain attached until base disposal finishes. + try { + super.dispose() + } catch (error) { + accountDisposalError = error + } + + try { + this._binding?.shutdown() + } catch (error) { + bindingShutdownError = error + } finally { this._binding = null } - super.dispose() + + if (accountDisposalError && bindingShutdownError) { + throw new AggregateError( + [accountDisposalError, bindingShutdownError], + 'Failed to dispose RGB Lightning accounts and binding' + ) + } + if (accountDisposalError) throw accountDisposalError + if (bindingShutdownError) throw bindingShutdownError } } diff --git a/tests/wallet-manager.test.js b/tests/wallet-manager.test.js index a01fd67..2a8e464 100644 --- a/tests/wallet-manager.test.js +++ b/tests/wallet-manager.test.js @@ -30,9 +30,17 @@ class FakeBinding { constructor (config) { this._config = config + this._shutdown = false this.attachExternalSigner = jest.fn() - this.shutdown = jest.fn() - this.bootstrap = jest.fn(() => ({ node_id: '02' + '11'.repeat(32) })) + this.shutdown = jest.fn(() => { + this._shutdown = true + }) + this.bootstrap = jest.fn(() => { + if (this._shutdown) { + throw new Error('attachExternalSigner(seedHex) must be called before bootstrap()') + } + return { node_id: '02' + '11'.repeat(32) } + }) this.vssStatus = jest.fn(() => ({ configured: false, url: null, allowHttp: false, lastBackupVersion: null })) this.ensureNode = jest.fn(() => ({})) FakeBinding.instances.push(this) @@ -144,7 +152,26 @@ describe('WalletManagerRgbLightning', () => { const binding = FakeBinding.instances[0] expect(account.keyPair.privateKey).toBeNull() manager.dispose() + expect(binding.bootstrap).toHaveBeenCalledTimes(2) + expect(binding.shutdown).toHaveBeenCalledTimes(1) + expect(binding.bootstrap.mock.invocationCallOrder[1]) + .toBeLessThan(binding.shutdown.mock.invocationCallOrder[0]) + }) + + it('still destroys the signer when base account cleanup fails', async () => { + const manager = new TestManager(MNEMONIC, { network: 'regtest', dataDir: '/wallet' }) + const account = await manager.getAccount() + const binding = FakeBinding.instances[0] + Object.defineProperty(account, 'keyPair', { + configurable: true, + get: () => { + throw new Error('account cleanup failed') + } + }) + + expect(() => manager.dispose()).toThrow('account cleanup failed') expect(binding.shutdown).toHaveBeenCalledTimes(1) + expect(manager._binding).toBeNull() }) it('dispose is a no-op before an account has created a binding', () => { From c97f810baa116eb53d56ea510c665ab65f856dc8 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 28 Jul 2026 16:49:55 +0530 Subject: [PATCH 07/34] Recover stale VSS fence during opt-in unlock --- README.md | 5 ++ index.d.ts | 8 ++++ src/binding-interface.js | 4 ++ src/wallet-account-rgb-lightning.js | 40 +++++++++++++++- src/wallet-manager-rgb-lightning.js | 7 ++- tests/wallet-account-surface.test.js | 72 ++++++++++++++++++++++++++++ tests/wallet-manager.test.js | 5 +- 7 files changed, 137 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a5431a2..6150304 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,7 @@ and a regtest stack via Docker Compose — lives in | `permissiveSignerPolicy` | `true` | Loosen the VLS policy filter for in-process single-user use. | | `nodeSeedDerivation` | `auto` | New nodes use WDK's normalized BIP-39 seed directly; existing beta nodes retry the legacy identity only on an exact persisted-identity mismatch. Use `wdk-seed-v2` or `legacy-v1` to disable auto-detection. | | `autoUnlockRequest` | — | Optional typed node request for integrations that load `getAddress()` before exposing account extensions, including WDK React Native Core. Concurrent activation is coalesced and only the real native address is returned. Omit it for explicit/manual unlock. | +| `autoRecoverStaleVssFence` | `false` | Optional single-retry recovery for RLN's stale VSS `__rln_instance__` fence during unlock. Enable only in hosts that can guarantee no other live node is using the same VSS store. | | `vssUrl` / `vssAllowHttp` / `vssAllowEmptyRestore` | — | VSS cloud backup; see [below](#vss-cloud-backup). | | `lspBaseUrl` / `lspBearerToken` | — | LSP wiring for APay and the LSP client; see [below](#lsp-integration). | @@ -363,6 +364,10 @@ rejected for non-loopback hosts unless `vssAllowHttp: true`. ownership fence after a previous node died holding it (restarts otherwise fail with `Rln(VssFenceHeld)`). Only call this when certain the previous owner is gone — pointing two live nodes at one VSS store corrupts state. +- `autoRecoverStaleVssFence: true` — host-level opt-in that applies the same + clear-fence operation once during unlock, and only for the exact stale-owner + fence error. Keep it disabled for production until ownership/liveness policy + is implemented around the VSS service. VSS operations on a wallet constructed without `vssUrl` throw `VssNotConfiguredError`. diff --git a/index.d.ts b/index.d.ts index 10d21ce..6944be7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -394,6 +394,14 @@ export interface RgbLightningWalletConfig extends RgbLightningBindingConfig { * extension methods. The account returns only the real native address. */ autoUnlockRequest?: RgbLightningNodeUnlockRequest + /** + * Opt-in recovery for a stale VSS single-writer fence during automatic or + * explicit unlock. When enabled, the account clears the fence once and retries + * unlock only for RLN's stale-owner `__rln_instance__` failure. Keep disabled + * unless the host can guarantee another live node is not using the same VSS + * store. + */ + autoRecoverStaleVssFence?: boolean bitcoindRpcUsername?: string bitcoindRpcPassword?: string bitcoindRpcHost?: string diff --git a/src/binding-interface.js b/src/binding-interface.js index e442155..82b4d75 100644 --- a/src/binding-interface.js +++ b/src/binding-interface.js @@ -48,6 +48,10 @@ * @property {string} [lspBearerToken] - Bearer token sent to the LSP's * `/internal/*` endpoints. Omit when the LSP does not require authorization. * + * Wallet-manager-only policy fields such as `autoUnlockRequest` and + * `autoRecoverStaleVssFence` are intentionally not part of this native binding + * config. The manager consumes them before constructing the binding. + * * Concrete WDK bindings always send RLN `reuse_addresses: true`. This keeps * inherited read-only `getAddress()` calls pinned to the current address; * callers use the full account's explicit `rotateAddress()` command when diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index d14fd76..949ca80 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -69,6 +69,31 @@ function sameUnlockRequest (left, right) { }) } +const STALE_VSS_FENCE_PATTERN = /VSS store_id is owned by another rgb-lightning-node instance|__rln_instance__/i + +function isStaleVssFenceError (error) { + let current = error + for (let depth = 0; current && depth < 4; depth += 1) { + const message = current && typeof current === 'object' && 'message' in current + ? String(current.message) + : String(current) + if (STALE_VSS_FENCE_PATTERN.test(message)) return true + current = current && typeof current === 'object' && 'cause' in current + ? current.cause + : undefined + } + return false +} + +function vssFenceClearPassword (unlockRequest) { + return unlockRequest && + typeof unlockRequest === 'object' && + typeof unlockRequest.bitcoind_rpc_password === 'string' && + unlockRequest.bitcoind_rpc_password.length > 0 + ? unlockRequest.bitcoind_rpc_password + : undefined +} + /** * Seed-isolated via RLN's `NativeExternalSigner`: the WDK secret manager * owns the BIP-39 mnemonic; the manager derives a 32-byte VLS node @@ -81,7 +106,7 @@ function sameUnlockRequest (left, right) { */ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning { /** - * @param {{ binding: BareRgbLightningBinding, autoUnlockRequest?: object }} bindings + * @param {{ binding: BareRgbLightningBinding, autoUnlockRequest?: object, autoRecoverStaleVssFence?: boolean }} bindings */ constructor (bindings) { if (!bindings || !bindings.binding) { @@ -90,6 +115,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL super(createReadOnlyRgbLightningAdapter(bindings.binding)) /** @private */ this._binding = bindings.binding /** @private */ this._autoUnlockRequest = normalizeAutoUnlockRequest(bindings.autoUnlockRequest) + /** @private */ this._autoRecoverStaleVssFence = bindings.autoRecoverStaleVssFence === true /** @private @type {{ request: object, promise: Promise<{ ok: true }> } | null} */ this._unlockInFlight = null /** @private @type {WalletAccountReadOnlyRgbLightning | null} */ @@ -128,6 +154,18 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL try { this._binding.unlock(unlockRequest) } catch (e) { + if (this._autoRecoverStaleVssFence && isStaleVssFenceError(e)) { + const password = vssFenceClearPassword(unlockRequest) + if (password) { + try { + this._binding.clearVssFence(password) + this._binding.unlock(unlockRequest) + return { ok: true } + } catch (recoveryError) { + throw wrapError(recoveryError, UnlockError) + } + } + } // Wrap into a typed UnlockError so callers can branch on // `err.name === 'UnlockError'` / `err.code` instead of // substring-matching the RLN message. The original message is diff --git a/src/wallet-manager-rgb-lightning.js b/src/wallet-manager-rgb-lightning.js index 8549d80..ec56a57 100644 --- a/src/wallet-manager-rgb-lightning.js +++ b/src/wallet-manager-rgb-lightning.js @@ -26,6 +26,7 @@ const MEMPOOL_SPACE_URL = 'https://mempool.space' * announceAddresses?: string[], * announceAlias?: string, * autoUnlockRequest?: object, + * autoRecoverStaleVssFence?: boolean, * nodeSeedDerivation?: 'auto'|'wdk-seed-v2'|'legacy-v1' * }} RgbLightningWalletConfig */ @@ -100,7 +101,7 @@ export default class WalletManagerRgbLightning extends WalletManager { * @param {RgbLightningWalletConfig} config */ constructor (seed, config = {}) { - const { autoUnlockRequest, ...managerConfig } = config + const { autoUnlockRequest, autoRecoverStaleVssFence, ...managerConfig } = config super(seed, managerConfig) if (!config.network) throw new Error('network configuration is required.') @@ -113,6 +114,7 @@ export default class WalletManagerRgbLightning extends WalletManager { /** @private */ this._network = config.network /** @private */ this._autoUnlockRequest = normalizeAutoUnlockRequest(autoUnlockRequest) + /** @private */ this._autoRecoverStaleVssFence = autoRecoverStaleVssFence === true /** @private @type {IRgbLightningBinding | null} */ this._binding = null } @@ -180,7 +182,8 @@ export default class WalletManagerRgbLightning extends WalletManager { this._accounts[index] = new WalletAccountRgbLightning({ binding, - autoUnlockRequest: this._autoUnlockRequest + autoUnlockRequest: this._autoUnlockRequest, + autoRecoverStaleVssFence: this._autoRecoverStaleVssFence }) } return this._accounts[index] diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index b728c90..64361f8 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -105,6 +105,7 @@ function makeBinding (overrides = {}) { bootstrap: jest.fn(() => ({ node_id: 'aa'.repeat(33) })), shutdown: jest.fn(() => undefined), apayNew: jest.fn(() => ({ order_id: 'o1' })), + clearVssFence: jest.fn(() => undefined), vssStatus: jest.fn(() => ({ configured: false, url: null, allowHttp: false, lastBackupVersion: null })), ...bindingOverrides } @@ -170,6 +171,77 @@ describe('lifecycle', () => { expect(err.code).toBe('UNLOCK_FAILED') }) + it('does not clear a stale VSS fence unless the host opts in', async () => { + const clearVssFence = jest.fn() + const account = makeAccount({ + unlock: () => { + throw new Error('VSS store_id is owned by another rgb-lightning-node instance') + }, + clearVssFence + }) + + await expect(account.unlock(AUTO_UNLOCK_REQUEST)).rejects.toMatchObject({ + name: 'UnlockError', + code: 'UNLOCK_FAILED' + }) + expect(clearVssFence).not.toHaveBeenCalled() + }) + + it('clears a stale VSS fence once and retries unlock when explicitly enabled', async () => { + const unlock = jest.fn() + .mockImplementationOnce(() => { + throw new Error( + 'VSS store_id is owned by another rgb-lightning-node instance; delete the `__rln_instance__` key from VSS first' + ) + }) + .mockImplementationOnce(() => undefined) + const clearVssFence = jest.fn() + const account = makeAccount( + { unlock, clearVssFence }, + { autoRecoverStaleVssFence: true } + ) + + await expect(account.unlock(AUTO_UNLOCK_REQUEST)).resolves.toEqual({ ok: true }) + expect(clearVssFence).toHaveBeenCalledTimes(1) + expect(clearVssFence).toHaveBeenCalledWith(AUTO_UNLOCK_REQUEST.bitcoind_rpc_password) + expect(unlock).toHaveBeenCalledTimes(2) + expect(unlock).toHaveBeenNthCalledWith(2, AUTO_UNLOCK_REQUEST) + }) + + it('does not clear VSS for unrelated unlock failures even when recovery is enabled', async () => { + const clearVssFence = jest.fn() + const account = makeAccount( + { + unlock: () => { throw new Error('Rln(Conflict): Invalid indexer') }, + clearVssFence + }, + { autoRecoverStaleVssFence: true } + ) + + await expect(account.unlock(AUTO_UNLOCK_REQUEST)).rejects.toMatchObject({ + name: 'UnlockError', + message: 'Rln(Conflict): Invalid indexer' + }) + expect(clearVssFence).not.toHaveBeenCalled() + }) + + it('does not attempt stale-fence recovery without a clear-fence password', async () => { + const clearVssFence = jest.fn() + const account = makeAccount( + { + unlock: () => { throw new Error('__rln_instance__ belongs to a previous owner') }, + clearVssFence + }, + { autoRecoverStaleVssFence: true } + ) + + await expect(account.unlock({})).rejects.toMatchObject({ + name: 'UnlockError', + code: 'UNLOCK_FAILED' + }) + expect(clearVssFence).not.toHaveBeenCalled() + }) + it('coalesces concurrent unlock calls at the account boundary', async () => { const unlock = jest.fn() const account = makeAccount({ unlock }) diff --git a/tests/wallet-manager.test.js b/tests/wallet-manager.test.js index 2a8e464..f707bc5 100644 --- a/tests/wallet-manager.test.js +++ b/tests/wallet-manager.test.js @@ -92,16 +92,19 @@ describe('WalletManagerRgbLightning', () => { const manager = new TestManager(MNEMONIC, { network: 'regtest', dataDir: '/wallet', - autoUnlockRequest: AUTO_UNLOCK_REQUEST + autoUnlockRequest: AUTO_UNLOCK_REQUEST, + autoRecoverStaleVssFence: true }) const account = await manager.getAccount() const binding = FakeBinding.instances[0] expect(account._autoUnlockRequest).toEqual(AUTO_UNLOCK_REQUEST) + expect(account._autoRecoverStaleVssFence).toBe(true) expect(account._autoUnlockRequest).not.toBe(AUTO_UNLOCK_REQUEST) expect(Object.isFrozen(account._autoUnlockRequest)).toBe(true) expect(Object.isFrozen(account._autoUnlockRequest.announce_addresses)).toBe(true) expect(binding._config.autoUnlockRequest).toBeUndefined() + expect(binding._config.autoRecoverStaleVssFence).toBeUndefined() }) it('rejects malformed automatic activation requests without exposing values', () => { From 028aa684538da4fac18c95b668df9191652b9bad Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 28 Jul 2026 17:00:31 +0530 Subject: [PATCH 08/34] Document external-signer VSS identity --- README.md | 11 +++++++---- src/binding-interface.js | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6150304..0c4e998 100644 --- a/README.md +++ b/README.md @@ -350,10 +350,13 @@ cross-host callbacks require the explicit `allowCrossHostCallback: true` opt-in. Set `vssUrl` at construction to mirror LDK channel state and RGB wallet data to a remote VSS key-value store in near-real-time. Payloads are client-side -encrypted (XChaCha20-Poly1305, keyed via HKDF of a signing key derived from -the BIP-39 mnemonic at BIP-32 path `m/535'/1'`); the server sees only -ciphertext, and recovery requires the original seed. Plain `http://` is -rejected for non-loopback hosts unless `vssAllowHttp: true`. +encrypted by RLN before upload; the VSS server sees only ciphertext. In +internal-mnemonic mode RLN derives the VSS identity from the BIP-39 wallet +secret; in this package's external-signer mode RLN reconstructs the same VSS +identity from the persisted key-source identity written by the signer +bootstrap. Recovery still requires recreating the same signer/node identity +from the original seed. Plain `http://` is rejected for non-loopback hosts +unless `vssAllowHttp: true`. - `account.vssStatus()` — local view: whether VSS is configured, the URL + allow-http flag, and the snapshot version from the most recent diff --git a/src/binding-interface.js b/src/binding-interface.js index 82b4d75..8bea808 100644 --- a/src/binding-interface.js +++ b/src/binding-interface.js @@ -48,6 +48,11 @@ * @property {string} [lspBearerToken] - Bearer token sent to the LSP's * `/internal/*` endpoints. Omit when the LSP does not require authorization. * + * In internal-mnemonic mode, RLN derives VSS identity from the wallet secret. + * This package uses an external signer and reconstructs the same identity from + * persisted signer key-source material. Recovery therefore requires the + * original seed so the signer and node identities can be recreated. + * * Wallet-manager-only policy fields such as `autoUnlockRequest` and * `autoRecoverStaleVssFence` are intentionally not part of this native binding * config. The manager consumes them before constructing the binding. From 0359fc7b4456ab2a2f5ada651e4829adb91657a9 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 28 Jul 2026 20:28:56 +0530 Subject: [PATCH 09/34] feat: expose Lightning fee controls and evidence --- .github/workflows/build.yml | 4 +-- index.d.ts | 50 ++++++++++++++++++++++++-- src/wallet-account-rgb-lightning.js | 5 ++- src/wallet-snapshot-contract.js | 3 +- tests/wallet-account-surface.test.js | 2 +- tests/wallet-snapshot-contract.test.js | 15 +++++++- 6 files changed, 70 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e4debd..fd95177 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,9 @@ name: Build and Test on: push: - branches: [main] + branches: [main, iris-wallet] pull_request: - branches: [main] + branches: [main, iris-wallet] permissions: contents: read diff --git a/index.d.ts b/index.d.ts index 6944be7..fbdbaf1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -132,6 +132,7 @@ export interface WalletSnapshotPayment { created_at: DecimalString updated_at: DecimalString payee_pubkey: string + fee_paid_msat: DecimalString | null } export interface WalletSnapshotTransferEndpoint { @@ -314,6 +315,49 @@ export interface OpenChannelRequest { */ export type RgbPaymentType = 'Outbound' | 'InboundAutoClaim' | 'InboundHodl' +export type LightningPaymentStatus = + | 'Pending' + | 'Claimable' + | 'Claiming' + | 'Succeeded' + | 'Cancelled' + | 'Failed' + +export interface SendPaymentRequest { + invoice: string + amt_msat?: number + asset_id?: string + asset_amount?: number + /** + * Absolute maximum routing fee accepted by LDK for this payment, in millisatoshis. + * Callers should always set an explicit policy-derived cap. + */ + max_total_routing_fee_msat?: number +} + +export interface SendPaymentResult { + payment_id: string + payment_hash: string | null + payment_secret: string | null + status: LightningPaymentStatus +} + +export interface LightningPayment { + amt_msat: number | null + asset_amount: number | null + asset_id: string | null + payment_hash: string + payment_type: RgbPaymentType + status: LightningPaymentStatus + created_at: number + updated_at: number + payee_pubkey: string + preimage: string | null + description_hash: string | null + /** Actual routing fee reported by LDK after a successful outbound payment. */ + fee_paid_msat: number | null +} + /** RGB assignment discriminant accepted by RLN's `parse_assignment_kind`. */ export type RgbAssignmentKind = 'Fungible' | 'NonFungible' | 'InflationRight' | 'ReplaceRight' | 'Any' @@ -524,10 +568,10 @@ export class WalletAccountReadOnlyRgbLightning extends WalletAccountReadOnly { getInvoiceStatus(invoice: string): Promise /** Returns the node's Lightning payment history. */ - listPayments(): Promise + listPayments(): Promise /** Returns one Lightning payment by hash and payment type. */ - getPayment(paymentHashHex: string, paymentType: RgbPaymentType): Promise + getPayment(paymentHashHex: string, paymentType: RgbPaymentType): Promise /** Returns RGB assets, optionally filtered by asset schema. */ listAssets(filterAssetSchemas?: string[]): Promise @@ -663,7 +707,7 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning claimHodlInvoice(request: object): Promise // Payments - sendPayment(request: object): Promise + sendPayment(request: SendPaymentRequest): Promise keysend(request: object): Promise // RGB assets diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 949ca80..35ae113 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -825,7 +825,10 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL // Payments // ========================================================================== - /** @param {Object} request - JsonSendPaymentRequest (invoice, amt_msat?, asset_id?, ...) */ + /** + * @param {import('../index.js').SendPaymentRequest} request + * @returns {Promise} + */ async sendPayment (request) { return this._node.sendPayment(request) } /** @param {Object} request - JsonKeysendRequest (dest_pubkey, amt_msat, asset_id?, ...) */ diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js index 9e57f7a..8be4c8a 100644 --- a/src/wallet-snapshot-contract.js +++ b/src/wallet-snapshot-contract.js @@ -321,7 +321,7 @@ function snapshotPayment (value, path) { const item = record(value, path) const fields = [ 'amt_msat', 'asset_amount', 'asset_id', 'payment_hash', 'payment_type', - 'status', 'created_at', 'updated_at', 'payee_pubkey' + 'status', 'created_at', 'updated_at', 'payee_pubkey', 'fee_paid_msat' ] exactKeys(item, fields, [], path) nullableDecimal(item.amt_msat, `${path}.amt_msat`) @@ -333,6 +333,7 @@ function snapshotPayment (value, path) { decimal(item.created_at, `${path}.created_at`) decimal(item.updated_at, `${path}.updated_at`) text(item.payee_pubkey, `${path}.payee_pubkey`, 130) + nullableDecimal(item.fee_paid_msat, `${path}.fee_paid_msat`) } function transferEndpoint (value, path) { diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 64361f8..6dad817 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -661,7 +661,7 @@ describe('payments', () => { it('sendPayment forwards to node.sendPayment', async () => { const node = makeNode() const account = makeAccount({ node }) - const req = { invoice: 'lnbc1' } + const req = { invoice: 'lnbc1', max_total_routing_fee_msat: 1_250 } await expect(account.sendPayment(req)).resolves.toMatchObject({ payment_hash: 'sp' }) expect(node.sendPayment).toHaveBeenCalledWith(req) }) diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index 20070fa..1b61dab 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -106,7 +106,8 @@ function activitySnapshot (overrides = {}) { status: 'Succeeded', created_at: '1000', updated_at: '1001', - payee_pubkey: '02abc' + payee_pubkey: '02abc', + fee_paid_msat: null }], transfers: [{ asset_id: 'asset-1', @@ -244,6 +245,18 @@ describe('wallet snapshot response contract', () => { .toHaveLength(1) }) + it('preserves the exact actual Lightning fee as decimal text', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + const value = activitySnapshot() + value.payments[0].fee_paid_msat = '1250' + + expect(validateWalletSnapshotResponse(value, options).payments[0].fee_paid_msat) + .toBe('1250') + }) + it('accepts transfer endpoint metadata and nullable transfer fields', () => { const options = normalizeWalletSnapshotOptions({ includeActivity: true, From 65c85f89a3ae3e6e4c48b90813c73deee35935a4 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 28 Jul 2026 22:06:57 +0530 Subject: [PATCH 10/34] feat: expose deterministic on-chain send plans --- CHANGELOG.md | 7 ++ index.d.ts | 43 +++++++- src/send-plan-contract.js | 147 +++++++++++++++++++++++++++ src/wallet-account-rgb-lightning.js | 31 ++++++ tests/send-plan-contract.test.js | 62 +++++++++++ tests/types-contract.ts | 19 ++++ tests/wallet-account-surface.test.js | 69 +++++++++++++ 7 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 src/send-plan-contract.js create mode 100644 tests/send-plan-contract.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d1866bd..ff7b2b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Exact BTC and RGB on-chain send-plan APIs. Accounts can prepare the native + unsigned transaction, validate its transaction id and decimal-safe fee + totals, commit that exact plan, cancel an abandoned BTC plan, and inspect + bounded pending vanilla transactions for crash recovery. +- Strict response validation for prepared plans, committed transactions, BTC + cancellation acknowledgements, and pending-operation records. Malformed or + lossy native binding responses fail closed at the WDK boundary. - UMA address-format compatibility across Lightning Address payment flows. `$recipient@example.com` is normalized to `recipient@example.com` before LNURL discovery. New root exports include `isUmaAddress`, diff --git a/index.d.ts b/index.d.ts index 4abce39..f9d6356 100644 --- a/index.d.ts +++ b/index.d.ts @@ -380,6 +380,41 @@ export interface SendRgbAssetRequest { recipient_groups: Array<{ asset_id: string; recipients: RgbSendRecipient[] }> } +export interface BtcSendRequest { + amount: number + address: string + fee_rate: number + skip_sync: boolean +} + +export interface PreparedSend { + plan_id: string + unsigned_psbt: string + fee_sat: DecimalString + total_input_sat: DecimalString + total_output_sat: DecimalString + size_vbytes: DecimalString +} + +export interface CommitPreparedSendRequest { + plan_id: string + unsigned_psbt: string +} + +export interface CommittedBtcSend { + txid: string +} + +export interface CommittedRgbSend { + txid: string + batch_transfer_idx: number +} + +export interface PendingVanillaTransaction { + txid: string + operation_type: 'CreateUtxos' | 'Drain' | 'SendBtc' +} + /** Native `JsonRgbInvoiceRequest` shape for `createRgbInvoice`. */ export interface CreateRgbInvoiceRequest { /** REQUIRED — RLN rejects the request on deserialise if omitted. */ @@ -715,10 +750,16 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning failTransfers(request: object): Promise createRgbInvoice(request: CreateRgbInvoiceRequest | object): Promise sendRgbAsset(request: SendRgbAssetRequest | object): Promise + prepareRgbSend(request: SendRgbAssetRequest): Promise + commitPreparedRgbSend(request: CommitPreparedSendRequest): Promise postAssetMedia(request: object): Promise // BTC on-chain - sendBtc(request: object): Promise + sendBtc(request: BtcSendRequest): Promise + prepareBtcSend(request: BtcSendRequest): Promise + commitPreparedBtcSend(request: CommitPreparedSendRequest): Promise + cancelBtcSendPlan(request: { plan_id: string }): Promise<{ cancelled: true }> + listPendingVanillaTransactions(): Promise sendTransaction(tx: Transaction | object): Promise rotateAddress(): Promise createUtxos(request: object): Promise<{ ok: true }> diff --git a/src/send-plan-contract.js b/src/send-plan-contract.js new file mode 100644 index 0000000..f757cd5 --- /dev/null +++ b/src/send-plan-contract.js @@ -0,0 +1,147 @@ +const TXID_PATTERN = /^[0-9a-f]{64}$/i +const DECIMAL_PATTERN = /^(0|[1-9]\d*)$/ + +function fail (path, expectation) { + throw new TypeError(`${path} must ${expectation}`) +} + +function requireObject (value, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'be an object') + } + return value +} + +function requireExactKeys (value, expected, path) { + const actual = Object.keys(value).sort() + const wanted = [...expected].sort() + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + fail(path, `contain exactly: ${wanted.join(', ')}`) + } +} + +function requireDecimal (value, path) { + if (typeof value !== 'string' || !DECIMAL_PATTERN.test(value)) { + fail(path, 'be an unsigned base-10 integer string') + } + return value +} + +export function validatePreparedSendResponse (value) { + const response = requireObject(value, 'prepared send response') + requireExactKeys(response, [ + 'plan_id', + 'unsigned_psbt', + 'fee_sat', + 'total_input_sat', + 'total_output_sat', + 'size_vbytes' + ], 'prepared send response') + + if (typeof response.plan_id !== 'string' || !TXID_PATTERN.test(response.plan_id)) { + fail('prepared send response.plan_id', 'be a 32-byte transaction id') + } + if (typeof response.unsigned_psbt !== 'string' || response.unsigned_psbt.length === 0) { + fail('prepared send response.unsigned_psbt', 'be a non-empty PSBT') + } + + const fee = BigInt(requireDecimal(response.fee_sat, 'prepared send response.fee_sat')) + const totalInput = BigInt( + requireDecimal(response.total_input_sat, 'prepared send response.total_input_sat') + ) + const totalOutput = BigInt( + requireDecimal(response.total_output_sat, 'prepared send response.total_output_sat') + ) + const sizeVbytes = BigInt( + requireDecimal(response.size_vbytes, 'prepared send response.size_vbytes') + ) + + if (totalOutput > totalInput) { + fail('prepared send response', 'not spend more than its inputs') + } + if (totalInput - totalOutput !== fee) { + fail('prepared send response.fee_sat', 'equal total_input_sat minus total_output_sat') + } + if (sizeVbytes === 0n) { + fail('prepared send response.size_vbytes', 'be greater than zero') + } + + return Object.freeze({ + plan_id: response.plan_id.toLowerCase(), + unsigned_psbt: response.unsigned_psbt, + fee_sat: response.fee_sat, + total_input_sat: response.total_input_sat, + total_output_sat: response.total_output_sat, + size_vbytes: response.size_vbytes + }) +} + +function requireTxid (value, path) { + if (typeof value !== 'string' || !TXID_PATTERN.test(value)) { + fail(path, 'be a 32-byte transaction id') + } + return value.toLowerCase() +} + +export function validateCommittedBtcSendResponse (value) { + const response = requireObject(value, 'committed BTC send response') + requireExactKeys(response, ['txid'], 'committed BTC send response') + return Object.freeze({ + txid: requireTxid(response.txid, 'committed BTC send response.txid') + }) +} + +export function validateCommittedRgbSendResponse (value) { + const response = requireObject(value, 'committed RGB send response') + requireExactKeys( + response, + ['txid', 'batch_transfer_idx'], + 'committed RGB send response' + ) + if (!Number.isSafeInteger(response.batch_transfer_idx) || response.batch_transfer_idx < 0) { + fail( + 'committed RGB send response.batch_transfer_idx', + 'be a non-negative safe integer' + ) + } + return Object.freeze({ + txid: requireTxid(response.txid, 'committed RGB send response.txid'), + batch_transfer_idx: response.batch_transfer_idx + }) +} + +export function validateCancelBtcSendPlanResponse (value) { + const response = requireObject(value, 'cancel BTC send plan response') + requireExactKeys(response, ['cancelled'], 'cancel BTC send plan response') + if (response.cancelled !== true) { + fail('cancel BTC send plan response.cancelled', 'be true') + } + return Object.freeze({ cancelled: true }) +} + +export function validatePendingVanillaTransactions (value) { + if (!Array.isArray(value) || value.length > 10_000) { + fail('pending vanilla transactions', 'be a bounded array') + } + return Object.freeze(value.map((entry, index) => { + const transaction = requireObject(entry, `pending vanilla transactions[${index}]`) + requireExactKeys( + transaction, + ['txid', 'operation_type'], + `pending vanilla transactions[${index}]` + ) + if (!['CreateUtxos', 'Drain', 'SendBtc'].includes(transaction.operation_type)) { + fail( + `pending vanilla transactions[${index}].operation_type`, + 'be a supported vanilla operation' + ) + } + return Object.freeze({ + txid: requireTxid( + transaction.txid, + `pending vanilla transactions[${index}].txid` + ), + operation_type: transaction.operation_type + }) + })) +} diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index a964c45..71645e7 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -44,6 +44,13 @@ import { validateWalletSyncResponse, walletSnapshotRequestKey } from './wallet-snapshot-contract.js' +import { + validateCancelBtcSendPlanResponse, + validateCommittedBtcSendResponse, + validateCommittedRgbSendResponse, + validatePendingVanillaTransactions, + validatePreparedSendResponse +} from './send-plan-contract.js' export { PENDING_ADDRESS } @@ -895,6 +902,14 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL */ async sendRgbAsset (request) { return this._node.sendRgb(request) } + async prepareRgbSend (request) { + return validatePreparedSendResponse(this._node.prepareRgbSend(request)) + } + + async commitPreparedRgbSend (request) { + return validateCommittedRgbSendResponse(this._node.commitPreparedRgbSend(request)) + } + /** @param {Object} request - JsonInflateRequest */ async inflate (request) { return this._node.inflate(request) } @@ -908,6 +923,22 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL /** Raw RLN send-btc escape hatch for callers that already own the native request shape. */ async sendBtc (request) { return this._node.sendBtc(request) } + async prepareBtcSend (request) { + return validatePreparedSendResponse(this._node.prepareBtcSend(request)) + } + + async commitPreparedBtcSend (request) { + return validateCommittedBtcSendResponse(this._node.commitPreparedBtcSend(request)) + } + + async cancelBtcSendPlan (request) { + return validateCancelBtcSendPlanResponse(this._node.cancelBtcSendPlan(request)) + } + + async listPendingVanillaTransactions () { + return validatePendingVanillaTransactions(this._node.listPendingVanillaTransactions()) + } + /** * WDK-standard on-chain send. Accepts `{ to, value, feeRate?, * confirmationTarget? }`; the former RLN `{ address, amount, fee_rate, diff --git a/tests/send-plan-contract.test.js b/tests/send-plan-contract.test.js new file mode 100644 index 0000000..d4e7abc --- /dev/null +++ b/tests/send-plan-contract.test.js @@ -0,0 +1,62 @@ +import { + validateCancelBtcSendPlanResponse, + validateCommittedBtcSendResponse, + validateCommittedRgbSendResponse, + validatePendingVanillaTransactions, + validatePreparedSendResponse +} from '../src/send-plan-contract.js' + +const TXID = 'ab'.repeat(32) + +function plan (overrides = {}) { + return { + plan_id: TXID, + unsigned_psbt: 'cHNidP8BAAoCAAAAAQ', + fee_sat: '100', + total_input_sat: '10000', + total_output_sat: '9900', + size_vbytes: '140', + ...overrides + } +} + +describe('prepared send contract', () => { + it('accepts and freezes a coherent lossless plan', () => { + const result = validatePreparedSendResponse(plan()) + + expect(result).toEqual(plan()) + expect(Object.isFrozen(result)).toBe(true) + }) + + it.each([ + ['unknown keys', { extra: true }], + ['invalid txid', { plan_id: 'abc' }], + ['empty PSBT', { unsigned_psbt: '' }], + ['JSON number fee', { fee_sat: 100 }], + ['negative decimal', { fee_sat: '-1' }], + ['fee mismatch', { fee_sat: '99' }], + ['zero vsize', { size_vbytes: '0' }] + ])('rejects %s', (_label, overrides) => { + expect(() => validatePreparedSendResponse(plan(overrides))).toThrow() + }) + + it('validates exact commit and cancellation responses', () => { + expect(validateCommittedBtcSendResponse({ txid: TXID })).toEqual({ txid: TXID }) + expect(validateCommittedRgbSendResponse({ + txid: TXID, + batch_transfer_idx: 7 + })).toEqual({ + txid: TXID, + batch_transfer_idx: 7 + }) + expect(validateCancelBtcSendPlanResponse({ cancelled: true })) + .toEqual({ cancelled: true }) + expect(validatePendingVanillaTransactions([{ + txid: TXID, + operation_type: 'SendBtc' + }])).toEqual([{ + txid: TXID, + operation_type: 'SendBtc' + }]) + }) +}) diff --git a/tests/types-contract.ts b/tests/types-contract.ts index f704c31..08e4098 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -15,6 +15,9 @@ import { import type WalletManagerRgbLightning from '../index.js' import type { IRgbLightningBinding, + BtcSendRequest, + CommitPreparedSendRequest, + PreparedSend, LnurlPayOptions, LspLiquidityTimeoutError, ParsedLightningAddress, @@ -64,6 +67,19 @@ const walletSnapshotOptions: WalletSnapshotOptions = { includeActivity: true } const refreshed: Promise = account.refreshWalletSnapshot(walletSnapshotOptions) +const btcSendRequest: BtcSendRequest = { + amount: 1_000, + address: 'bcrt1ptest', + fee_rate: 2, + skip_sync: false +} +const preparedBtc: Promise = account.prepareBtcSend(btcSendRequest) +const preparedCommit: CommitPreparedSendRequest = { + plan_id: 'ab'.repeat(32), + unsigned_psbt: 'cHNidP8BAAoCAAAAAQ' +} +const committedBtc = account.commitPreparedBtcSend(preparedCommit) +const cancelledBtc = account.cancelBtcSendPlan({ plan_id: preparedCommit.plan_id }) // @ts-expect-error recovery mode is explicit; arbitrary sync strategies are rejected. account.refreshWalletSnapshot({ mode: 'fast' }) @@ -82,6 +98,9 @@ binding.ensureNode() binding.node void lnurlOptions +void preparedBtc +void committedBtc +void cancelledBtc void payAddressOptions void minimumLiquidity void nodeHealth diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 6dad817..ee75c9e 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -75,11 +75,37 @@ function makeNode (overrides = {}) { rgbInvoice: jest.fn((r) => ({ rgbinv: r })), decodeRgbInvoice: jest.fn((i) => ({ decodedRgb: i })), sendRgb: jest.fn((r) => ({ txid: 'rgbtx', echo: r })), + prepareRgbSend: jest.fn(() => ({ + plan_id: 'ab'.repeat(32), + unsigned_psbt: 'cHNidP8BAAoCAAAAAQ', + fee_sat: '100', + total_input_sat: '10000', + total_output_sat: '9900', + size_vbytes: '140' + })), + commitPreparedRgbSend: jest.fn(() => ({ + txid: 'ab'.repeat(32), + batch_transfer_idx: 7 + })), inflate: jest.fn((r) => ({ inflated: r })), getAssetMedia: jest.fn((d) => ({ media: d })), postAssetMedia: jest.fn((r) => ({ posted: r })), btcBalance: jest.fn(() => ({ vanilla: { spendable: 1234, settled: 1000 } })), sendBtc: jest.fn((r) => ({ txid: 'btctx', echo: r })), + prepareBtcSend: jest.fn(() => ({ + plan_id: 'ab'.repeat(32), + unsigned_psbt: 'cHNidP8BAAoCAAAAAQ', + fee_sat: '100', + total_input_sat: '10000', + total_output_sat: '9900', + size_vbytes: '140' + })), + commitPreparedBtcSend: jest.fn(() => ({ txid: 'ab'.repeat(32) })), + cancelBtcSendPlan: jest.fn(() => ({ cancelled: true })), + listPendingVanillaTransactions: jest.fn(() => [{ + txid: 'cd'.repeat(32), + operation_type: 'SendBtc' + }]), listTransactions: jest.fn(() => ({ transactions: [] })), listTransactionsByTxid: jest.fn(() => []), listUnspents: jest.fn(() => ({ unspents: [] })), @@ -769,6 +795,23 @@ describe('RGB invoices / transfers / media', () => { expect(node.sendRgb).toHaveBeenCalledWith(req) }) + it('prepares and commits an exact RGB transaction plan', async () => { + const node = makeNode() + const account = makeAccount({ node }) + const sendRequest = { recipient_groups: [] } + const plan = await account.prepareRgbSend(sendRequest) + + expect(plan.fee_sat).toBe('100') + expect(node.prepareRgbSend).toHaveBeenCalledWith(sendRequest) + await expect(account.commitPreparedRgbSend({ + plan_id: plan.plan_id, + unsigned_psbt: plan.unsigned_psbt + })).resolves.toMatchObject({ + txid: plan.plan_id, + batch_transfer_idx: 7 + }) + }) + it('getAssetMedia forwards the digest', async () => { const node = makeNode() const account = makeAccount({ node }) @@ -784,6 +827,32 @@ describe('RGB invoices / transfers / media', () => { }) describe('BTC ops', () => { + it('prepares, commits, and cancels exact BTC transaction plans', async () => { + const node = makeNode() + const account = makeAccount({ node }) + const sendRequest = { + amount: 1_000, + address: 'bcrt1ptest', + fee_rate: 2, + skip_sync: false + } + const plan = await account.prepareBtcSend(sendRequest) + + expect(plan.fee_sat).toBe('100') + expect(node.prepareBtcSend).toHaveBeenCalledWith(sendRequest) + await expect(account.commitPreparedBtcSend({ + plan_id: plan.plan_id, + unsigned_psbt: plan.unsigned_psbt + })).resolves.toEqual({ txid: plan.plan_id }) + await expect(account.cancelBtcSendPlan({ plan_id: plan.plan_id })) + .resolves.toEqual({ cancelled: true }) + await expect(account.listPendingVanillaTransactions()) + .resolves.toEqual([{ + txid: 'cd'.repeat(32), + operation_type: 'SendBtc' + }]) + }) + it('getBalance parses vanilla.spendable to a bigint', async () => { const account = makeAccount({ node: makeNode({ btcBalance: () => ({ vanilla: { spendable: 4242, settled: 100 } }) }) From 61c518a498b0de56348b7225412c86846bff96b8 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 00:20:31 +0530 Subject: [PATCH 11/34] feat: harden wallet send and receive contracts --- CHANGELOG.md | 10 ++- index.d.ts | 23 ++++++- src/address-receipt-contract.js | 71 ++++++++++++++++++++ src/send-plan-contract.js | 90 +++++++++++++++++++++++--- src/wallet-account-rgb-lightning.js | 52 +++++++++++++-- tests/address-receipt-contract.test.js | 33 ++++++++++ tests/send-plan-contract.test.js | 40 +++++++++++- tests/types-contract.ts | 18 +++++- tests/wallet-account-surface.test.js | 35 ++++++++-- 9 files changed, 340 insertions(+), 32 deletions(-) create mode 100644 src/address-receipt-contract.js create mode 100644 tests/address-receipt-contract.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7b2b4..b627677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Strict `listAddressReceipts(address)` validation and account exposure for + authoritative BTC receive settlement, partial-payment, confirmation, and + reorg reconciliation. - Exact BTC and RGB on-chain send-plan APIs. Accounts can prepare the native - unsigned transaction, validate its transaction id and decimal-safe fee - totals, commit that exact plan, cancel an abandoned BTC plan, and inspect - bounded pending vanilla transactions for crash recovery. + unsigned transaction without exposing PSBT material to JavaScript, validate + its transaction id and decimal-safe fee totals, idempotently commit that + exact native plan, cancel abandoned BTC or RGB plans, and inspect bounded + pending plans for crash recovery. - Strict response validation for prepared plans, committed transactions, BTC cancellation acknowledgements, and pending-operation records. Malformed or lossy native binding responses fail closed at the WDK boundary. diff --git a/index.d.ts b/index.d.ts index f9d6356..6090b5c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -389,16 +389,18 @@ export interface BtcSendRequest { export interface PreparedSend { plan_id: string - unsigned_psbt: string fee_sat: DecimalString total_input_sat: DecimalString total_output_sat: DecimalString size_vbytes: DecimalString } +export interface PreparedRgbSend extends PreparedSend { + batch_transfer_idx: number +} + export interface CommitPreparedSendRequest { plan_id: string - unsigned_psbt: string } export interface CommittedBtcSend { @@ -415,6 +417,18 @@ export interface PendingVanillaTransaction { operation_type: 'CreateUtxos' | 'Drain' | 'SendBtc' } +export interface PendingRgbSendPlan { + plan_id: string + batch_transfer_idx: number +} + +export interface AddressReceipt { + txid: string + amount_sat: DecimalString + confirmations: number + block_height: number | null +} + /** Native `JsonRgbInvoiceRequest` shape for `createRgbInvoice`. */ export interface CreateRgbInvoiceRequest { /** REQUIRED — RLN rejects the request on deserialise if omitted. */ @@ -750,8 +764,10 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning failTransfers(request: object): Promise createRgbInvoice(request: CreateRgbInvoiceRequest | object): Promise sendRgbAsset(request: SendRgbAssetRequest | object): Promise - prepareRgbSend(request: SendRgbAssetRequest): Promise + prepareRgbSend(request: SendRgbAssetRequest): Promise commitPreparedRgbSend(request: CommitPreparedSendRequest): Promise + cancelRgbSendPlan(request: { plan_id: string }): Promise<{ cancelled: true }> + listPendingRgbSendPlans(): Promise postAssetMedia(request: object): Promise // BTC on-chain @@ -760,6 +776,7 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning commitPreparedBtcSend(request: CommitPreparedSendRequest): Promise cancelBtcSendPlan(request: { plan_id: string }): Promise<{ cancelled: true }> listPendingVanillaTransactions(): Promise + listAddressReceipts(address: string): Promise sendTransaction(tx: Transaction | object): Promise rotateAddress(): Promise createUtxos(request: object): Promise<{ ok: true }> diff --git a/src/address-receipt-contract.js b/src/address-receipt-contract.js new file mode 100644 index 0000000..144800b --- /dev/null +++ b/src/address-receipt-contract.js @@ -0,0 +1,71 @@ +const TXID_PATTERN = /^[0-9a-f]{64}$/i +const DECIMAL_PATTERN = /^(0|[1-9]\d*)$/ +const MAX_RECEIPTS = 1_000 +const U64_MAX = (1n << 64n) - 1n + +function fail (path, expectation) { + throw new TypeError(`${path} must ${expectation}`) +} + +function requireExactKeys (value, expected, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'be an object') + } + const actual = Object.keys(value).sort() + const wanted = [...expected].sort() + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) { + fail(path, `contain exactly: ${wanted.join(', ')}`) + } +} + +export function validateAddressReceipts (value) { + if (!Array.isArray(value) || value.length > MAX_RECEIPTS) { + fail('address receipts', `be an array with at most ${MAX_RECEIPTS} entries`) + } + + const seen = new Set() + return Object.freeze(value.map((receipt, index) => { + const path = `address receipts[${index}]` + requireExactKeys( + receipt, + ['txid', 'amount_sat', 'confirmations', 'block_height'], + path + ) + if (typeof receipt.txid !== 'string' || !TXID_PATTERN.test(receipt.txid)) { + fail(`${path}.txid`, 'be a 32-byte transaction id') + } + const txid = receipt.txid.toLowerCase() + if (seen.has(txid)) { + fail(`${path}.txid`, 'be unique') + } + seen.add(txid) + if (typeof receipt.amount_sat !== 'string' || !DECIMAL_PATTERN.test(receipt.amount_sat)) { + fail(`${path}.amount_sat`, 'be an unsigned base-10 integer string') + } + const amountSat = BigInt(receipt.amount_sat) + if (amountSat === 0n || amountSat > U64_MAX) { + fail(`${path}.amount_sat`, 'be between one and the maximum unsigned 64-bit value') + } + if (!Number.isSafeInteger(receipt.confirmations) || receipt.confirmations < 0) { + fail(`${path}.confirmations`, 'be a non-negative safe integer') + } + if ( + receipt.block_height !== null && + (!Number.isSafeInteger(receipt.block_height) || receipt.block_height < 1) + ) { + fail(`${path}.block_height`, 'be null or a positive safe integer') + } + if ( + (receipt.confirmations === 0 && receipt.block_height !== null) || + (receipt.confirmations > 0 && receipt.block_height === null) + ) { + fail(`${path}`, 'have coherent confirmation evidence') + } + return Object.freeze({ + txid, + amount_sat: receipt.amount_sat, + confirmations: receipt.confirmations, + block_height: receipt.block_height + }) + })) +} diff --git a/src/send-plan-contract.js b/src/send-plan-contract.js index f757cd5..c43deab 100644 --- a/src/send-plan-contract.js +++ b/src/send-plan-contract.js @@ -31,7 +31,6 @@ export function validatePreparedSendResponse (value) { const response = requireObject(value, 'prepared send response') requireExactKeys(response, [ 'plan_id', - 'unsigned_psbt', 'fee_sat', 'total_input_sat', 'total_output_sat', @@ -41,10 +40,6 @@ export function validatePreparedSendResponse (value) { if (typeof response.plan_id !== 'string' || !TXID_PATTERN.test(response.plan_id)) { fail('prepared send response.plan_id', 'be a 32-byte transaction id') } - if (typeof response.unsigned_psbt !== 'string' || response.unsigned_psbt.length === 0) { - fail('prepared send response.unsigned_psbt', 'be a non-empty PSBT') - } - const fee = BigInt(requireDecimal(response.fee_sat, 'prepared send response.fee_sat')) const totalInput = BigInt( requireDecimal(response.total_input_sat, 'prepared send response.total_input_sat') @@ -68,7 +63,6 @@ export function validatePreparedSendResponse (value) { return Object.freeze({ plan_id: response.plan_id.toLowerCase(), - unsigned_psbt: response.unsigned_psbt, fee_sat: response.fee_sat, total_input_sat: response.total_input_sat, total_output_sat: response.total_output_sat, @@ -76,6 +70,35 @@ export function validatePreparedSendResponse (value) { }) } +export function validatePreparedRgbSendResponse (value) { + const response = requireObject(value, 'prepared RGB send response') + requireExactKeys(response, [ + 'plan_id', + 'batch_transfer_idx', + 'fee_sat', + 'total_input_sat', + 'total_output_sat', + 'size_vbytes' + ], 'prepared RGB send response') + if (!Number.isSafeInteger(response.batch_transfer_idx) || response.batch_transfer_idx < 0) { + fail( + 'prepared RGB send response.batch_transfer_idx', + 'be a non-negative safe integer' + ) + } + const prepared = validatePreparedSendResponse({ + plan_id: response.plan_id, + fee_sat: response.fee_sat, + total_input_sat: response.total_input_sat, + total_output_sat: response.total_output_sat, + size_vbytes: response.size_vbytes + }) + return Object.freeze({ + ...prepared, + batch_transfer_idx: response.batch_transfer_idx + }) +} + function requireTxid (value, path) { if (typeof value !== 'string' || !TXID_PATTERN.test(value)) { fail(path, 'be a 32-byte transaction id') @@ -83,6 +106,14 @@ function requireTxid (value, path) { return value.toLowerCase() } +export function validateSendPlanRequest (value) { + const request = requireObject(value, 'send plan request') + requireExactKeys(request, ['plan_id'], 'send plan request') + return Object.freeze({ + plan_id: requireTxid(request.plan_id, 'send plan request.plan_id') + }) +} + export function validateCommittedBtcSendResponse (value) { const response = requireObject(value, 'committed BTC send response') requireExactKeys(response, ['txid'], 'committed BTC send response') @@ -123,6 +154,7 @@ export function validatePendingVanillaTransactions (value) { if (!Array.isArray(value) || value.length > 10_000) { fail('pending vanilla transactions', 'be a bounded array') } + const transactionIds = new Set() return Object.freeze(value.map((entry, index) => { const transaction = requireObject(entry, `pending vanilla transactions[${index}]`) requireExactKeys( @@ -136,12 +168,50 @@ export function validatePendingVanillaTransactions (value) { 'be a supported vanilla operation' ) } + const txid = requireTxid( + transaction.txid, + `pending vanilla transactions[${index}].txid` + ) + if (transactionIds.has(txid)) { + fail(`pending vanilla transactions[${index}].txid`, 'be unique') + } + transactionIds.add(txid) return Object.freeze({ - txid: requireTxid( - transaction.txid, - `pending vanilla transactions[${index}].txid` - ), + txid, operation_type: transaction.operation_type }) })) } + +export function validatePendingRgbSendPlans (value) { + if (!Array.isArray(value) || value.length > 10_000) { + fail('pending RGB send plans', 'be a bounded array') + } + const planIds = new Set() + return Object.freeze(value.map((entry, index) => { + const plan = requireObject(entry, `pending RGB send plans[${index}]`) + requireExactKeys( + plan, + ['plan_id', 'batch_transfer_idx'], + `pending RGB send plans[${index}]` + ) + const planId = requireTxid( + plan.plan_id, + `pending RGB send plans[${index}].plan_id` + ) + if (planIds.has(planId)) { + fail(`pending RGB send plans[${index}].plan_id`, 'be unique') + } + planIds.add(planId) + if (!Number.isSafeInteger(plan.batch_transfer_idx) || plan.batch_transfer_idx < 0) { + fail( + `pending RGB send plans[${index}].batch_transfer_idx`, + 'be a non-negative safe integer' + ) + } + return Object.freeze({ + plan_id: planId, + batch_transfer_idx: plan.batch_transfer_idx + }) + })) +} diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 71645e7..545084c 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -48,9 +48,13 @@ import { validateCancelBtcSendPlanResponse, validateCommittedBtcSendResponse, validateCommittedRgbSendResponse, + validatePendingRgbSendPlans, validatePendingVanillaTransactions, - validatePreparedSendResponse + validatePreparedRgbSendResponse, + validatePreparedSendResponse, + validateSendPlanRequest } from './send-plan-contract.js' +import { validateAddressReceipts } from './address-receipt-contract.js' export { PENDING_ADDRESS } @@ -903,11 +907,33 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL async sendRgbAsset (request) { return this._node.sendRgb(request) } async prepareRgbSend (request) { - return validatePreparedSendResponse(this._node.prepareRgbSend(request)) + return validatePreparedRgbSendResponse(this._node.prepareRgbSend(request)) } async commitPreparedRgbSend (request) { - return validateCommittedRgbSendResponse(this._node.commitPreparedRgbSend(request)) + return validateCommittedRgbSendResponse( + this._node.commitPreparedRgbSend(validateSendPlanRequest(request)) + ) + } + + async cancelRgbSendPlan (request) { + if (typeof this._node.cancelRgbSendPlan !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose cancelRgbSendPlan()' + ) + } + return validateCancelBtcSendPlanResponse( + this._node.cancelRgbSendPlan(validateSendPlanRequest(request)) + ) + } + + async listPendingRgbSendPlans () { + if (typeof this._node.listPendingRgbSendPlans !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose listPendingRgbSendPlans()' + ) + } + return validatePendingRgbSendPlans(this._node.listPendingRgbSendPlans()) } /** @param {Object} request - JsonInflateRequest */ @@ -928,17 +954,33 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } async commitPreparedBtcSend (request) { - return validateCommittedBtcSendResponse(this._node.commitPreparedBtcSend(request)) + return validateCommittedBtcSendResponse( + this._node.commitPreparedBtcSend(validateSendPlanRequest(request)) + ) } async cancelBtcSendPlan (request) { - return validateCancelBtcSendPlanResponse(this._node.cancelBtcSendPlan(request)) + return validateCancelBtcSendPlanResponse( + this._node.cancelBtcSendPlan(validateSendPlanRequest(request)) + ) } async listPendingVanillaTransactions () { return validatePendingVanillaTransactions(this._node.listPendingVanillaTransactions()) } + async listAddressReceipts (address) { + if (typeof address !== 'string' || address.length === 0) { + throw new TypeError('listAddressReceipts(address) requires a non-empty address') + } + if (typeof this._node.listAddressReceipts !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose listAddressReceipts()' + ) + } + return validateAddressReceipts(this._node.listAddressReceipts(address)) + } + /** * WDK-standard on-chain send. Accepts `{ to, value, feeRate?, * confirmationTarget? }`; the former RLN `{ address, amount, fee_rate, diff --git a/tests/address-receipt-contract.test.js b/tests/address-receipt-contract.test.js new file mode 100644 index 0000000..0fc95b6 --- /dev/null +++ b/tests/address-receipt-contract.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from '@jest/globals' + +import { validateAddressReceipts } from '../src/address-receipt-contract.js' + +const receipt = { + txid: 'ab'.repeat(32), + amount_sat: '125000', + confirmations: 2, + block_height: 200 +} + +describe('address receipt contract', () => { + it('normalizes and freezes authoritative receipt evidence', () => { + const result = validateAddressReceipts([receipt]) + expect(result).toEqual([receipt]) + expect(Object.isFrozen(result)).toBe(true) + expect(Object.isFrozen(result[0])).toBe(true) + }) + + it.each([ + [{ ...receipt, txid: 'bad' }], + [{ ...receipt, amount_sat: 1 }], + [{ ...receipt, amount_sat: '0' }], + [{ ...receipt, amount_sat: (1n << 64n).toString() }], + [{ ...receipt, confirmations: -1 }], + [{ ...receipt, confirmations: 0, block_height: 200 }], + [{ ...receipt, confirmations: 1, block_height: null }], + [{ ...receipt, unexpected: true }], + [receipt, receipt] + ])('rejects malformed or ambiguous evidence %#', (...entries) => { + expect(() => validateAddressReceipts(entries)).toThrow() + }) +}) diff --git a/tests/send-plan-contract.test.js b/tests/send-plan-contract.test.js index d4e7abc..b4be13b 100644 --- a/tests/send-plan-contract.test.js +++ b/tests/send-plan-contract.test.js @@ -2,8 +2,11 @@ import { validateCancelBtcSendPlanResponse, validateCommittedBtcSendResponse, validateCommittedRgbSendResponse, + validatePendingRgbSendPlans, validatePendingVanillaTransactions, - validatePreparedSendResponse + validatePreparedRgbSendResponse, + validatePreparedSendResponse, + validateSendPlanRequest } from '../src/send-plan-contract.js' const TXID = 'ab'.repeat(32) @@ -11,7 +14,6 @@ const TXID = 'ab'.repeat(32) function plan (overrides = {}) { return { plan_id: TXID, - unsigned_psbt: 'cHNidP8BAAoCAAAAAQ', fee_sat: '100', total_input_sat: '10000', total_output_sat: '9900', @@ -31,7 +33,7 @@ describe('prepared send contract', () => { it.each([ ['unknown keys', { extra: true }], ['invalid txid', { plan_id: 'abc' }], - ['empty PSBT', { unsigned_psbt: '' }], + ['native commit material', { unsigned_psbt: 'must-not-cross-the-JS-boundary' }], ['JSON number fee', { fee_sat: 100 }], ['negative decimal', { fee_sat: '-1' }], ['fee mismatch', { fee_sat: '99' }], @@ -41,6 +43,9 @@ describe('prepared send contract', () => { }) it('validates exact commit and cancellation responses', () => { + expect(validateSendPlanRequest({ plan_id: TXID.toUpperCase() })).toEqual({ + plan_id: TXID + }) expect(validateCommittedBtcSendResponse({ txid: TXID })).toEqual({ txid: TXID }) expect(validateCommittedRgbSendResponse({ txid: TXID, @@ -58,5 +63,34 @@ describe('prepared send contract', () => { txid: TXID, operation_type: 'SendBtc' }]) + expect(validatePreparedRgbSendResponse({ + ...plan(), + batch_transfer_idx: 7 + })).toEqual({ + ...plan(), + batch_transfer_idx: 7 + }) + expect(validatePendingRgbSendPlans([{ + plan_id: TXID, + batch_transfer_idx: 7 + }])).toEqual([{ + plan_id: TXID, + batch_transfer_idx: 7 + }]) + }) + + it('rejects commit material and duplicate pending native identities', () => { + expect(() => validateSendPlanRequest({ + plan_id: TXID, + unsigned_psbt: 'must-not-cross-the-WDK-boundary' + })).toThrow() + expect(() => validatePendingVanillaTransactions([ + { txid: TXID, operation_type: 'SendBtc' }, + { txid: TXID.toUpperCase(), operation_type: 'SendBtc' } + ])).toThrow() + expect(() => validatePendingRgbSendPlans([ + { plan_id: TXID, batch_transfer_idx: 7 }, + { plan_id: TXID.toUpperCase(), batch_transfer_idx: 7 } + ])).toThrow() }) }) diff --git a/tests/types-contract.ts b/tests/types-contract.ts index 08e4098..8d93170 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -17,6 +17,9 @@ import type { IRgbLightningBinding, BtcSendRequest, CommitPreparedSendRequest, + AddressReceipt, + PendingRgbSendPlan, + PreparedRgbSend, PreparedSend, LnurlPayOptions, LspLiquidityTimeoutError, @@ -75,11 +78,19 @@ const btcSendRequest: BtcSendRequest = { } const preparedBtc: Promise = account.prepareBtcSend(btcSendRequest) const preparedCommit: CommitPreparedSendRequest = { - plan_id: 'ab'.repeat(32), - unsigned_psbt: 'cHNidP8BAAoCAAAAAQ' + plan_id: 'ab'.repeat(32) } const committedBtc = account.commitPreparedBtcSend(preparedCommit) const cancelledBtc = account.cancelBtcSendPlan({ plan_id: preparedCommit.plan_id }) +const preparedRgb: Promise = account.prepareRgbSend({ + donation: false, + fee_rate: 2, + min_confirmations: 1, + recipient_groups: [] +}) +const pendingRgb: Promise = account.listPendingRgbSendPlans() +const addressReceipts: Promise = + account.listAddressReceipts('bcrt1ptest') // @ts-expect-error recovery mode is explicit; arbitrary sync strategies are rejected. account.refreshWalletSnapshot({ mode: 'fast' }) @@ -101,6 +112,9 @@ void lnurlOptions void preparedBtc void committedBtc void cancelledBtc +void preparedRgb +void pendingRgb +void addressReceipts void payAddressOptions void minimumLiquidity void nodeHealth diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index ee75c9e..e5d2bef 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -77,7 +77,7 @@ function makeNode (overrides = {}) { sendRgb: jest.fn((r) => ({ txid: 'rgbtx', echo: r })), prepareRgbSend: jest.fn(() => ({ plan_id: 'ab'.repeat(32), - unsigned_psbt: 'cHNidP8BAAoCAAAAAQ', + batch_transfer_idx: 7, fee_sat: '100', total_input_sat: '10000', total_output_sat: '9900', @@ -87,6 +87,11 @@ function makeNode (overrides = {}) { txid: 'ab'.repeat(32), batch_transfer_idx: 7 })), + cancelRgbSendPlan: jest.fn(() => ({ cancelled: true })), + listPendingRgbSendPlans: jest.fn(() => [{ + plan_id: 'ab'.repeat(32), + batch_transfer_idx: 7 + }]), inflate: jest.fn((r) => ({ inflated: r })), getAssetMedia: jest.fn((d) => ({ media: d })), postAssetMedia: jest.fn((r) => ({ posted: r })), @@ -94,7 +99,6 @@ function makeNode (overrides = {}) { sendBtc: jest.fn((r) => ({ txid: 'btctx', echo: r })), prepareBtcSend: jest.fn(() => ({ plan_id: 'ab'.repeat(32), - unsigned_psbt: 'cHNidP8BAAoCAAAAAQ', fee_sat: '100', total_input_sat: '10000', total_output_sat: '9900', @@ -106,6 +110,12 @@ function makeNode (overrides = {}) { txid: 'cd'.repeat(32), operation_type: 'SendBtc' }]), + listAddressReceipts: jest.fn(() => [{ + txid: 'ef'.repeat(32), + amount_sat: '125000', + confirmations: 2, + block_height: 200 + }]), listTransactions: jest.fn(() => ({ transactions: [] })), listTransactionsByTxid: jest.fn(() => []), listUnspents: jest.fn(() => ({ unspents: [] })), @@ -804,12 +814,18 @@ describe('RGB invoices / transfers / media', () => { expect(plan.fee_sat).toBe('100') expect(node.prepareRgbSend).toHaveBeenCalledWith(sendRequest) await expect(account.commitPreparedRgbSend({ - plan_id: plan.plan_id, - unsigned_psbt: plan.unsigned_psbt + plan_id: plan.plan_id })).resolves.toMatchObject({ txid: plan.plan_id, batch_transfer_idx: 7 }) + await expect(account.cancelRgbSendPlan({ + plan_id: plan.plan_id + })).resolves.toEqual({ cancelled: true }) + await expect(account.listPendingRgbSendPlans()).resolves.toEqual([{ + plan_id: plan.plan_id, + batch_transfer_idx: 7 + }]) }) it('getAssetMedia forwards the digest', async () => { @@ -841,8 +857,7 @@ describe('BTC ops', () => { expect(plan.fee_sat).toBe('100') expect(node.prepareBtcSend).toHaveBeenCalledWith(sendRequest) await expect(account.commitPreparedBtcSend({ - plan_id: plan.plan_id, - unsigned_psbt: plan.unsigned_psbt + plan_id: plan.plan_id })).resolves.toEqual({ txid: plan.plan_id }) await expect(account.cancelBtcSendPlan({ plan_id: plan.plan_id })) .resolves.toEqual({ cancelled: true }) @@ -851,6 +866,14 @@ describe('BTC ops', () => { txid: 'cd'.repeat(32), operation_type: 'SendBtc' }]) + await expect(account.listAddressReceipts('bcrt1ptest')) + .resolves.toEqual([{ + txid: 'ef'.repeat(32), + amount_sat: '125000', + confirmations: 2, + block_height: 200 + }]) + expect(node.listAddressReceipts).toHaveBeenCalledWith('bcrt1ptest') }) it('getBalance parses vanilla.spendable to a bigint', async () => { From fad7242bce7fdfe34d1f574505799e25b52dc9d5 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 01:49:35 +0530 Subject: [PATCH 12/34] fix: require complete Lightning invoice metadata --- CHANGELOG.md | 8 ++++++++ index.d.ts | 16 +++++++++++++++- package-lock.json | 8 ++++---- package.json | 6 +++--- tests/types-contract.ts | 4 ++++ tests/wallet-account-surface.test.js | 17 +++++++++++++++-- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b627677..9694800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Exact `DecodedLightningInvoice` typing across the WDK account boundary, + including `min_final_cltv_expiry_delta`. - Strict `listAddressReceipts(address)` validation and account exposure for authoritative BTC receive settlement, partial-payment, confirmation, and reorg reconciliation. @@ -27,6 +29,12 @@ while pre-`1.0`. `parseLightningAddress` now also returns the canonical address, domain, and whether the input used UMA form. +### Changed +- Raised native peer floors to `@utexo/rgb-lightning-node-bare + >=0.1.0-beta.16 <0.2.0` and `@utexo/rgb-lightning-node-nodejs + >=0.1.0-beta.12 <0.2.0`, the first releases that preserve Lightning CLTV + metadata through the C-FFI decode response. + ## [0.1.0-beta.15] — 2026-07-23 ### Added diff --git a/index.d.ts b/index.d.ts index 6090b5c..b7b8caf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -266,6 +266,20 @@ export interface CreateLightningInvoiceRequest { minFinalCltvExpiryDelta?: number } +/** Native BOLT11 decode result exposed by both supported RLN bindings. */ +export interface DecodedLightningInvoice { + amt_msat: number | null + expiry_sec: number + timestamp: number + asset_id: string | null + asset_amount: number | null + payment_hash: string + payment_secret: string + payee_pubkey: string | null + min_final_cltv_expiry_delta: number + network: string +} + export interface CreateHodlInvoiceParams { /** 32-byte payment hash (hex). The preimage is released later via claimHodlInvoice. */ paymentHash: string @@ -611,7 +625,7 @@ export class WalletAccountReadOnlyRgbLightning extends WalletAccountReadOnly { listPeers(): Promise /** Decodes a BOLT11 Lightning invoice without paying it. */ - decodeInvoice(invoice: string): Promise + decodeInvoice(invoice: string): Promise /** Returns the node's current status for a Lightning invoice. */ getInvoiceStatus(invoice: string): Promise diff --git a/package-lock.json b/package-lock.json index 26b143d..aae3caa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.15", + "version": "0.1.0-beta.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.15", + "version": "0.1.0-beta.16", "license": "Apache-2.0", "dependencies": { "@tetherto/wdk-wallet": "1.0.0-beta.14", @@ -21,8 +21,8 @@ "typescript": "5.8.3" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.14 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.10 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.16 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.12 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/package.json b/package.json index 4321474..5d789ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.15", + "version": "0.1.0-beta.16", "description": "WDK module for RGB Lightning (rgb-lightning-node) — channels, invoices, payments, hodl, RGB-over-LN.", "keywords": [ "wdk", @@ -35,8 +35,8 @@ "sodium-universal": "5.0.1" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.14 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.10 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.16 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.12 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/tests/types-contract.ts b/tests/types-contract.ts index 8d93170..e0c43af 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -17,6 +17,7 @@ import type { IRgbLightningBinding, BtcSendRequest, CommitPreparedSendRequest, + DecodedLightningInvoice, AddressReceipt, PendingRgbSendPlan, PreparedRgbSend, @@ -91,6 +92,8 @@ const preparedRgb: Promise = account.prepareRgbSend({ const pendingRgb: Promise = account.listPendingRgbSendPlans() const addressReceipts: Promise = account.listAddressReceipts('bcrt1ptest') +const decodedLightningInvoice: Promise = + account.decodeInvoice('lnbcrt1...') // @ts-expect-error recovery mode is explicit; arbitrary sync strategies are rejected. account.refreshWalletSnapshot({ mode: 'fast' }) @@ -115,6 +118,7 @@ void cancelledBtc void preparedRgb void pendingRgb void addressReceipts +void decodedLightningInvoice void payAddressOptions void minimumLiquidity void nodeHealth diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index e5d2bef..a60fa94 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -35,6 +35,19 @@ const AUTO_UNLOCK_REQUEST = Object.freeze({ announce_alias: 'wallet-test' }) +const DECODED_LIGHTNING_INVOICE = Object.freeze({ + amt_msat: 3_000_000, + expiry_sec: 3_600, + timestamp: 1_750_000_000, + asset_id: null, + asset_amount: null, + payment_hash: '11'.repeat(32), + payment_secret: '22'.repeat(32), + payee_pubkey: '02' + '33'.repeat(32), + min_final_cltv_expiry_delta: 42, + network: 'Regtest' +}) + // Build a fake RLN node whose methods are jest.fn returning canned // values. Every method the account forwards to is present so we can // assert forwarding + arg pass-through. @@ -53,7 +66,7 @@ function makeNode (overrides = {}) { disconnectPeer: jest.fn(() => undefined), listPeers: jest.fn(() => ({ peers: [] })), lnInvoice: jest.fn((r) => ({ invoice: 'lnbc1', payment_hash: 'ph', echo: r })), - decodeLnInvoice: jest.fn((i) => ({ decoded: i })), + decodeLnInvoice: jest.fn(() => DECODED_LIGHTNING_INVOICE), invoiceStatus: jest.fn((i) => ({ status: 'Pending', echo: i })), cancelHodlInvoice: jest.fn(() => undefined), claimHodlInvoice: jest.fn((r) => ({ claimed: r })), @@ -613,7 +626,7 @@ describe('invoices', () => { it('decodeInvoice forwards to node.decodeLnInvoice', async () => { const node = makeNode() const account = makeAccount({ node }) - await expect(account.decodeInvoice('lnbc1')).resolves.toEqual({ decoded: 'lnbc1' }) + await expect(account.decodeInvoice('lnbc1')).resolves.toEqual(DECODED_LIGHTNING_INVOICE) expect(node.decodeLnInvoice).toHaveBeenCalledWith('lnbc1') }) From 99954bfd6b8124e647f55ba89c419d93fc349549 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 02:33:52 +0530 Subject: [PATCH 13/34] fix: expose stable RGB invoice decode metadata --- CHANGELOG.md | 6 ++++++ index.d.ts | 19 ++++++++++++++++++- package-lock.json | 8 ++++---- package.json | 6 +++--- tests/types-contract.ts | 4 ++++ tests/wallet-account-surface.test.js | 16 ++++++++++++++-- 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9694800..e0d88b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Exact `DecodedRgbInvoice` and tagged `DecodedRgbAssignment` typing across + the WDK account boundary. - Exact `DecodedLightningInvoice` typing across the WDK account boundary, including `min_final_cltv_expiry_delta`. - Strict `listAddressReceipts(address)` validation and account exposure for @@ -30,6 +32,10 @@ while pre-`1.0`. whether the input used UMA form. ### Changed +- Raised native peer floors to `@utexo/rgb-lightning-node-bare + >=0.1.0-beta.17 <0.2.0` and `@utexo/rgb-lightning-node-nodejs + >=0.1.0-beta.13 <0.2.0`, the first releases with stable RGB assignment + decoding. - Raised native peer floors to `@utexo/rgb-lightning-node-bare >=0.1.0-beta.16 <0.2.0` and `@utexo/rgb-lightning-node-nodejs >=0.1.0-beta.12 <0.2.0`, the first releases that preserve Lightning CLTV diff --git a/index.d.ts b/index.d.ts index b7b8caf..d809a3d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -280,6 +280,23 @@ export interface DecodedLightningInvoice { network: string } +export type DecodedRgbAssignment = + | { type: 'Fungible'; value: number } + | { type: 'NonFungible' } + | { type: 'InflationRight'; value: number } + | { type: 'Any' } + +export interface DecodedRgbInvoice { + recipient_id: string + recipient_type: 'Blind' | 'Witness' + asset_schema: string | null + asset_id: string | null + assignment: DecodedRgbAssignment + network: string + expiration_timestamp: number | null + transport_endpoints: string[] +} + export interface CreateHodlInvoiceParams { /** 32-byte payment hash (hex). The preimage is released later via claimHodlInvoice. */ paymentHash: string @@ -656,7 +673,7 @@ export class WalletAccountReadOnlyRgbLightning extends WalletAccountReadOnly { listTransfersByTxid(txid: string): Promise /** Decodes an RGB invoice without creating a transfer. */ - decodeRgbInvoice(invoice: string): Promise + decodeRgbInvoice(invoice: string): Promise /** Returns RGB asset media identified by its content digest. */ getAssetMedia(digest: string): Promise diff --git a/package-lock.json b/package-lock.json index aae3caa..2c9bc21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "license": "Apache-2.0", "dependencies": { "@tetherto/wdk-wallet": "1.0.0-beta.14", @@ -21,8 +21,8 @@ "typescript": "5.8.3" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.16 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.12 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.17 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.13 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/package.json b/package.json index 5d789ba..c8e1a4c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.17", "description": "WDK module for RGB Lightning (rgb-lightning-node) — channels, invoices, payments, hodl, RGB-over-LN.", "keywords": [ "wdk", @@ -35,8 +35,8 @@ "sodium-universal": "5.0.1" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.16 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.12 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.17 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.13 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/tests/types-contract.ts b/tests/types-contract.ts index e0c43af..773662e 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -18,6 +18,7 @@ import type { BtcSendRequest, CommitPreparedSendRequest, DecodedLightningInvoice, + DecodedRgbInvoice, AddressReceipt, PendingRgbSendPlan, PreparedRgbSend, @@ -94,6 +95,8 @@ const addressReceipts: Promise = account.listAddressReceipts('bcrt1ptest') const decodedLightningInvoice: Promise = account.decodeInvoice('lnbcrt1...') +const decodedRgbInvoice: Promise = + account.decodeRgbInvoice('rgb:...') // @ts-expect-error recovery mode is explicit; arbitrary sync strategies are rejected. account.refreshWalletSnapshot({ mode: 'fast' }) @@ -119,6 +122,7 @@ void preparedRgb void pendingRgb void addressReceipts void decodedLightningInvoice +void decodedRgbInvoice void payAddressOptions void minimumLiquidity void nodeHealth diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index a60fa94..e26bf1d 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -48,6 +48,17 @@ const DECODED_LIGHTNING_INVOICE = Object.freeze({ network: 'Regtest' }) +const DECODED_RGB_INVOICE = Object.freeze({ + recipient_id: 'bcrt:utxob:test', + recipient_type: 'Blind', + asset_schema: 'Nia', + asset_id: 'rgb:test', + assignment: Object.freeze({ type: 'Fungible', value: 0 }), + network: 'Regtest', + expiration_timestamp: 1_750_003_600, + transport_endpoints: Object.freeze(['rpc://127.0.0.1:3000/json-rpc']) +}) + // Build a fake RLN node whose methods are jest.fn returning canned // values. Every method the account forwards to is present so we can // assert forwarding + arg pass-through. @@ -86,7 +97,7 @@ function makeNode (overrides = {}) { refreshTransfers: jest.fn(() => undefined), failTransfers: jest.fn((r) => ({ failed: r })), rgbInvoice: jest.fn((r) => ({ rgbinv: r })), - decodeRgbInvoice: jest.fn((i) => ({ decodedRgb: i })), + decodeRgbInvoice: jest.fn(() => DECODED_RGB_INVOICE), sendRgb: jest.fn((r) => ({ txid: 'rgbtx', echo: r })), prepareRgbSend: jest.fn(() => ({ plan_id: 'ab'.repeat(32), @@ -807,7 +818,8 @@ describe('RGB invoices / transfers / media', () => { it('decodeRgbInvoice forwards to node.decodeRgbInvoice', async () => { const node = makeNode() const account = makeAccount({ node }) - await expect(account.decodeRgbInvoice('rgb:abc')).resolves.toEqual({ decodedRgb: 'rgb:abc' }) + await expect(account.decodeRgbInvoice('rgb:abc')).resolves.toEqual(DECODED_RGB_INVOICE) + expect(node.decodeRgbInvoice).toHaveBeenCalledWith('rgb:abc') }) it('sendRgbAsset forwards to node.sendRgb', async () => { From b036469def550d7334434163e3173e7021848ef4 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 03:44:53 +0530 Subject: [PATCH 14/34] fix: require recoverable Lightning bindings --- CHANGELOG.md | 5 +++++ package-lock.json | 8 ++++---- package.json | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d88b1..232db63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ while pre-`1.0`. whether the input used UMA form. ### Changed +- Raised native peer floors to `@utexo/rgb-lightning-node-bare + >=0.1.0-beta.18 <0.2.0` and `@utexo/rgb-lightning-node-nodejs + >=0.1.0-beta.14 <0.2.0`. These releases preserve duplicate-channel + protection while allowing a trusted virtual channel to be opened again + after the previous native session reaches its terminal abandoned state. - Raised native peer floors to `@utexo/rgb-lightning-node-bare >=0.1.0-beta.17 <0.2.0` and `@utexo/rgb-lightning-node-nodejs >=0.1.0-beta.13 <0.2.0`, the first releases with stable RGB assignment diff --git a/package-lock.json b/package-lock.json index 2c9bc21..e2bc975 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.17", + "version": "0.1.0-beta.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.17", + "version": "0.1.0-beta.18", "license": "Apache-2.0", "dependencies": { "@tetherto/wdk-wallet": "1.0.0-beta.14", @@ -21,8 +21,8 @@ "typescript": "5.8.3" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.17 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.13 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.18 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.14 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/package.json b/package.json index c8e1a4c..1d1ffcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.17", + "version": "0.1.0-beta.18", "description": "WDK module for RGB Lightning (rgb-lightning-node) — channels, invoices, payments, hodl, RGB-over-LN.", "keywords": [ "wdk", @@ -35,8 +35,8 @@ "sodium-universal": "5.0.1" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.17 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.13 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.18 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.14 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { From 2502bee6fe0e48d1317e306131268b2523e0c51e Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 05:25:18 +0530 Subject: [PATCH 15/34] fix: persist VLS signer state across restarts --- CHANGELOG.md | 19 +++++++++++ README.md | 14 +++++--- package-lock.json | 8 ++--- package.json | 6 ++-- src/bare-binding.js | 14 +++++--- src/binding-interface.js | 8 +++-- src/node-binding.js | 7 ++-- src/signer-storage-path.js | 31 ++++++++++++++++++ src/wallet-manager-rgb-lightning.js | 5 +-- tests/__mocks__/rgb-lightning-node-bare.mjs | 4 +++ tests/__mocks__/rgb-lightning-node-nodejs.mjs | 4 +++ tests/bare-binding-methods.test.js | 21 ++++++++---- tests/node-binding-methods.test.js | 32 ++++++++++++------- tests/signer-storage-path.test.js | 20 ++++++++++++ 14 files changed, 150 insertions(+), 43 deletions(-) create mode 100644 src/signer-storage-path.js create mode 100644 tests/signer-storage-path.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 232db63..66cb174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) while pre-`1.0`. +## [0.1.0-beta.19] - 2026-07-29 + +### Fixed +- Construct both React Native and Node external signers with disk-backed VLS + storage below the account's persistent `dataDir`. Per-commitment secrets and + points now survive process restarts, so restored channels remain signable. +- Isolate the automatic legacy seed fallback in its own signer store to avoid + opening one VLS database with two wallet identities. + +### Changed +- Raised native peer floors to `@utexo/rgb-lightning-node-bare + >=0.1.0-beta.19 <0.2.0` and `@utexo/rgb-lightning-node-nodejs + >=0.1.0-beta.15 <0.2.0`, the first releases exposing persistent signer + construction. +- Documented that RLN VSS currently replicates LDK state but not the external + signer's redb database. Local restart recovery is supported; cross-device + recovery of open channels remains incomplete until signer-state backup is + implemented. + ## [Unreleased] ### Added diff --git a/README.md b/README.md index 0bc5ae8..1ab1e05 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,11 @@ bootstrap. Recovery still requires recreating the same signer/node identity from the original seed. Plain `http://` is rejected for non-loopback hosts unless `vssAllowHttp: true`. +VSS currently replicates RLN's LDK and wallet key-value state, but it does not +replicate the external VLS signer's redb database stored below `dataDir`. +Consequently, process restarts on the same device are supported, while +cross-device recovery with open channels is not yet a complete recovery path. + - `account.vssStatus()` — local view: whether VSS is configured, the URL + allow-http flag, and the snapshot version from the most recent `vssBackup()` this session. @@ -402,11 +407,10 @@ the wallet's behalf. Against a production LSP this requires ## Security model - **Seed never leaves the host.** The mnemonic is owned by the WDK secret - manager. The binding derives a 32-byte BIP-32 entropy, passes it once to - `NativeExternalSigner.create`, and RLN persists only public identifying - material (xpubs, node id, master fingerprint). Re-deriving from the same - mnemonic reproduces the same entropy, matches the on-disk key-source, and - keeps the LDK node identity stable across restarts. + manager. The binding derives 32-byte node entropy and passes it to + `NativeExternalSigner.createWithStorage`. The signer persists derived VLS + identity and channel commitment state below the account's app-private + `dataDir`; reopening still requires the original mnemonic. - **All channel-state crypto runs in-process** through [`vls-protocol-signer`][vls]. The signer's lifecycle is tied to the binding and is destroyed on `manager.dispose()`. Retained seed copies use diff --git a/package-lock.json b/package-lock.json index e2bc975..fba1230 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.18", + "version": "0.1.0-beta.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.18", + "version": "0.1.0-beta.19", "license": "Apache-2.0", "dependencies": { "@tetherto/wdk-wallet": "1.0.0-beta.14", @@ -21,8 +21,8 @@ "typescript": "5.8.3" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.18 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.14 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.19 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.15 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/package.json b/package.json index 1d1ffcd..a01e93f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@utexo/wdk-rgb-lightning", - "version": "0.1.0-beta.18", + "version": "0.1.0-beta.19", "description": "WDK module for RGB Lightning (rgb-lightning-node) — channels, invoices, payments, hodl, RGB-over-LN.", "keywords": [ "wdk", @@ -35,8 +35,8 @@ "sodium-universal": "5.0.1" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.18 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.14 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.19 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.15 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/src/bare-binding.js b/src/bare-binding.js index 13987fc..bd90909 100644 --- a/src/bare-binding.js +++ b/src/bare-binding.js @@ -14,17 +14,19 @@ // Seed handling: // The host (WDK) owns the BIP-39 mnemonic. The binding receives a // 32-byte BIP-32 entropy (`seedHex`) derived from that mnemonic and -// uses it to construct a `NativeExternalSigner`. RLN never persists +// uses it to construct a disk-backed `NativeExternalSigner`. RLN never +// persists // the seed — the key-source file written by // `initWithNativeExternalSigner` only records identifying public // data (xpubs, node id, master fingerprint). On subsequent app // launches, the same mnemonic re-derives the same seedHex, which // re-derives the same signer identity, which matches the key-source -// file on disk — so the LDK node identity stays stable across -// restarts. +// file on disk — while the VLS store preserves channel commitment state +// across restarts. import rln from '@utexo/rgb-lightning-node-bare' import { retainSecret, revealSecret, secretMatches, wipeSecret } from './secret-buffer.js' +import { signerStoragePath } from './signer-storage-path.js' const { SdkNode, @@ -148,9 +150,10 @@ export class BareRgbLightningBinding { } return } - this._signer = NativeExternalSigner.create( + this._signer = NativeExternalSigner.createWithStorage( seedHex, this._config.network, + signerStoragePath(this._config.dataDir), this._config.permissiveSignerPolicy ?? true ) wipeSecret(this._seedHex) @@ -197,9 +200,10 @@ export class BareRgbLightningBinding { } const fallbackSeed = this._fallbackSeedHex - const fallbackSigner = NativeExternalSigner.create( + const fallbackSigner = NativeExternalSigner.createWithStorage( revealSecret(fallbackSeed), this._config.network, + signerStoragePath(this._config.dataDir, 'legacy'), this._config.permissiveSignerPolicy ?? true ) try { diff --git a/src/binding-interface.js b/src/binding-interface.js index 8bea808..b4d1115 100644 --- a/src/binding-interface.js +++ b/src/binding-interface.js @@ -49,9 +49,11 @@ * `/internal/*` endpoints. Omit when the LSP does not require authorization. * * In internal-mnemonic mode, RLN derives VSS identity from the wallet secret. - * This package uses an external signer and reconstructs the same identity from - * persisted signer key-source material. Recovery therefore requires the - * original seed so the signer and node identities can be recreated. + * This package uses an external signer and persists its VLS commitment state + * below `dataDir`. Recovery requires both the original seed and that signer + * store. RLN's current VSS replication covers LDK state but not the external + * signer's redb store, so cross-device recovery of open channels is not yet a + * complete backup path. * * Wallet-manager-only policy fields such as `autoUnlockRequest` and * `autoRecoverStaleVssFence` are intentionally not part of this native binding diff --git a/src/node-binding.js b/src/node-binding.js index 1248d3a..a761952 100644 --- a/src/node-binding.js +++ b/src/node-binding.js @@ -12,6 +12,7 @@ import rln from '@utexo/rgb-lightning-node-nodejs' import { retainSecret, revealSecret, secretMatches, wipeSecret } from './secret-buffer.js' +import { signerStoragePath } from './signer-storage-path.js' const { SdkNode, @@ -126,9 +127,10 @@ export class NodeRgbLightningBinding { } return } - this._signer = NativeExternalSigner.create( + this._signer = NativeExternalSigner.createWithStorage( seedHex, this._config.network, + signerStoragePath(this._config.dataDir), this._config.permissiveSignerPolicy ?? true ) wipeSecret(this._seedHex) @@ -172,9 +174,10 @@ export class NodeRgbLightningBinding { } const fallbackSeed = this._fallbackSeedHex - const fallbackSigner = NativeExternalSigner.create( + const fallbackSigner = NativeExternalSigner.createWithStorage( revealSecret(fallbackSeed), this._config.network, + signerStoragePath(this._config.dataDir, 'legacy'), this._config.permissiveSignerPolicy ?? true ) try { diff --git a/src/signer-storage-path.js b/src/signer-storage-path.js new file mode 100644 index 0000000..a7eec6d --- /dev/null +++ b/src/signer-storage-path.js @@ -0,0 +1,31 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +'use strict' + +const PRIMARY_SIGNER_DIRECTORY = 'vls-signer' +const LEGACY_SIGNER_DIRECTORY = 'vls-signer-legacy' + +/** + * Resolve a stable signer store below the account's persistent native data + * directory without importing Node's `path` module into React Native. + * + * @param {string} dataDir + * @param {'primary'|'legacy'} [identity] + * @returns {string} + */ +export function signerStoragePath (dataDir, identity = 'primary') { + if (typeof dataDir !== 'string' || dataDir.trim().length === 0) { + throw new Error('A persistent dataDir is required for VLS signer storage') + } + if (identity !== 'primary' && identity !== 'legacy') { + throw new Error("Signer storage identity must be 'primary' or 'legacy'") + } + + const root = dataDir.replace(/[\\/]+$/, '') + const directory = identity === 'legacy' + ? LEGACY_SIGNER_DIRECTORY + : PRIMARY_SIGNER_DIRECTORY + return `${root}/${directory}` +} diff --git a/src/wallet-manager-rgb-lightning.js b/src/wallet-manager-rgb-lightning.js index ec56a57..55ccc1a 100644 --- a/src/wallet-manager-rgb-lightning.js +++ b/src/wallet-manager-rgb-lightning.js @@ -74,8 +74,9 @@ export function legacyWdkSeedToNodeSeedHex (seed) { * the mnemonic ourselves — we derive a 32-byte VLS node entropy * from it on demand and hand it to RLN's `NativeExternalSigner`, * which runs the VLS signer entirely in-process. RLN's on-disk - * state only contains identifying public data (xpubs, node id, - * master fingerprint via the key-source file), never the seed. + * state contains identifying public data plus the VLS commitment-state + * database. It never contains the seed; the same mnemonic is still required + * to reopen the signer after a restart. */ export default class WalletManagerRgbLightning extends WalletManager { /** diff --git a/tests/__mocks__/rgb-lightning-node-bare.mjs b/tests/__mocks__/rgb-lightning-node-bare.mjs index 133a0ea..319edee 100644 --- a/tests/__mocks__/rgb-lightning-node-bare.mjs +++ b/tests/__mocks__/rgb-lightning-node-bare.mjs @@ -14,6 +14,10 @@ const NativeExternalSigner = { create: () => ({ bootstrap: () => ({}), destroy: () => {} + }), + createWithStorage: () => ({ + bootstrap: () => ({}), + destroy: () => {} }) } diff --git a/tests/__mocks__/rgb-lightning-node-nodejs.mjs b/tests/__mocks__/rgb-lightning-node-nodejs.mjs index 9e09cd3..b86474d 100644 --- a/tests/__mocks__/rgb-lightning-node-nodejs.mjs +++ b/tests/__mocks__/rgb-lightning-node-nodejs.mjs @@ -19,6 +19,10 @@ const NativeExternalSigner = { create: () => ({ bootstrap: () => ({}), destroy: () => {} + }), + createWithStorage: () => ({ + bootstrap: () => ({}), + destroy: () => {} }) } diff --git a/tests/bare-binding-methods.test.js b/tests/bare-binding-methods.test.js index 33d11ac..096175f 100644 --- a/tests/bare-binding-methods.test.js +++ b/tests/bare-binding-methods.test.js @@ -63,13 +63,13 @@ describe('BareRgbLightningBinding', () => { it('retains primary and fallback seeds without constructing the fallback eagerly', () => { const signer = fakeSigner() - const createSpy = jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(signer) + const createSpy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage').mockReturnValue(signer) const binding = makeBinding({ permissiveSignerPolicy: false }) binding.attachExternalSigner('seed-v2', 'seed-v1') expect(createSpy).toHaveBeenCalledTimes(1) - expect(createSpy).toHaveBeenCalledWith('seed-v2', 'regtest', false) + expect(createSpy).toHaveBeenCalledWith('seed-v2', 'regtest', '/d/vls-signer', false) expect(binding._signer).toBe(signer) expect(binding._seedHex.toString()).toBe('seed-v2') expect(binding._fallbackSeedHex.toString()).toBe('seed-v1') @@ -77,7 +77,7 @@ describe('BareRgbLightningBinding', () => { it('keeps same-seed attachment idempotent and rejects a wallet swap', () => { const signer = fakeSigner() - const createSpy = jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(signer) + const createSpy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage').mockReturnValue(signer) const binding = makeBinding() binding.attachExternalSigner('seed-v2', 'seed-old') @@ -85,7 +85,7 @@ describe('BareRgbLightningBinding', () => { binding.attachExternalSigner('seed-v2', 'seed-v1') expect(createSpy).toHaveBeenCalledTimes(1) - expect(createSpy).toHaveBeenCalledWith('seed-v2', 'regtest', true) + expect(createSpy).toHaveBeenCalledWith('seed-v2', 'regtest', '/d/vls-signer', true) expect(binding._fallbackSeedHex.toString()).toBe('seed-v1') expect(oldFallback.every((byte) => byte === 0)).toBe(true) expect(() => binding.attachExternalSigner('seed-v3')).toThrow('a different signer is already attached') @@ -158,7 +158,8 @@ describe('BareRgbLightningBinding', () => { throw new Error('external signer identity does not match persisted key_source.json') }) .mockImplementationOnce(() => undefined) - jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(fallbackSigner) + const createSpy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage') + .mockReturnValue(fallbackSigner) binding._node = node binding._signer = primarySigner binding._seedHex = primarySeed @@ -167,6 +168,12 @@ describe('BareRgbLightningBinding', () => { binding.unlock({ rpc: true }) expect(primarySigner.destroy).toHaveBeenCalledTimes(1) + expect(createSpy).toHaveBeenCalledWith( + 'seed-v1', + 'regtest', + '/d/vls-signer-legacy', + true + ) expect(binding._signer).toBe(fallbackSigner) expect(binding._seedHex).toBe(fallbackSeed) expect(binding._fallbackSeedHex).toBeUndefined() @@ -186,7 +193,7 @@ describe('BareRgbLightningBinding', () => { node.unlockWithNativeExternalSigner.mockImplementation(() => { throw new Error('Rln(ExternalSignerMismatch): identity mismatch') }) - jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(fallbackSigner) + jest.spyOn(rln.NativeExternalSigner, 'createWithStorage').mockReturnValue(fallbackSigner) binding._node = node binding._signer = primarySigner binding._seedHex = primarySeed @@ -227,7 +234,7 @@ describe('BareRgbLightningBinding', () => { node.unlockWithNativeExternalSigner.mockImplementation(() => { throw new Error('Rln(ExternalSignerMismatch): identity mismatch') }) - jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(fallbackSigner) + jest.spyOn(rln.NativeExternalSigner, 'createWithStorage').mockReturnValue(fallbackSigner) binding._node = node binding._signer = primarySigner binding._seedHex = Buffer.from('seed-v2') diff --git a/tests/node-binding-methods.test.js b/tests/node-binding-methods.test.js index e98a752..a2bfade 100644 --- a/tests/node-binding-methods.test.js +++ b/tests/node-binding-methods.test.js @@ -89,7 +89,7 @@ describe('binding surface', () => { }) describe('attachExternalSigner', () => { - it('creates a signer via NativeExternalSigner.create when none attached', () => { + it('creates a persistent signer when none is attached', () => { const b = makeBinding() expect(b._signer).toBeNull() b.attachExternalSigner('seed-a') @@ -98,27 +98,27 @@ describe('attachExternalSigner', () => { }) it('records an optional legacy fallback seed without constructing it eagerly', () => { - const spy = jest.spyOn(rln.NativeExternalSigner, 'create') + const spy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage') .mockReturnValue({ bootstrap: jest.fn(), destroy: jest.fn() }) try { const b = makeBinding() b.attachExternalSigner('seed-v2', 'seed-v1') expect(b._fallbackSeedHex.toString()).toBe('seed-v1') expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith('seed-v2', 'regtest', true) + expect(spy).toHaveBeenCalledWith('seed-v2', 'regtest', '/d/vls-signer', true) } finally { spy.mockRestore() } }) - it('passes the seed, configured network and permissive-policy default to NativeExternalSigner.create', () => { + it('passes the seed, network, stable storage and policy to the signer', () => { const created = { bootstrap: jest.fn(), destroy: jest.fn() } - const spy = jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(created) + const spy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage').mockReturnValue(created) try { const b = makeBinding() b.attachExternalSigner('seed-a') expect(spy).toHaveBeenCalledTimes(1) - expect(spy).toHaveBeenCalledWith('seed-a', 'regtest', true) + expect(spy).toHaveBeenCalledWith('seed-a', 'regtest', '/d/vls-signer', true) expect(b._signer).toBe(created) } finally { spy.mockRestore() @@ -127,12 +127,12 @@ describe('attachExternalSigner', () => { // An explicit false value must not be replaced by the default. it('forwards an explicit permissiveSignerPolicy=false instead of the default', () => { - const spy = jest.spyOn(rln.NativeExternalSigner, 'create') + const spy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage') .mockReturnValue({ bootstrap: jest.fn(), destroy: jest.fn() }) try { const b = makeBinding({ permissiveSignerPolicy: false }) b.attachExternalSigner('seed-a') - expect(spy).toHaveBeenCalledWith('seed-a', 'regtest', false) + expect(spy).toHaveBeenCalledWith('seed-a', 'regtest', '/d/vls-signer', false) } finally { spy.mockRestore() } @@ -243,7 +243,8 @@ describe('unlock', () => { throw new Error('Rln(ExternalSignerMismatch): External signer identity does not match persisted node identity') }) .mockImplementationOnce(() => undefined) - const createSpy = jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(fallbackSigner) + const createSpy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage') + .mockReturnValue(fallbackSigner) b._node = node b._signer = primarySigner const primarySeed = Buffer.from('seed-v2') @@ -253,7 +254,12 @@ describe('unlock', () => { try { expect(() => b.unlock({ rpc: true })).not.toThrow() expect(primarySigner.destroy).toHaveBeenCalledTimes(1) - expect(createSpy).toHaveBeenCalledWith('seed-v1', 'regtest', true) + expect(createSpy).toHaveBeenCalledWith( + 'seed-v1', + 'regtest', + '/d/vls-signer-legacy', + true + ) expect(node.unlockWithNativeExternalSigner).toHaveBeenLastCalledWith(fallbackSigner, { rpc: true }) expect(b._seedHex).toBe(fallbackSeed) expect(b._seedHex.toString()).toBe('seed-v1') @@ -274,7 +280,8 @@ describe('unlock', () => { node.unlockWithNativeExternalSigner.mockImplementation(() => { throw new Error('Rln(ExternalSignerMismatch): External signer identity does not match persisted node identity') }) - const createSpy = jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(fallbackSigner) + const createSpy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage') + .mockReturnValue(fallbackSigner) const primarySeed = Buffer.from('seed-v2') const fallbackSeed = Buffer.from('seed-v1') b._node = node @@ -305,7 +312,8 @@ describe('unlock', () => { node.unlockWithNativeExternalSigner.mockImplementation(() => { throw new Error('Rln(ExternalSignerMismatch): External signer identity does not match persisted node identity') }) - const createSpy = jest.spyOn(rln.NativeExternalSigner, 'create').mockReturnValue(fallbackSigner) + const createSpy = jest.spyOn(rln.NativeExternalSigner, 'createWithStorage') + .mockReturnValue(fallbackSigner) b._node = node b._signer = primarySigner b._seedHex = Buffer.from('seed-v2') diff --git a/tests/signer-storage-path.test.js b/tests/signer-storage-path.test.js new file mode 100644 index 0000000..3bd6394 --- /dev/null +++ b/tests/signer-storage-path.test.js @@ -0,0 +1,20 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +import { signerStoragePath } from '../src/signer-storage-path.js' + +describe('signerStoragePath', () => { + it('uses stable identity-specific stores below the account data directory', () => { + expect(signerStoragePath('/wallet/account-0/')).toBe('/wallet/account-0/vls-signer') + expect(signerStoragePath('/wallet/account-0', 'legacy')) + .toBe('/wallet/account-0/vls-signer-legacy') + }) + + it('normalizes trailing separators and rejects invalid input', () => { + expect(signerStoragePath('C:\\wallet\\')).toBe('C:\\wallet/vls-signer') + expect(() => signerStoragePath('')).toThrow('persistent dataDir') + expect(() => signerStoragePath('/wallet', 'unknown')).toThrow('identity') + }) +}) From dbec00c89172d9e8ea8845518029890d8af1d4b5 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 06:26:13 +0530 Subject: [PATCH 16/34] fix: require RGB witness output data --- README.md | 5 ++- index.d.ts | 11 ++++++ src/wallet-account-rgb-lightning.js | 37 +++++++++++++---- tests/transfer-router.test.js | 61 +++++++++++++++++++++++++++++ tests/types-contract.ts | 5 +++ 5 files changed, 111 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 1ab1e05..e4f97cb 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,10 @@ Notes: `options.recipient` (BOLT11 invoice, LN pubkey, BTC address, or RGB invoice) and dispatches to the right primitive. `options.token` is an RGB `asset_id` when present. Amounts are msats for LN flows and sats for - on-chain flows. + on-chain flows. An on-chain RGB invoice whose recipient type is `Witness` + requires `options.witnessData.amountSats`; that value funds the Bitcoin + witness output and is not a fee. The router rejects witness data on blinded + recipients instead of silently ignoring it. - **`getBalance()` returns `bigint` satoshis**, matching WDK's account contract. `getTokenBalance(assetId)` returns the spendable RGB amount as a `bigint` and falls back to the settled amount when needed. diff --git a/index.d.ts b/index.d.ts index d809a3d..214cb94 100644 --- a/index.d.ts +++ b/index.d.ts @@ -221,6 +221,17 @@ export interface TransferOptions { /** sat/vB override for on-chain flows. */ feeRate?: number confirmationTarget?: number + /** + * Bitcoin witness output carried by an on-chain RGB transfer. + * Required when the decoded RGB invoice recipient type is `Witness` and + * rejected for blinded recipients. + */ + witnessData?: { + /** Positive safe integer in satoshis. This is an output value, not a fee. */ + amountSats: number + /** Optional non-negative safe-integer RGB witness blinding value. */ + blinding?: number + } } export interface TransferResult { diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 545084c..05ff3f1 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -1084,7 +1084,9 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL * `options.amount` is treated as **msats** for LN flows and **sats** * for on-chain flows — callers using `transfer()` for on-chain need to * pass sats, not msats. For finer control, call the underlying method - * directly. + * directly. Witness RGB invoices additionally require + * `options.witnessData.amountSats`; this is the Bitcoin output value + * committed by the transfer, not a routing or miner fee. * * @param {TransferOptions} options * @returns {Promise} @@ -1157,19 +1159,40 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL if (endpoints.length === 0) { throw new Error('transfer(rgb): the RGB invoice carries no transport endpoints and the wallet has no proxyEndpoint configured') } + const witnessData = options.witnessData + if (decoded?.recipient_type === 'Witness') { + if (!witnessData || !Number.isSafeInteger(witnessData.amountSats) || witnessData.amountSats <= 0) { + throw new Error('transfer(rgb): witnessData.amountSats must be a positive safe integer for a Witness RGB invoice') + } + if ( + witnessData.blinding !== undefined && + (!Number.isSafeInteger(witnessData.blinding) || witnessData.blinding < 0) + ) { + throw new Error('transfer(rgb): witnessData.blinding must be a non-negative safe integer when provided') + } + } else if (witnessData !== undefined) { + throw new Error('transfer(rgb): witnessData is only valid for a Witness RGB invoice') + } const feeRate = options.feeRate ?? await this._defaultFeeRate(6) + const recipientRequest = { + recipient_id: recipientId, + assignment_kind: 'Fungible', + assignment_amount: Number(amount), + transport_endpoints: endpoints + } + if (decoded?.recipient_type === 'Witness') { + recipientRequest.witness_data = { + amount_sat: witnessData.amountSats, + ...(witnessData.blinding === undefined ? {} : { blinding: witnessData.blinding }) + } + } const req = { donation: false, fee_rate: Number(feeRate), min_confirmations: 1, recipient_groups: [{ asset_id: contractId, - recipients: [{ - recipient_id: recipientId, - assignment_kind: 'Fungible', - assignment_amount: Number(amount), - transport_endpoints: endpoints - }] + recipients: [recipientRequest] }] } const r = await this.sendRgbAsset(req) diff --git a/tests/transfer-router.test.js b/tests/transfer-router.test.js index 81dea44..1eb7c9b 100644 --- a/tests/transfer-router.test.js +++ b/tests/transfer-router.test.js @@ -29,6 +29,7 @@ function makeAccount () { // transport_endpoints before building the native sendRgb request. account.decodeRgbInvoice = jest.fn(async () => ({ recipient_id: 'recip123', + recipient_type: 'Blind', asset_id: 'assetFromInvoice', transport_endpoints: ['rpc://proxy.example/json-rpc'] })) @@ -108,6 +109,66 @@ describe('WalletAccountRgbLightning.transfer', () => { expect(res).toEqual({ hash: 'rgbtxid', fee: 0n }) }) + it('requires and forwards explicit Bitcoin output data for a Witness RGB invoice', async () => { + const account = makeAccount() + account.decodeRgbInvoice = jest.fn(async () => ({ + recipient_id: 'witness-recipient', + recipient_type: 'Witness', + asset_id: 'assetFromInvoice', + transport_endpoints: ['rpc://proxy.example/json-rpc'] + })) + + await expect(account.transfer({ + recipient: RGB_INVOICE, + amount: 5, + feeRate: 4 + })).rejects.toThrow('witnessData.amountSats must be a positive safe integer') + + await account.transfer({ + recipient: RGB_INVOICE, + amount: 5, + feeRate: 4, + witnessData: { amountSats: 1_000, blinding: 7 } + }) + + expect(account.sendRgbAsset).toHaveBeenCalledWith(expect.objectContaining({ + recipient_groups: [{ + asset_id: 'assetFromInvoice', + recipients: [{ + recipient_id: 'witness-recipient', + assignment_kind: 'Fungible', + assignment_amount: 5, + transport_endpoints: ['rpc://proxy.example/json-rpc'], + witness_data: { amount_sat: 1_000, blinding: 7 } + }] + }] + })) + }) + + it('rejects invalid or inapplicable RGB witness output data', async () => { + const account = makeAccount() + await expect(account.transfer({ + recipient: RGB_INVOICE, + amount: 5, + feeRate: 1, + witnessData: { amountSats: 1_000 } + })).rejects.toThrow('witnessData is only valid for a Witness RGB invoice') + + account.decodeRgbInvoice = jest.fn(async () => ({ + recipient_id: 'witness-recipient', + recipient_type: 'Witness', + asset_id: 'assetFromInvoice', + transport_endpoints: ['rpc://proxy.example/json-rpc'] + })) + await expect(account.transfer({ + recipient: RGB_INVOICE, + amount: 5, + feeRate: 1, + witnessData: { amountSats: 1_000, blinding: -1 } + })).rejects.toThrow('witnessData.blinding must be a non-negative safe integer') + expect(account.sendRgbAsset).not.toHaveBeenCalled() + }) + it('falls back to the invoice-encoded asset_id when no token is supplied', async () => { const account = makeAccount() await account.transfer({ recipient: RGB_INVOICE, amount: 2, feeRate: 1 }) diff --git a/tests/types-contract.ts b/tests/types-contract.ts index 773662e..6b11555 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -90,6 +90,11 @@ const preparedRgb: Promise = account.prepareRgbSend({ min_confirmations: 1, recipient_groups: [] }) +account.transfer({ + recipient: 'rgb:...', + amount: 1n, + witnessData: { amountSats: 1_000, blinding: 7 } +}) const pendingRgb: Promise = account.listPendingRgbSendPlans() const addressReceipts: Promise = account.listAddressReceipts('bcrt1ptest') From d7c409f5b0f32b783a2e8459620238bd6b25db53 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 06:57:40 +0530 Subject: [PATCH 17/34] fix: reconcile RGB transfers before snapshots --- README.md | 5 +++++ src/wallet-account-rgb-lightning.js | 17 +++++++++++++++++ tests/wallet-snapshot-contract.test.js | 23 +++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/README.md b/README.md index e4f97cb..99e2d33 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,11 @@ Notes: - **Lightning claimable value is not routing capacity.** The snapshot keeps aggregate/per-channel claimable satoshis separate from inbound and outbound capacity. Consumers must not relabel either capacity as wallet-owned value. +- **Snapshot refresh includes RGB transport reconciliation.** After both + Bitcoin keychains synchronize, `refreshWalletSnapshot()` advances pending + RGB consignments with `refreshTransfers({ skip_sync: true })` before + capturing balances and activity. A proxy or consignment refresh failure + fails the snapshot closed instead of returning stale asset state. - **`createInvoice` / `createLightningInvoice`** accept either RLN's native snake_case request or a camelCase convenience shape diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 05ff3f1..fd77611 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -522,6 +522,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL const node = this._node if ( typeof node.syncWallet !== 'function' || + typeof node.refreshTransfers !== 'function' || typeof node.walletSnapshot !== 'function' ) { throw new WalletSnapshotError( @@ -628,6 +629,22 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL ) } + try { + // syncWallet advances the Bitcoin keychains. RGB consignments have a + // separate transport lifecycle and must be refreshed before the + // snapshot is allowed to represent current asset state. + await node.refreshTransfers({ skip_sync: true }) + } catch (error) { + throw new WalletSyncError( + 'The native RGB transfer synchronization failed.', + { + code: 'WALLET_SYNC_RGB_TRANSFER_FAILURE', + cause: error, + details: Object.freeze({ mode }) + } + ) + } + return sync } diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index 1b61dab..9eaff24 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -132,6 +132,9 @@ function activitySnapshot (overrides = {}) { } function accountWith (node) { + if (typeof node.refreshTransfers !== 'function') { + node.refreshTransfers = jest.fn(() => undefined) + } return new WalletAccountRgbLightning({ binding: { ensureNode: jest.fn(() => node), @@ -369,6 +372,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { const result = await account.refreshWalletSnapshot() expect(node.syncWallet).toHaveBeenCalledWith({ mode: 'routine' }) + expect(node.refreshTransfers).toHaveBeenCalledWith({ skip_sync: true }) expect(node.walletSnapshot).toHaveBeenCalledWith({ asset_ids: [], max_assets: 128, @@ -397,6 +401,24 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { error_code: 'FAILED_BDK_SYNC' }) expect(node.walletSnapshot).not.toHaveBeenCalled() + expect(node.refreshTransfers).not.toHaveBeenCalled() + }) + + it('fails closed when RGB consignment refresh fails', async () => { + const node = { + syncWallet: jest.fn(() => syncResult()), + refreshTransfers: jest.fn(() => { + throw new Error('proxy unavailable') + }), + walletSnapshot: jest.fn() + } + + const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) + + expect(error).toBeInstanceOf(WalletSyncError) + expect(error.code).toBe('WALLET_SYNC_RGB_TRANSFER_FAILURE') + expect(error.details).toEqual({ mode: 'routine' }) + expect(node.walletSnapshot).not.toHaveBeenCalled() }) it('uses FullScan recovery mode only when explicitly selected', async () => { @@ -420,6 +442,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { } const result = await accountWith(node).refreshWalletSnapshot() expect(node.syncWallet).toHaveBeenCalledTimes(2) + expect(node.refreshTransfers).toHaveBeenCalledTimes(2) expect(node.walletSnapshot).toHaveBeenCalledTimes(2) expect(result.snapshot.capture_sequence).toBe('8') }) From 97afa4b4fcf4256481c6fa63072963b3282f1c83 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 09:17:03 +0530 Subject: [PATCH 18/34] feat: add explicit RGB wallet UTXO setup --- CHANGELOG.md | 4 + README.md | 2 +- index.d.ts | 16 +++ scripts/smoke-node-package.mjs | 6 +- src/rgb-utxo-setup-contract.js | 157 ++++++++++++++++++++++++++ src/wallet-account-rgb-lightning.js | 37 ++++++ tests/rgb-utxo-setup-contract.test.js | 68 +++++++++++ tests/types-contract.ts | 16 +++ tests/wallet-account-surface.test.js | 53 +++++++++ 9 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 src/rgb-utxo-setup-contract.js create mode 100644 tests/rgb-utxo-setup-contract.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cb174..d564a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ while pre-`1.0`. its transaction id and decimal-safe fee totals, idempotently commit that exact native plan, cancel abandoned BTC or RGB plans, and inspect bounded pending plans for crash recovery. +- Explicit, reviewable RGB wallet UTXO setup with `prepareCreateUtxos()`, + `commitPreparedCreateUtxos()`, and `cancelCreateUtxosPlan()`. Requests and + native responses are strictly validated, monetary values remain decimal + strings, and no PSBT material crosses the WDK boundary. - Strict response validation for prepared plans, committed transactions, BTC cancellation acknowledgements, and pending-operation records. Malformed or lossy native binding responses fail closed at the WDK boundary. diff --git a/README.md b/README.md index 99e2d33..1d23e94 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ are async and forward to the active binding. | RGB assets | `listAssets(filter?)`, `getAssetBalance(id)`, `getAssetMetadata(id)`, `listTransfers(id)`, `listTransfersByTxid(txid)`, `refreshTransfers(req)`, `failTransfers(req)` | | RGB invoices/transfers | `createRgbInvoice(request)`, `decodeRgbInvoice(invoice)`, `sendRgbAsset(request)`, `getAssetMedia(digest)`, `postAssetMedia(request)` | | RGB issuance (forwarded) | `issueAssetNia(request)`, `issueAssetUda(request)`, `issueAssetCfa(request)`, `issueAssetIfa(request)`, `inflate(request)` — forward to the binding; `@utexo/wdk-wallet-rgb` is the supported path (see note) | -| BTC | `getBalance(skipSync?)`, `getBalanceDetails(skipSync?)`, `sendTransaction({ to, value, ... })`, `sendBtc(nativeRequest)`, `getTransactions(skipSync?)`, `getTransactionsByTxid(txid)`, `listUnspents(skipSync?)`, `createUtxos(request)`, `estimateFee(blocks)` | +| BTC | `getBalance(skipSync?)`, `getBalanceDetails(skipSync?)`, `sendTransaction({ to, value, ... })`, `sendBtc(nativeRequest)`, `prepareBtcSend(request)`, `commitPreparedBtcSend(request)`, `cancelBtcSendPlan(request)`, `getTransactions(skipSync?)`, `getTransactionsByTxid(txid)`, `listUnspents(skipSync?)`, `createUtxos(request)`, `prepareCreateUtxos(request)`, `commitPreparedCreateUtxos(request)`, `cancelCreateUtxosPlan(request)`, `estimateFee(blocks)` | | WDK-standard | `index`, `path`, `keyPair`, `sign(message)`, `verify(message, signature)`, `transfer(options)`, `quoteTransfer(options)`, `quoteSendTransaction(tx)`, `getTransactionReceipt(hash)`, `toReadOnlyAccount()` | | Diagnostics | `sendOnionMessage(request)`, `checkIndexerUrl(url)`, `checkProxyEndpoint(endpoint)` | | VSS | `vssStatus()`, `vssBackup()`, `clearVssFence(password)` | diff --git a/index.d.ts b/index.d.ts index 214cb94..b1a40b1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -441,6 +441,19 @@ export interface PreparedRgbSend extends PreparedSend { batch_transfer_idx: number } +export interface CreateUtxosRequest { + up_to: boolean + num?: number + size?: number + fee_rate: number + skip_sync: boolean +} + +export interface PreparedCreateUtxos extends PreparedSend { + target_count: number + output_size_sat: number +} + export interface CommitPreparedSendRequest { plan_id: string } @@ -817,6 +830,9 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning prepareBtcSend(request: BtcSendRequest): Promise commitPreparedBtcSend(request: CommitPreparedSendRequest): Promise cancelBtcSendPlan(request: { plan_id: string }): Promise<{ cancelled: true }> + prepareCreateUtxos(request: CreateUtxosRequest): Promise + commitPreparedCreateUtxos(request: CommitPreparedSendRequest): Promise + cancelCreateUtxosPlan(request: { plan_id: string }): Promise<{ cancelled: true }> listPendingVanillaTransactions(): Promise listAddressReceipts(address: string): Promise sendTransaction(tx: Transaction | object): Promise diff --git a/scripts/smoke-node-package.mjs b/scripts/smoke-node-package.mjs index 56f874d..104929b 100644 --- a/scripts/smoke-node-package.mjs +++ b/scripts/smoke-node-package.mjs @@ -84,6 +84,10 @@ try { packageJson.peerDependencies[nativePackage], nativePackage ) + const nativePackageSpec = registryPackage + ? `${nativePackage}@${nativeVersion}` + : process.env.WDK_RGB_LIGHTNING_NODE_SPEC ?? + 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#iris-wallet' writeFileSync( path.join(temporaryRoot, 'package.json'), @@ -101,7 +105,7 @@ try { '--no-fund', '--save-exact', packageSpec, - `${nativePackage}@${nativeVersion}` + nativePackageSpec ], { cwd: temporaryRoot }) const runtimeInstallations = runAndCapture( diff --git a/src/rgb-utxo-setup-contract.js b/src/rgb-utxo-setup-contract.js new file mode 100644 index 0000000..93bd846 --- /dev/null +++ b/src/rgb-utxo-setup-contract.js @@ -0,0 +1,157 @@ +const TXID_PATTERN = /^[0-9a-f]{64}$/i +const DECIMAL_PATTERN = /^(0|[1-9]\d*)$/ +const UINT8_MAX = 255 +const UINT32_MAX = 4_294_967_295 + +function fail (path, expectation) { + throw new TypeError(`${path} must ${expectation}`) +} + +function requireObject (value, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'be an object') + } + return value +} + +function requireExactKeys (value, required, optional, path) { + const allowed = new Set([...required, ...optional]) + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + fail(path, `contain only: ${[...allowed].sort().join(', ')}`) + } + } + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + fail(path, `contain ${key}`) + } + } +} + +function requireBoolean (value, path) { + if (typeof value !== 'boolean') fail(path, 'be a boolean') + return value +} + +function requirePositiveSafeInteger (value, maximum, path) { + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + fail(path, `be an integer from 1 through ${maximum}`) + } + return value +} + +function requireDecimal (value, path) { + if (typeof value !== 'string' || !DECIMAL_PATTERN.test(value)) { + fail(path, 'be an unsigned base-10 integer string') + } + return value +} + +function requireTxid (value, path) { + if (typeof value !== 'string' || !TXID_PATTERN.test(value)) { + fail(path, 'be a 32-byte transaction id') + } + return value.toLowerCase() +} + +export function validateCreateUtxosRequest (value) { + const request = requireObject(value, 'create UTXOs request') + requireExactKeys( + request, + ['up_to', 'fee_rate', 'skip_sync'], + ['num', 'size'], + 'create UTXOs request' + ) + + const normalized = { + up_to: requireBoolean(request.up_to, 'create UTXOs request.up_to'), + fee_rate: requirePositiveSafeInteger( + request.fee_rate, + Number.MAX_SAFE_INTEGER, + 'create UTXOs request.fee_rate' + ), + skip_sync: requireBoolean(request.skip_sync, 'create UTXOs request.skip_sync') + } + if (request.num !== undefined) { + normalized.num = requirePositiveSafeInteger( + request.num, + UINT8_MAX, + 'create UTXOs request.num' + ) + } + if (request.size !== undefined) { + normalized.size = requirePositiveSafeInteger( + request.size, + UINT32_MAX, + 'create UTXOs request.size' + ) + } + return Object.freeze(normalized) +} + +export function validatePreparedCreateUtxosResponse (value) { + const response = requireObject(value, 'prepared create UTXOs response') + requireExactKeys(response, [ + 'plan_id', + 'fee_sat', + 'total_input_sat', + 'total_output_sat', + 'size_vbytes', + 'target_count', + 'output_size_sat' + ], [], 'prepared create UTXOs response') + + const feeSat = requireDecimal( + response.fee_sat, + 'prepared create UTXOs response.fee_sat' + ) + const totalInputSat = requireDecimal( + response.total_input_sat, + 'prepared create UTXOs response.total_input_sat' + ) + const totalOutputSat = requireDecimal( + response.total_output_sat, + 'prepared create UTXOs response.total_output_sat' + ) + const sizeVbytes = requireDecimal( + response.size_vbytes, + 'prepared create UTXOs response.size_vbytes' + ) + const fee = BigInt(feeSat) + const totalInput = BigInt(totalInputSat) + const totalOutput = BigInt(totalOutputSat) + + if (totalOutput > totalInput) { + fail('prepared create UTXOs response', 'not spend more than its inputs') + } + if (totalInput - totalOutput !== fee) { + fail( + 'prepared create UTXOs response.fee_sat', + 'equal total_input_sat minus total_output_sat' + ) + } + if (BigInt(sizeVbytes) === 0n) { + fail('prepared create UTXOs response.size_vbytes', 'be greater than zero') + } + + return Object.freeze({ + plan_id: requireTxid( + response.plan_id, + 'prepared create UTXOs response.plan_id' + ), + fee_sat: feeSat, + total_input_sat: totalInputSat, + total_output_sat: totalOutputSat, + size_vbytes: sizeVbytes, + target_count: requirePositiveSafeInteger( + response.target_count, + UINT8_MAX, + 'prepared create UTXOs response.target_count' + ), + output_size_sat: requirePositiveSafeInteger( + response.output_size_sat, + UINT32_MAX, + 'prepared create UTXOs response.output_size_sat' + ) + }) +} diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index fd77611..ca7c19b 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -54,6 +54,10 @@ import { validatePreparedSendResponse, validateSendPlanRequest } from './send-plan-contract.js' +import { + validateCreateUtxosRequest, + validatePreparedCreateUtxosResponse +} from './rgb-utxo-setup-contract.js' import { validateAddressReceipts } from './address-receipt-contract.js' export { PENDING_ADDRESS } @@ -998,6 +1002,39 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL return validateAddressReceipts(this._node.listAddressReceipts(address)) } + async prepareCreateUtxos (request) { + if (typeof this._node.prepareCreateUtxos !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose prepareCreateUtxos()' + ) + } + return validatePreparedCreateUtxosResponse( + this._node.prepareCreateUtxos(validateCreateUtxosRequest(request)) + ) + } + + async commitPreparedCreateUtxos (request) { + if (typeof this._node.commitPreparedCreateUtxos !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose commitPreparedCreateUtxos()' + ) + } + return validateCommittedBtcSendResponse( + this._node.commitPreparedCreateUtxos(validateSendPlanRequest(request)) + ) + } + + async cancelCreateUtxosPlan (request) { + if (typeof this._node.cancelCreateUtxosPlan !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose cancelCreateUtxosPlan()' + ) + } + return validateCancelBtcSendPlanResponse( + this._node.cancelCreateUtxosPlan(validateSendPlanRequest(request)) + ) + } + /** * WDK-standard on-chain send. Accepts `{ to, value, feeRate?, * confirmationTarget? }`; the former RLN `{ address, amount, fee_rate, diff --git a/tests/rgb-utxo-setup-contract.test.js b/tests/rgb-utxo-setup-contract.test.js new file mode 100644 index 0000000..aba5a3c --- /dev/null +++ b/tests/rgb-utxo-setup-contract.test.js @@ -0,0 +1,68 @@ +import { + validateCreateUtxosRequest, + validatePreparedCreateUtxosResponse +} from '../src/rgb-utxo-setup-contract.js' + +const PLAN_ID = 'ab'.repeat(32) + +describe('RGB UTXO setup contract', () => { + it('normalizes an exact create request without adding native defaults', () => { + expect(validateCreateUtxosRequest({ + up_to: true, + num: 5, + size: 1_000, + fee_rate: 2, + skip_sync: false + })).toEqual({ + up_to: true, + num: 5, + size: 1_000, + fee_rate: 2, + skip_sync: false + }) + }) + + it.each([ + [{ up_to: true, fee_rate: 2, skip_sync: false, typo: true }], + [{ up_to: 'true', fee_rate: 2, skip_sync: false }], + [{ up_to: true, num: 0, fee_rate: 2, skip_sync: false }], + [{ up_to: true, num: 256, fee_rate: 2, skip_sync: false }], + [{ up_to: true, size: 0, fee_rate: 2, skip_sync: false }], + [{ up_to: true, fee_rate: 0, skip_sync: false }] + ])('rejects an invalid create request %#', (request) => { + expect(() => validateCreateUtxosRequest(request)).toThrow(TypeError) + }) + + it('returns an immutable review-safe setup plan', () => { + const plan = validatePreparedCreateUtxosResponse({ + plan_id: PLAN_ID.toUpperCase(), + fee_sat: '300', + total_input_sat: '10300', + total_output_sat: '10000', + size_vbytes: '180', + target_count: 5, + output_size_sat: 2_000 + }) + + expect(plan).toEqual({ + plan_id: PLAN_ID, + fee_sat: '300', + total_input_sat: '10300', + total_output_sat: '10000', + size_vbytes: '180', + target_count: 5, + output_size_sat: 2_000 + }) + expect(Object.isFrozen(plan)).toBe(true) + expect(plan).not.toHaveProperty('psbt') + }) + + it.each([ + [{ plan_id: PLAN_ID, fee_sat: '1', total_input_sat: '2', total_output_sat: '1', size_vbytes: '1', target_count: 1, output_size_sat: 1, psbt: 'secret' }], + [{ plan_id: PLAN_ID, fee_sat: '2', total_input_sat: '2', total_output_sat: '1', size_vbytes: '1', target_count: 1, output_size_sat: 1 }], + [{ plan_id: PLAN_ID, fee_sat: '1', total_input_sat: '2', total_output_sat: '1', size_vbytes: '0', target_count: 1, output_size_sat: 1 }], + [{ plan_id: PLAN_ID, fee_sat: '1', total_input_sat: '2', total_output_sat: '1', size_vbytes: '1', target_count: 0, output_size_sat: 1 }] + ])('rejects an invalid prepared response %#', (response) => { + expect(() => validatePreparedCreateUtxosResponse(response)).toThrow(TypeError) + }) +}) diff --git a/tests/types-contract.ts b/tests/types-contract.ts index 6b11555..7f06e36 100644 --- a/tests/types-contract.ts +++ b/tests/types-contract.ts @@ -17,11 +17,13 @@ import type { IRgbLightningBinding, BtcSendRequest, CommitPreparedSendRequest, + CreateUtxosRequest, DecodedLightningInvoice, DecodedRgbInvoice, AddressReceipt, PendingRgbSendPlan, PreparedRgbSend, + PreparedCreateUtxos, PreparedSend, LnurlPayOptions, LspLiquidityTimeoutError, @@ -84,6 +86,17 @@ const preparedCommit: CommitPreparedSendRequest = { } const committedBtc = account.commitPreparedBtcSend(preparedCommit) const cancelledBtc = account.cancelBtcSendPlan({ plan_id: preparedCommit.plan_id }) +const createUtxosRequest: CreateUtxosRequest = { + up_to: true, + num: 5, + size: 2_000, + fee_rate: 2, + skip_sync: false +} +const preparedCreateUtxos: Promise = + account.prepareCreateUtxos(createUtxosRequest) +const committedCreateUtxos = account.commitPreparedCreateUtxos(preparedCommit) +const cancelledCreateUtxos = account.cancelCreateUtxosPlan(preparedCommit) const preparedRgb: Promise = account.prepareRgbSend({ donation: false, fee_rate: 2, @@ -123,6 +136,9 @@ void lnurlOptions void preparedBtc void committedBtc void cancelledBtc +void preparedCreateUtxos +void committedCreateUtxos +void cancelledCreateUtxos void preparedRgb void pendingRgb void addressReceipts diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index e26bf1d..c5ff79f 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -130,6 +130,17 @@ function makeNode (overrides = {}) { })), commitPreparedBtcSend: jest.fn(() => ({ txid: 'ab'.repeat(32) })), cancelBtcSendPlan: jest.fn(() => ({ cancelled: true })), + prepareCreateUtxos: jest.fn(() => ({ + plan_id: 'ef'.repeat(32), + fee_sat: '300', + total_input_sat: '10300', + total_output_sat: '10000', + size_vbytes: '180', + target_count: 5, + output_size_sat: 2_000 + })), + commitPreparedCreateUtxos: jest.fn(() => ({ txid: 'ef'.repeat(32) })), + cancelCreateUtxosPlan: jest.fn(() => ({ cancelled: true })), listPendingVanillaTransactions: jest.fn(() => [{ txid: 'cd'.repeat(32), operation_type: 'SendBtc' @@ -901,6 +912,48 @@ describe('BTC ops', () => { expect(node.listAddressReceipts).toHaveBeenCalledWith('bcrt1ptest') }) + it('prepares, commits, and cancels an explicit RGB wallet UTXO setup', async () => { + const node = makeNode() + const account = makeAccount({ node }) + const request = { + up_to: true, + num: 5, + size: 2_000, + fee_rate: 2, + skip_sync: false + } + + const plan = await account.prepareCreateUtxos(request) + + expect(plan).toEqual({ + plan_id: 'ef'.repeat(32), + fee_sat: '300', + total_input_sat: '10300', + total_output_sat: '10000', + size_vbytes: '180', + target_count: 5, + output_size_sat: 2_000 + }) + expect(node.prepareCreateUtxos).toHaveBeenCalledWith(request) + await expect(account.commitPreparedCreateUtxos({ plan_id: plan.plan_id })) + .resolves.toEqual({ txid: plan.plan_id }) + await expect(account.cancelCreateUtxosPlan({ plan_id: plan.plan_id })) + .resolves.toEqual({ cancelled: true }) + }) + + it.each([ + ['prepareCreateUtxos', { up_to: true, fee_rate: 2, skip_sync: false }], + ['commitPreparedCreateUtxos', { plan_id: 'ef'.repeat(32) }], + ['cancelCreateUtxosPlan', { plan_id: 'ef'.repeat(32) }] + ])('fails closed when the native binding lacks %s()', async (method, request) => { + const node = makeNode({ [method]: undefined }) + const account = makeAccount({ node }) + + await expect(account[method](request)).rejects.toThrow( + `does not expose ${method}()` + ) + }) + it('getBalance parses vanilla.spendable to a bigint', async () => { const account = makeAccount({ node: makeNode({ btcBalance: () => ({ vanilla: { spendable: 4242, settled: 100 } }) }) From 9fbe2a91dc62f48ab9ee44ff9e839cea997f05f0 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Wed, 29 Jul 2026 09:34:10 +0530 Subject: [PATCH 19/34] fix: validate RGB unspent allocation state --- index.d.ts | 18 ++- src/rgb-utxo-setup-contract.js | 116 ++++++++++++++++++ src/wallet-account-read-only-rgb-lightning.js | 7 +- tests/rgb-utxo-setup-contract.test.js | 82 ++++++++++++- tests/wallet-account-surface.test.js | 8 +- 5 files changed, 223 insertions(+), 8 deletions(-) diff --git a/index.d.ts b/index.d.ts index b1a40b1..412ee1b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -454,6 +454,22 @@ export interface PreparedCreateUtxos extends PreparedSend { output_size_sat: number } +export interface RgbAllocation { + asset_id: string | null + assignment: string + settled: boolean +} + +export interface RgbUnspent { + utxo: { + outpoint: string + btc_amount: number + colorable: boolean + } + rgb_allocations: readonly RgbAllocation[] + pending_blinded: number +} + export interface CommitPreparedSendRequest { plan_id: string } @@ -718,7 +734,7 @@ export class WalletAccountReadOnlyRgbLightning extends WalletAccountReadOnly { getTransactionsByTxid(txid: string, skipSync?: boolean): Promise /** Returns the account's unspent Bitcoin outputs. */ - listUnspents(skipSync?: boolean): Promise + listUnspents(skipSync?: boolean): Promise /** Estimates the Bitcoin fee rate for a confirmation target. */ estimateFee(blocks: number): Promise diff --git a/src/rgb-utxo-setup-contract.js b/src/rgb-utxo-setup-contract.js index 93bd846..b761c46 100644 --- a/src/rgb-utxo-setup-contract.js +++ b/src/rgb-utxo-setup-contract.js @@ -1,7 +1,12 @@ const TXID_PATTERN = /^[0-9a-f]{64}$/i +const OUTPOINT_PATTERN = /^([0-9a-f]{64}):(\d+)$/i const DECIMAL_PATTERN = /^(0|[1-9]\d*)$/ const UINT8_MAX = 255 const UINT32_MAX = 4_294_967_295 +const MAX_UNSPENTS = 10_000 +const MAX_ALLOCATIONS_PER_UNSPENT = 255 +const MAX_ASSET_ID_LENGTH = 512 +const MAX_ASSIGNMENT_LENGTH = 512 function fail (path, expectation) { throw new TypeError(`${path} must ${expectation}`) @@ -33,6 +38,13 @@ function requireBoolean (value, path) { return value } +function requireNonNegativeSafeInteger (value, maximum, path) { + if (!Number.isSafeInteger(value) || value < 0 || value > maximum) { + fail(path, `be an integer from 0 through ${maximum}`) + } + return value +} + function requirePositiveSafeInteger (value, maximum, path) { if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { fail(path, `be an integer from 1 through ${maximum}`) @@ -40,6 +52,25 @@ function requirePositiveSafeInteger (value, maximum, path) { return value } +function requireBoundedString (value, maximumLength, path, nullable = false) { + if (nullable && value === null) return null + if (typeof value !== 'string' || value.length === 0 || value.length > maximumLength) { + fail(path, `be a non-empty string no longer than ${maximumLength} characters`) + } + return value +} + +function requireOutpoint (value, path) { + if (typeof value !== 'string') fail(path, 'be a Bitcoin outpoint') + const match = OUTPOINT_PATTERN.exec(value) + if (!match) fail(path, 'be a Bitcoin outpoint') + const vout = Number(match[2]) + if (!Number.isSafeInteger(vout) || vout > UINT32_MAX) { + fail(path, `contain an output index from 0 through ${UINT32_MAX}`) + } + return `${match[1].toLowerCase()}:${vout}` +} + function requireDecimal (value, path) { if (typeof value !== 'string' || !DECIMAL_PATTERN.test(value)) { fail(path, 'be an unsigned base-10 integer string') @@ -155,3 +186,88 @@ export function validatePreparedCreateUtxosResponse (value) { ) }) } + +export function validateRgbUnspents (value) { + if (!Array.isArray(value) || value.length > MAX_UNSPENTS) { + fail('RGB unspents', `be an array with at most ${MAX_UNSPENTS} entries`) + } + + const unspents = value.map((entry, index) => { + const path = `RGB unspents[${index}]` + const unspent = requireObject(entry, path) + requireExactKeys( + unspent, + ['utxo', 'rgb_allocations', 'pending_blinded'], + [], + path + ) + + const utxoPath = `${path}.utxo` + const utxo = requireObject(unspent.utxo, utxoPath) + requireExactKeys( + utxo, + ['outpoint', 'btc_amount', 'colorable'], + [], + utxoPath + ) + + const allocationsPath = `${path}.rgb_allocations` + if ( + !Array.isArray(unspent.rgb_allocations) || + unspent.rgb_allocations.length > MAX_ALLOCATIONS_PER_UNSPENT + ) { + fail( + allocationsPath, + `be an array with at most ${MAX_ALLOCATIONS_PER_UNSPENT} entries` + ) + } + + const rgbAllocations = unspent.rgb_allocations.map((entry, allocationIndex) => { + const allocationPath = `${allocationsPath}[${allocationIndex}]` + const allocation = requireObject(entry, allocationPath) + requireExactKeys( + allocation, + ['asset_id', 'assignment', 'settled'], + [], + allocationPath + ) + return Object.freeze({ + asset_id: requireBoundedString( + allocation.asset_id, + MAX_ASSET_ID_LENGTH, + `${allocationPath}.asset_id`, + true + ), + assignment: requireBoundedString( + allocation.assignment, + MAX_ASSIGNMENT_LENGTH, + `${allocationPath}.assignment` + ), + settled: requireBoolean( + allocation.settled, + `${allocationPath}.settled` + ) + }) + }) + + return Object.freeze({ + utxo: Object.freeze({ + outpoint: requireOutpoint(utxo.outpoint, `${utxoPath}.outpoint`), + btc_amount: requireNonNegativeSafeInteger( + utxo.btc_amount, + Number.MAX_SAFE_INTEGER, + `${utxoPath}.btc_amount` + ), + colorable: requireBoolean(utxo.colorable, `${utxoPath}.colorable`) + }), + rgb_allocations: Object.freeze(rgbAllocations), + pending_blinded: requireNonNegativeSafeInteger( + unspent.pending_blinded, + UINT32_MAX, + `${path}.pending_blinded` + ) + }) + }) + + return Object.freeze(unspents) +} diff --git a/src/wallet-account-read-only-rgb-lightning.js b/src/wallet-account-read-only-rgb-lightning.js index 24855f4..06d2dd1 100644 --- a/src/wallet-account-read-only-rgb-lightning.js +++ b/src/wallet-account-read-only-rgb-lightning.js @@ -7,6 +7,7 @@ import { WalletAccountReadOnly } from '@tetherto/wdk-wallet' import { AccountLockedError } from './errors.js' +import { validateRgbUnspents } from './rgb-utxo-setup-contract.js' /** @typedef {import('@tetherto/wdk-wallet').Transaction} Transaction */ /** @typedef {import('@tetherto/wdk-wallet').TransferOptions} TransferOptions */ @@ -434,10 +435,12 @@ export default class WalletAccountReadOnlyRgbLightning extends WalletAccountRead * * @param {boolean} [skipSync=false] - Whether to skip a network sync before * reading unspent outputs. - * @returns {Promise} The native unspent-output response. + * @returns {Promise>} Validated native unspent outputs. */ async listUnspents (skipSync = false) { - return this._reader.listUnspents(Boolean(skipSync)) + return validateRgbUnspents( + await this._reader.listUnspents(Boolean(skipSync)) + ) } /** diff --git a/tests/rgb-utxo-setup-contract.test.js b/tests/rgb-utxo-setup-contract.test.js index aba5a3c..55a0624 100644 --- a/tests/rgb-utxo-setup-contract.test.js +++ b/tests/rgb-utxo-setup-contract.test.js @@ -1,6 +1,7 @@ import { validateCreateUtxosRequest, - validatePreparedCreateUtxosResponse + validatePreparedCreateUtxosResponse, + validateRgbUnspents } from '../src/rgb-utxo-setup-contract.js' const PLAN_ID = 'ab'.repeat(32) @@ -65,4 +66,83 @@ describe('RGB UTXO setup contract', () => { ])('rejects an invalid prepared response %#', (response) => { expect(() => validatePreparedCreateUtxosResponse(response)).toThrow(TypeError) }) + + it('returns normalized, deeply immutable RGB unspents', () => { + const txid = 'AB'.repeat(32) + const unspents = validateRgbUnspents([{ + utxo: { + outpoint: `${txid}:01`, + btc_amount: 20_000, + colorable: true + }, + rgb_allocations: [{ + asset_id: null, + assignment: 'Blind', + settled: false + }], + pending_blinded: 1 + }]) + + expect(unspents).toEqual([{ + utxo: { + outpoint: `${txid.toLowerCase()}:1`, + btc_amount: 20_000, + colorable: true + }, + rgb_allocations: [{ + asset_id: null, + assignment: 'Blind', + settled: false + }], + pending_blinded: 1 + }]) + expect(Object.isFrozen(unspents)).toBe(true) + expect(Object.isFrozen(unspents[0])).toBe(true) + expect(Object.isFrozen(unspents[0].utxo)).toBe(true) + expect(Object.isFrozen(unspents[0].rgb_allocations)).toBe(true) + expect(Object.isFrozen(unspents[0].rgb_allocations[0])).toBe(true) + }) + + it.each([ + [{ unspents: [] }], + [[{ + utxo: { + outpoint: `${'ab'.repeat(32)}:0`, + btc_amount: 20_000, + colorable: true + }, + rgb_allocations: [], + pending_blinded: 0, + unexpected: true + }]], + [[{ + utxo: { + outpoint: 'not-an-outpoint', + btc_amount: 20_000, + colorable: true + }, + rgb_allocations: [], + pending_blinded: 0 + }]], + [[{ + utxo: { + outpoint: `${'ab'.repeat(32)}:0`, + btc_amount: Number.MAX_SAFE_INTEGER + 1, + colorable: true + }, + rgb_allocations: [], + pending_blinded: 0 + }]], + [[{ + utxo: { + outpoint: `${'ab'.repeat(32)}:0`, + btc_amount: 20_000, + colorable: true + }, + rgb_allocations: [], + pending_blinded: -1 + }]] + ])('rejects invalid RGB unspents %#', (unspents) => { + expect(() => validateRgbUnspents(unspents)).toThrow(TypeError) + }) }) diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index c5ff79f..1c63f46 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -153,7 +153,7 @@ function makeNode (overrides = {}) { }]), listTransactions: jest.fn(() => ({ transactions: [] })), listTransactionsByTxid: jest.fn(() => []), - listUnspents: jest.fn(() => ({ unspents: [] })), + listUnspents: jest.fn(() => []), createUtxos: jest.fn(() => undefined), estimateFee: jest.fn(() => ({ fee_rate: 12 })), sendOnionMessage: jest.fn(() => undefined), @@ -1101,14 +1101,14 @@ describe('BTC ops', () => { }) it('listUnspents forwards to node.listUnspents', async () => { - const listUnspents = jest.fn(() => ({ unspents: [] })) + const listUnspents = jest.fn(() => []) const account = makeAccount({ node: makeNode({ listUnspents }) }) - await expect(account.listUnspents(false)).resolves.toEqual({ unspents: [] }) + await expect(account.listUnspents(false)).resolves.toEqual([]) expect(listUnspents).toHaveBeenCalledWith(false) }) it('listUnspents normalizes skipSync to boolean', async () => { - const listUnspents = jest.fn(() => ({ unspents: [] })) + const listUnspents = jest.fn(() => []) const account = makeAccount({ node: makeNode({ listUnspents }) }) await account.listUnspents(1) const arg = listUnspents.mock.calls[0][0] From fe1509cbecf7450174894c01f18ef7a34a6cde0e Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 30 Jul 2026 04:47:06 +0530 Subject: [PATCH 20/34] feat: add coherent snapshots and native operation control --- index.d.ts | 76 +++++++++++-- src/bare-binding.js | 39 ++++++- src/binding-interface.js | 10 +- src/native-operation.js | 63 +++++++++++ src/node-binding.js | 39 ++++++- src/wallet-account-rgb-lightning.js | 56 ++++++++-- src/wallet-snapshot-contract.js | 118 ++++++++++++++++++--- tests/bare-binding-methods.test.js | 53 +++++++--- tests/native-operation.test.js | 67 ++++++++++++ tests/node-binding-methods.test.js | 84 ++++++++++----- tests/vss-status.test.js | 33 ++++++ tests/wallet-snapshot-contract.test.js | 141 ++++++++++++++++++++----- 12 files changed, 672 insertions(+), 107 deletions(-) create mode 100644 src/native-operation.js create mode 100644 tests/native-operation.test.js diff --git a/index.d.ts b/index.d.ts index 412ee1b..4786999 100644 --- a/index.d.ts +++ b/index.d.ts @@ -27,11 +27,11 @@ export type DecimalString = `${bigint}` export type WalletSyncMode = 'routine' | 'recovery' export type WalletSyncKeychainResult = - | { status: 'succeeded' } + | { status: 'succeeded'; checkpoint: WalletSnapshotNetwork } | { status: 'failed'; error_code: string } export interface WalletSyncResponse { - contract_version: 1 + contract_version: 2 mode: WalletSyncMode vanilla: WalletSyncKeychainResult colored: WalletSyncKeychainResult @@ -49,6 +49,7 @@ export interface WalletSnapshotOptions { export interface WalletSnapshotNetwork { network: Network height: number + block_hash: string } export interface WalletSnapshotBalance { @@ -115,10 +116,18 @@ export interface WalletSnapshotBlockTime { export interface WalletSnapshotTransaction { transaction_type: 'RgbSend' | 'Drain' | 'CreateUtxos' | 'SendBtc' | 'Incoming' + purpose: + | 'incoming_bitcoin' + | 'outgoing_bitcoin' + | 'rgb_anchor' + | 'wallet_drain' + | 'rgb_utxo_maintenance' + direction: 'incoming' | 'outgoing' | 'internal' txid: string received: DecimalString sent: DecimalString fee: DecimalString + external_value: DecimalString | null confirmation_time: WalletSnapshotBlockTime | null } @@ -146,8 +155,8 @@ export interface WalletSnapshotTransfer { created_at: DecimalString updated_at: DecimalString status: string - requested_assignment: string | null - assignments: string[] + requested_assignment: WalletSnapshotRgbAssignment | null + assignments: WalletSnapshotRgbAssignment[] kind: string txid: string | null recipient_id: string | null @@ -157,15 +166,22 @@ export interface WalletSnapshotTransfer { transport_endpoints: WalletSnapshotTransferEndpoint[] } +export interface WalletSnapshotRgbAssignment { + kind: 'Fungible' | 'NonFungible' | 'InflationRight' | 'Any' + amount?: DecimalString +} + export interface WalletSnapshotAssetTransfers { asset_id: string transfers: WalletSnapshotTransfer[] } export interface WalletSnapshotResponse { - contract_version: 1 - native_source: 'rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1' + contract_version: 2 + native_source: 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v2' capture_sequence: DecimalString + capture_attempts: 2 | 3 + stable_capture_count: 2 started_at_ms: DecimalString completed_at_ms: DecimalString network_before: WalletSnapshotNetwork @@ -587,6 +603,29 @@ export interface RgbLightningNodeUnlockRequest { announce_alias: string } +export type NativeOperationState = + | 'queued' + | 'running' + | 'cancel_requested' + | 'succeeded' + | 'failed' + | 'cancelled' + +export interface NativeOperationStatus { + contract_version: 1 + operation_id: string + kind: 'unlock_with_native_external_signer' + state: NativeOperationState + created_at_ms: DecimalString + started_at_ms?: DecimalString + finished_at_ms?: DecimalString + updated_at_ms: DecimalString + cancellation_requested: boolean + can_cancel_immediately: boolean + adoption_count: number + error?: string +} + // ─────────────────────────────────────────────────────────────────── // Bindings (low-level; usually not constructed directly) // ─────────────────────────────────────────────────────────────────── @@ -594,10 +633,17 @@ export interface RgbLightningNodeUnlockRequest { export interface IRgbLightningBinding { ensureNode(): unknown attachExternalSigner(seedHex: string, fallbackSeedHex?: string): void - unlock(unlockRequest: RgbLightningNodeUnlockRequest): void + unlock( + unlockRequest: RgbLightningNodeUnlockRequest, + options?: { signal?: { readonly aborted: boolean } } + ): Promise + unlockOperationStatus(): NativeOperationStatus | null + adoptUnlockOperation(operationId: string): NativeOperationStatus + cancelUnlockOperation(): NativeOperationStatus | null bootstrap(): object clearVssFence(password: string): void vssBackup(): { version: number } + vssDeleteAll(password: string): { deleted_keys: number } vssStatus(): VssStatus apayNew(hostNodeId: string): object shutdown(): void @@ -607,10 +653,14 @@ export class NodeRgbLightningBinding implements IRgbLightningBinding { constructor(config: RgbLightningBindingConfig) ensureNode(): unknown attachExternalSigner(seedHex: string, fallbackSeedHex?: string): void - unlock(unlockRequest: object): void + unlock(unlockRequest: object, options?: { signal?: { readonly aborted: boolean } }): Promise + unlockOperationStatus(): NativeOperationStatus | null + adoptUnlockOperation(operationId: string): NativeOperationStatus + cancelUnlockOperation(): NativeOperationStatus | null bootstrap(): object clearVssFence(password: string): void vssBackup(): { version: number } + vssDeleteAll(password: string): { deleted_keys: number } vssStatus(): VssStatus apayNew(hostNodeId: string): object shutdown(): void @@ -624,10 +674,14 @@ export class BareRgbLightningBinding implements IRgbLightningBinding { constructor(config: RgbLightningBindingConfig) ensureNode(): unknown attachExternalSigner(seedHex: string, fallbackSeedHex?: string): void - unlock(unlockRequest: object): void + unlock(unlockRequest: object, options?: { signal?: { readonly aborted: boolean } }): Promise + unlockOperationStatus(): NativeOperationStatus | null + adoptUnlockOperation(operationId: string): NativeOperationStatus + cancelUnlockOperation(): NativeOperationStatus | null bootstrap(): object clearVssFence(password: string): void vssBackup(): { version: number } + vssDeleteAll(password: string): { deleted_keys: number } vssStatus(): VssStatus apayNew(hostNodeId: string): object shutdown(): void @@ -774,6 +828,9 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning // Lifecycle unlock(unlockRequest: RgbLightningNodeUnlockRequest): Promise<{ ok: true }> + unlockOperationStatus(): Promise + adoptUnlockOperation(operationId: string): Promise + cancelUnlockOperation(): Promise getBootstrap(): Promise shutdown(): Promise<{ ok: true }> @@ -782,6 +839,7 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning clearVssFence(password: string): Promise<{ ok: true }> /** @throws {VssNotConfiguredError} if built without a vssUrl. @throws {VssError} on failure. */ vssBackup(): Promise<{ version: number }> + vssDeleteAll(password: string): Promise<{ deleted_keys: number }> /** Local-view status; does not hit the server. */ vssStatus(): Promise diff --git a/src/bare-binding.js b/src/bare-binding.js index bd90909..0f543f0 100644 --- a/src/bare-binding.js +++ b/src/bare-binding.js @@ -27,6 +27,7 @@ import rln from '@utexo/rgb-lightning-node-bare' import { retainSecret, revealSecret, secretMatches, wipeSecret } from './secret-buffer.js' import { signerStoragePath } from './signer-storage-path.js' +import { validateNativeOperationStatus, waitForNativeOperation } from './native-operation.js' const { SdkNode, @@ -110,6 +111,8 @@ export class BareRgbLightningBinding { this._sdkInitDone = false /** @type {number | null} Snapshot version returned by the most recent vssBackup(). */ this._lastVssVersion = null + /** @type {string | null} Most recent adoptable native unlock operation. */ + this._unlockOperationId = null } /** @@ -172,7 +175,7 @@ export class BareRgbLightningBinding { * @throws {Error} - If no signer is attached or native initialization or * unlock fails. */ - unlock (unlockRequest) { + async unlock (unlockRequest, options = {}) { const node = this.ensureNode() if (!this._signer) { throw new Error('attachExternalSigner(seedHex) must be called before unlock()') @@ -190,7 +193,9 @@ export class BareRgbLightningBinding { this._sdkInitDone = true } try { - node.unlockWithNativeExternalSigner(this._signer, unlockRequest) + const operation = node.startUnlockWithNativeExternalSigner(this._signer, unlockRequest) + this._unlockOperationId = operation.operation_id + await waitForNativeOperation(node, operation, options.signal) wipeSecret(this._fallbackSeedHex) this._fallbackSeedHex = undefined } catch (error) { @@ -223,10 +228,34 @@ export class BareRgbLightningBinding { wipeSecret(this._seedHex) this._seedHex = fallbackSeed this._fallbackSeedHex = undefined - node.unlockWithNativeExternalSigner(this._signer, unlockRequest) + const operation = node.startUnlockWithNativeExternalSigner(this._signer, unlockRequest) + this._unlockOperationId = operation.operation_id + await waitForNativeOperation(node, operation, options.signal) } } + unlockOperationStatus () { + if (!this._unlockOperationId) return null + return validateNativeOperationStatus( + this.ensureNode().nativeOperationStatus(this._unlockOperationId) + ) + } + + adoptUnlockOperation (operationId) { + const status = validateNativeOperationStatus( + this.ensureNode().adoptNativeOperation(operationId) + ) + this._unlockOperationId = status.operation_id + return status + } + + cancelUnlockOperation () { + if (!this._unlockOperationId) return null + return validateNativeOperationStatus( + this.ensureNode().cancelNativeOperation(this._unlockOperationId) + ) + } + /** * Return the bootstrap dictionary for the currently attached signer. * @@ -270,6 +299,10 @@ export class BareRgbLightningBinding { return r } + vssDeleteAll (password) { + return this.ensureNode().vssDeleteAll({ password }) + } + /** * Local-view VSS status. RLN's C-FFI exposes no read-only * server-side backup-info query (unlike rgb-lib's `vssBackupInfo`), diff --git a/src/binding-interface.js b/src/binding-interface.js index b4d1115..3f85407 100644 --- a/src/binding-interface.js +++ b/src/binding-interface.js @@ -72,10 +72,16 @@ * @property {(seedHex: string, fallbackSeedHex?: string) => void} attachExternalSigner - Build * the in-process VLS signer from a host-supplied 32-byte seed. * Must be called before `unlock()`. - * @property {(unlockRequest: object) => void} unlock - Bring the node online. + * @property {(unlockRequest: object, options?: {signal?: AbortSignal}) => Promise} unlock - Bring the node online. * The first call initializes and unlocks a fresh data directory. Later calls * treat the expected init `Rln(Conflict)` as already initialized and proceed * with unlock. + * @property {() => object|null} unlockOperationStatus - Read the most recent + * native unlock operation without starting another operation. + * @property {(operationId: string) => object} adoptUnlockOperation - Adopt an + * existing native unlock operation owned by this node. + * @property {() => object|null} cancelUnlockOperation - Request cancellation + * of the most recent native unlock operation. * @property {() => object} bootstrap - Return the signer's bootstrap payload * (`node_id`, xpubs, and master fingerprint). * @property {(password: string) => void} clearVssFence - Forcibly take over a @@ -88,6 +94,8 @@ * controlled checkpoints (e.g. "save state before app suspend") rather than * relying on the implicit on-write flush. Requires configured VSS and a * successful server flush. + * @property {(password: string) => {deleted_keys: number}} vssDeleteAll - + * Permanently delete and verify the authenticated remote VSS store. * @property {() => { configured: boolean, url: string|null, allowHttp: boolean, lastBackupVersion: number|null }} vssStatus - Return * local-view VSS status without a server round-trip: whether VSS was * configured at construction, the URL + allow-http flag, and the diff --git a/src/native-operation.js b/src/native-operation.js new file mode 100644 index 0000000..67088a1 --- /dev/null +++ b/src/native-operation.js @@ -0,0 +1,63 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +'use strict' + +const POLL_INTERVAL_MS = 100 +const TERMINAL_STATES = new Set(['succeeded', 'failed', 'cancelled']) +const KNOWN_STATES = new Set([ + 'queued', + 'running', + 'cancel_requested', + ...TERMINAL_STATES +]) + +function sleep (durationMs) { + return new Promise(resolve => setTimeout(resolve, durationMs)) +} + +function assertStatus (status) { + if (!status || typeof status !== 'object') { + throw new Error('Native operation status must be an object') + } + if (status.contract_version !== 1) { + throw new Error('Unsupported native operation contract version') + } + if (typeof status.operation_id !== 'string' || status.operation_id.length === 0) { + throw new Error('Native operation status is missing operation_id') + } + if (!KNOWN_STATES.has(status.state)) { + throw new Error(`Unknown native operation state: ${String(status.state)}`) + } + return status +} + +/** + * Poll an adoptable native operation until its native terminal state is known. + * Cancellation never invents an early terminal result: a running operation + * remains `cancel_requested` until the native worker actually exits. + */ +export async function waitForNativeOperation (node, initialStatus, signal) { + let status = assertStatus(initialStatus) + + while (!TERMINAL_STATES.has(status.state)) { + if (signal?.aborted && !status.cancellation_requested) { + status = assertStatus(node.cancelNativeOperation(status.operation_id)) + } + if (!TERMINAL_STATES.has(status.state)) { + await sleep(POLL_INTERVAL_MS) + status = assertStatus(node.nativeOperationStatus(status.operation_id)) + } + } + + if (status.state === 'succeeded') return status + if (status.state === 'cancelled') { + throw new Error('Native operation was cancelled before it started') + } + throw new Error(status.error || 'Native operation failed without an error') +} + +export function validateNativeOperationStatus (status) { + return Object.freeze({ ...assertStatus(status) }) +} diff --git a/src/node-binding.js b/src/node-binding.js index a761952..dce6d54 100644 --- a/src/node-binding.js +++ b/src/node-binding.js @@ -13,6 +13,7 @@ import rln from '@utexo/rgb-lightning-node-nodejs' import { retainSecret, revealSecret, secretMatches, wipeSecret } from './secret-buffer.js' import { signerStoragePath } from './signer-storage-path.js' +import { validateNativeOperationStatus, waitForNativeOperation } from './native-operation.js' const { SdkNode, @@ -87,6 +88,8 @@ export class NodeRgbLightningBinding { this._sdkInitDone = false /** @type {number | null} Snapshot version returned by the most recent vssBackup(). */ this._lastVssVersion = null + /** @type {string | null} Most recent adoptable native unlock operation. */ + this._unlockOperationId = null } /** @@ -149,7 +152,7 @@ export class NodeRgbLightningBinding { * @throws {Error} - If no signer is attached or native initialization or * unlock fails. */ - unlock (unlockRequest) { + async unlock (unlockRequest, options = {}) { const node = this.ensureNode() if (!this._signer) { throw new Error('attachExternalSigner(seedHex) must be called before unlock()') @@ -164,7 +167,9 @@ export class NodeRgbLightningBinding { this._sdkInitDone = true } try { - node.unlockWithNativeExternalSigner(this._signer, unlockRequest) + const operation = node.startUnlockWithNativeExternalSigner(this._signer, unlockRequest) + this._unlockOperationId = operation.operation_id + await waitForNativeOperation(node, operation, options.signal) wipeSecret(this._fallbackSeedHex) this._fallbackSeedHex = undefined } catch (error) { @@ -197,10 +202,34 @@ export class NodeRgbLightningBinding { wipeSecret(this._seedHex) this._seedHex = fallbackSeed this._fallbackSeedHex = undefined - node.unlockWithNativeExternalSigner(this._signer, unlockRequest) + const operation = node.startUnlockWithNativeExternalSigner(this._signer, unlockRequest) + this._unlockOperationId = operation.operation_id + await waitForNativeOperation(node, operation, options.signal) } } + unlockOperationStatus () { + if (!this._unlockOperationId) return null + return validateNativeOperationStatus( + this.ensureNode().nativeOperationStatus(this._unlockOperationId) + ) + } + + adoptUnlockOperation (operationId) { + const status = validateNativeOperationStatus( + this.ensureNode().adoptNativeOperation(operationId) + ) + this._unlockOperationId = status.operation_id + return status + } + + cancelUnlockOperation () { + if (!this._unlockOperationId) return null + return validateNativeOperationStatus( + this.ensureNode().cancelNativeOperation(this._unlockOperationId) + ) + } + /** * Return the bootstrap dictionary for the currently attached signer. * @@ -244,6 +273,10 @@ export class NodeRgbLightningBinding { return r } + vssDeleteAll (password) { + return this.ensureNode().vssDeleteAll({ password }) + } + /** * Local-view VSS status. RLN's C-FFI exposes no read-only * server-side backup-info query (unlike rgb-lib's `vssBackupInfo`), diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index ca7c19b..b2a48ee 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -40,6 +40,7 @@ import { WalletSnapshotContractError, isCoherentWalletSnapshot, normalizeWalletSnapshotOptions, + snapshotMatchesWalletSync, validateWalletSnapshotResponse, validateWalletSyncResponse, walletSnapshotRequestKey @@ -165,16 +166,16 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL return this._unlockInFlight.promise } - const operation = Promise.resolve().then(() => { + const operation = Promise.resolve().then(async () => { try { - this._binding.unlock(unlockRequest) + await this._binding.unlock(unlockRequest) } catch (e) { if (this._autoRecoverStaleVssFence && isStaleVssFenceError(e)) { const password = vssFenceClearPassword(unlockRequest) if (password) { try { this._binding.clearVssFence(password) - this._binding.unlock(unlockRequest) + await this._binding.unlock(unlockRequest) return { ok: true } } catch (recoveryError) { throw wrapError(recoveryError, UnlockError) @@ -201,6 +202,18 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } } + async unlockOperationStatus () { + return this._binding.unlockOperationStatus() + } + + async adoptUnlockOperation (operationId) { + return this._binding.adoptUnlockOperation(operationId) + } + + async cancelUnlockOperation () { + return this._binding.cancelUnlockOperation() + } + /** * WDK React Native Core discovers an account by loading its address before * exposing extension methods. When explicitly configured, activate the full @@ -293,6 +306,22 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } } + /** + * Permanently delete every object in this wallet's authenticated VSS store. + * Native shutdown and an empty-store verification happen before success. + * + * @param {string} password + * @returns {Promise<{deleted_keys: number}>} + */ + async vssDeleteAll (password) { + this._assertVssConfigured() + try { + return this._binding.vssDeleteAll(password) + } catch (e) { + throw wrapError(e, VssError) + } + } + /** * Register this node with an LSP as an async-payments (APay) recipient. * Used for offline-receive over Lightning Address — the wallet uploads @@ -530,7 +559,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL typeof node.walletSnapshot !== 'function' ) { throw new WalletSnapshotError( - 'The installed RGB Lightning native binding does not support wallet snapshot contract v1.', + `The installed RGB Lightning native binding does not support wallet snapshot contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}.`, { code: 'WALLET_SNAPSHOT_UNSUPPORTED_BINDING' } ) } @@ -538,7 +567,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL let sync = await this._synchronizeWalletForSnapshot(node, options.mode) const first = await this._captureWalletSnapshot(node, options) - if (isCoherentWalletSnapshot(first)) { + if (snapshotMatchesWalletSync(first, sync)) { return Object.freeze({ contractVersion: WALLET_SNAPSHOT_CONTRACT_VERSION, sync, @@ -582,6 +611,19 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } ) } + if (!snapshotMatchesWalletSync(retry, sync)) { + throw new WalletSnapshotError( + 'The synchronized keychains and native financial snapshot do not share one chain checkpoint.', + { + code: 'WALLET_SNAPSHOT_SYNC_CHECKPOINT_MISMATCH', + details: Object.freeze({ + snapshot: retry.network_before, + vanilla: sync.vanilla.checkpoint, + colored: sync.colored.checkpoint + }) + } + ) + } return Object.freeze({ contractVersion: WALLET_SNAPSHOT_CONTRACT_VERSION, @@ -602,7 +644,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL const contractFailure = error instanceof WalletSnapshotContractError throw new WalletSyncError( contractFailure - ? `The native wallet sync response does not match contract v1: ${error.message}.` + ? `The native wallet sync response does not match contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}: ${error.message}.` : 'The native wallet synchronization failed.', { code: contractFailure @@ -664,7 +706,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL const contractFailure = error instanceof WalletSnapshotContractError throw new WalletSnapshotError( contractFailure - ? `The native wallet snapshot does not match contract v1: ${error.message}.` + ? `The native wallet snapshot does not match contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}: ${error.message}.` : 'The native wallet snapshot could not be captured.', { code: contractFailure diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js index 8be4c8a..fad9054 100644 --- a/src/wallet-snapshot-contract.js +++ b/src/wallet-snapshot-contract.js @@ -4,8 +4,8 @@ // you may not use this file except in compliance with the License. 'use strict' -export const WALLET_SNAPSHOT_CONTRACT_VERSION = 1 -export const WALLET_SNAPSHOT_NATIVE_SOURCE = 'rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1' +export const WALLET_SNAPSHOT_CONTRACT_VERSION = 2 +export const WALLET_SNAPSHOT_NATIVE_SOURCE = 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v2' const NATIVE_LIMITS = Object.freeze({ assets: 128, @@ -53,7 +53,9 @@ function exactKeys (value, required, optional, path) { if (!HAS_OWN(value, key)) fail(`${path}.${key}`, 'is required') } for (const key of Object.keys(value)) { - if (!allowed.has(key)) fail(`${path}.${key}`, 'is not part of contract v1') + if (!allowed.has(key)) { + fail(`${path}.${key}`, `is not part of contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}`) + } } } @@ -182,14 +184,21 @@ export function normalizeWalletSnapshotOptions (value) { function syncKeychain (value, path) { const item = record(value, path) - exactKeys(item, ['status'], ['error_code'], path) + exactKeys(item, ['status'], ['error_code', 'checkpoint'], path) oneOf(item.status, ['succeeded', 'failed'], `${path}.status`) if (item.status === 'succeeded' && HAS_OWN(item, 'error_code')) { fail(`${path}.error_code`, 'must be omitted after a successful sync') } + if (item.status === 'succeeded' && !HAS_OWN(item, 'checkpoint')) { + fail(`${path}.checkpoint`, 'is required after a successful sync') + } if (item.status === 'failed') { text(item.error_code, `${path}.error_code`, 128) + if (HAS_OWN(item, 'checkpoint')) { + fail(`${path}.checkpoint`, 'must be omitted after a failed sync') + } } + if (HAS_OWN(item, 'checkpoint')) network(item.checkpoint, `${path}.checkpoint`) } export function validateWalletSyncResponse (value, expectedMode) { @@ -201,14 +210,28 @@ export function validateWalletSyncResponse (value, expectedMode) { if (response.mode !== expectedMode) fail('sync.mode', `must equal ${expectedMode}`) syncKeychain(response.vanilla, 'sync.vanilla') syncKeychain(response.colored, 'sync.colored') + if ( + response.vanilla.status === 'succeeded' && + response.colored.status === 'succeeded' && + ( + response.vanilla.checkpoint.network !== response.colored.checkpoint.network || + response.vanilla.checkpoint.height !== response.colored.checkpoint.height || + response.vanilla.checkpoint.block_hash !== response.colored.checkpoint.block_hash + ) + ) { + fail('sync.colored.checkpoint', 'must match the vanilla keychain checkpoint') + } return deepFreeze(response) } function network (value, path) { const item = record(value, path) - exactKeys(item, ['network', 'height'], [], path) + exactKeys(item, ['network', 'height', 'block_hash'], [], path) oneOf(item.network, CANONICAL_NETWORKS, `${path}.network`) integer(item.height, `${path}.height`, 0, 0xffffffff) + if (typeof item.block_hash !== 'string' || !/^[0-9a-f]{64}$/.test(item.block_hash)) { + fail(`${path}.block_hash`, 'must be a lowercase 32-byte hexadecimal block hash') + } } function canonicalNetworkName (value) { @@ -308,13 +331,52 @@ function blockTime (value, path) { function snapshotTransaction (value, path) { const item = record(value, path) - exactKeys(item, ['transaction_type', 'txid', 'received', 'sent', 'fee', 'confirmation_time'], [], path) + exactKeys(item, [ + 'transaction_type', 'purpose', 'direction', 'txid', 'received', 'sent', + 'fee', 'external_value', 'confirmation_time' + ], [], path) oneOf(item.transaction_type, ['RgbSend', 'Drain', 'CreateUtxos', 'SendBtc', 'Incoming'], `${path}.transaction_type`) + oneOf(item.purpose, [ + 'incoming_bitcoin', + 'outgoing_bitcoin', + 'rgb_anchor', + 'wallet_drain', + 'rgb_utxo_maintenance' + ], `${path}.purpose`) + oneOf(item.direction, ['incoming', 'outgoing', 'internal'], `${path}.direction`) text(item.txid, `${path}.txid`, 128) decimal(item.received, `${path}.received`) decimal(item.sent, `${path}.sent`) decimal(item.fee, `${path}.fee`) + nullableDecimal(item.external_value, `${path}.external_value`) blockTime(item.confirmation_time, `${path}.confirmation_time`) + const taxonomy = { + Incoming: ['incoming_bitcoin', 'incoming'], + SendBtc: ['outgoing_bitcoin', 'outgoing'], + RgbSend: ['rgb_anchor', 'internal'], + Drain: ['wallet_drain', 'internal'], + CreateUtxos: ['rgb_utxo_maintenance', 'internal'] + }[item.transaction_type] + if (item.purpose !== taxonomy[0]) { + fail(`${path}.purpose`, `must equal ${taxonomy[0]} for ${item.transaction_type}`) + } + if (item.direction !== taxonomy[1]) { + fail(`${path}.direction`, `must equal ${taxonomy[1]} for ${item.transaction_type}`) + } + const received = BigInt(item.received) + const sent = BigInt(item.sent) + const fee = BigInt(item.fee) + const expectedExternalValue = item.direction === 'incoming' + ? received >= sent ? received - sent : null + : item.direction === 'outgoing' + ? sent >= received + fee ? sent - received - fee : null + : null + if ( + (expectedExternalValue === null && item.external_value !== null) || + (expectedExternalValue !== null && item.external_value !== expectedExternalValue.toString()) + ) { + fail(`${path}.external_value`, 'must equal the external wallet movement') + } } function snapshotPayment (value, path) { @@ -356,9 +418,11 @@ function snapshotTransfer (value, path) { decimal(item.created_at, `${path}.created_at`) decimal(item.updated_at, `${path}.updated_at`) text(item.status, `${path}.status`, 64) - nullableText(item.requested_assignment, `${path}.requested_assignment`, 1024) + if (item.requested_assignment !== null) { + rgbAssignment(item.requested_assignment, `${path}.requested_assignment`) + } array(item.assignments, `${path}.assignments`, 1024).forEach((entry, index) => { - text(entry, `${path}.assignments[${index}]`, 1024) + rgbAssignment(entry, `${path}.assignments[${index}]`) }) text(item.kind, `${path}.kind`, 64) nullableText(item.txid, `${path}.txid`, 128) @@ -371,6 +435,20 @@ function snapshotTransfer (value, path) { }) } +function rgbAssignment (value, path) { + const item = record(value, path) + exactKeys(item, ['kind'], ['amount'], path) + oneOf(item.kind, ['Fungible', 'NonFungible', 'InflationRight', 'Any'], `${path}.kind`) + const amountRequired = item.kind === 'Fungible' || item.kind === 'InflationRight' + if (amountRequired && !HAS_OWN(item, 'amount')) { + fail(`${path}.amount`, `is required for ${item.kind}`) + } + if (!amountRequired && HAS_OWN(item, 'amount')) { + fail(`${path}.amount`, `must be omitted for ${item.kind}`) + } + if (HAS_OWN(item, 'amount')) decimal(item.amount, `${path}.amount`) +} + function snapshotTransfers (value, path, options) { const item = record(value, path) exactKeys(item, ['asset_id', 'transfers'], [], path) @@ -396,9 +474,9 @@ export function validateWalletSnapshotResponse (value, options) { // strict contract validation below still rejects every unknown value. const snapshot = record(normalizeLegacyNetworkNames(value), 'snapshot') const required = [ - 'contract_version', 'native_source', 'capture_sequence', 'started_at_ms', - 'completed_at_ms', 'network_before', 'network_after', 'node', 'btc', - 'assets', 'channels' + 'contract_version', 'native_source', 'capture_sequence', 'capture_attempts', + 'stable_capture_count', 'started_at_ms', 'completed_at_ms', + 'network_before', 'network_after', 'node', 'btc', 'assets', 'channels' ] const optional = ['transactions', 'payments', 'transfers'] exactKeys(snapshot, required, optional, 'snapshot') @@ -410,6 +488,10 @@ export function validateWalletSnapshotResponse (value, options) { } decimal(snapshot.capture_sequence, 'snapshot.capture_sequence') if (BigInt(snapshot.capture_sequence) === 0n) fail('snapshot.capture_sequence', 'must be greater than zero') + integer(snapshot.capture_attempts, 'snapshot.capture_attempts', 2, 3) + if (snapshot.stable_capture_count !== 2) { + fail('snapshot.stable_capture_count', 'must equal 2') + } decimal(snapshot.started_at_ms, 'snapshot.started_at_ms') decimal(snapshot.completed_at_ms, 'snapshot.completed_at_ms') if (BigInt(snapshot.completed_at_ms) < BigInt(snapshot.started_at_ms)) { @@ -458,7 +540,19 @@ export function validateWalletSnapshotResponse (value, options) { export function isCoherentWalletSnapshot (snapshot) { return snapshot.network_before.network === snapshot.network_after.network && - snapshot.network_before.height === snapshot.network_after.height + snapshot.network_before.height === snapshot.network_after.height && + snapshot.network_before.block_hash === snapshot.network_after.block_hash && + snapshot.stable_capture_count === 2 +} + +export function snapshotMatchesWalletSync (snapshot, sync) { + if (!isCoherentWalletSnapshot(snapshot)) return false + return ['vanilla', 'colored'].every(keychain => { + const checkpoint = sync[keychain]?.checkpoint + return checkpoint?.network === snapshot.network_before.network && + checkpoint.height === snapshot.network_before.height && + checkpoint.block_hash === snapshot.network_before.block_hash + }) } export function walletSnapshotRequestKey (options) { diff --git a/tests/bare-binding-methods.test.js b/tests/bare-binding-methods.test.js index 096175f..e0e145a 100644 --- a/tests/bare-binding-methods.test.js +++ b/tests/bare-binding-methods.test.js @@ -12,14 +12,31 @@ function makeBinding (overrides = {}) { } function fakeNode () { - return { + let operationSequence = 0 + const node = { initWithNativeExternalSigner: jest.fn(), unlockWithNativeExternalSigner: jest.fn(), vssClearFence: jest.fn(), vssBackup: jest.fn(() => ({ version: 7 })), + vssDeleteAll: jest.fn(() => ({ deleted_keys: 12 })), apayNew: jest.fn(() => ({ order_id: 'order-1' })), shutdown: jest.fn() } + node.startUnlockWithNativeExternalSigner = jest.fn((signer, request) => { + node.unlockWithNativeExternalSigner(signer, request) + operationSequence += 1 + return { + contract_version: 1, + operation_id: `operation-${operationSequence}`, + kind: 'unlock', + state: 'succeeded', + cancellation_requested: false + } + }) + node.nativeOperationStatus = jest.fn() + node.adoptNativeOperation = jest.fn() + node.cancelNativeOperation = jest.fn() + return node } function fakeSigner () { @@ -96,15 +113,15 @@ describe('BareRgbLightningBinding', () => { expect(externallyAttached._seedHex).toBeUndefined() }) - it('requires a signer before unlock and bootstrap', () => { + it('requires a signer before unlock and bootstrap', async () => { const binding = makeBinding() binding._node = fakeNode() - expect(() => binding.unlock({})).toThrow('attachExternalSigner') + await expect(binding.unlock({})).rejects.toThrow('attachExternalSigner') expect(() => binding.bootstrap()).toThrow('attachExternalSigner') }) - it('initializes once and wipes an unused fallback after primary unlock', () => { + it('initializes once and wipes an unused fallback after primary unlock', async () => { const binding = makeBinding() const node = fakeNode() const signer = fakeSigner() @@ -113,8 +130,8 @@ describe('BareRgbLightningBinding', () => { binding._signer = signer binding._fallbackSeedHex = fallbackSeed - binding.unlock({ rpc: true }) - binding.unlock({ rpc: true }) + await binding.unlock({ rpc: true }) + await binding.unlock({ rpc: true }) expect(node.initWithNativeExternalSigner).toHaveBeenCalledTimes(1) expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(2) @@ -122,7 +139,7 @@ describe('BareRgbLightningBinding', () => { expect(fallbackSeed.every((byte) => byte === 0)).toBe(true) }) - it('accepts an existing SDK init but rethrows unrelated init failures', () => { + it('accepts an existing SDK init but rethrows unrelated init failures', async () => { const existing = makeBinding() const existingNode = fakeNode() existingNode.initWithNativeExternalSigner.mockImplementation(() => { @@ -131,7 +148,7 @@ describe('BareRgbLightningBinding', () => { existing._node = existingNode existing._signer = fakeSigner() - expect(() => existing.unlock({})).not.toThrow() + await expect(existing.unlock({})).resolves.toBeUndefined() expect(existingNode.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) const failing = makeBinding() @@ -141,12 +158,12 @@ describe('BareRgbLightningBinding', () => { failing._node = failingNode failing._signer = fakeSigner() - expect(() => failing.unlock({})).toThrow('init failed') + await expect(failing.unlock({})).rejects.toBe('init failed') expect(failingNode.unlockWithNativeExternalSigner).not.toHaveBeenCalled() expect(failing._sdkInitDone).toBe(false) }) - it('replaces a mismatched primary signer with the legacy signer', () => { + it('replaces a mismatched primary signer with the legacy signer', async () => { const binding = makeBinding() const node = fakeNode() const primarySigner = fakeSigner() @@ -165,7 +182,7 @@ describe('BareRgbLightningBinding', () => { binding._seedHex = primarySeed binding._fallbackSeedHex = fallbackSeed - binding.unlock({ rpc: true }) + await binding.unlock({ rpc: true }) expect(primarySigner.destroy).toHaveBeenCalledTimes(1) expect(createSpy).toHaveBeenCalledWith( @@ -181,7 +198,7 @@ describe('BareRgbLightningBinding', () => { expect(node.unlockWithNativeExternalSigner).toHaveBeenLastCalledWith(fallbackSigner, { rpc: true }) }) - it('destroys the fallback signer if the primary signer cannot be released', () => { + it('destroys the fallback signer if the primary signer cannot be released', async () => { const binding = makeBinding() const node = fakeNode() const primarySigner = fakeSigner() @@ -199,7 +216,7 @@ describe('BareRgbLightningBinding', () => { binding._seedHex = primarySeed binding._fallbackSeedHex = fallbackSeed - expect(() => binding.unlock({})).toThrow(destroyError) + await expect(binding.unlock({})).rejects.toBe(destroyError) expect(fallbackSigner.destroy).toHaveBeenCalledTimes(1) expect(binding._signer).toBe(primarySigner) expect(binding._seedHex).toBe(primarySeed) @@ -207,7 +224,7 @@ describe('BareRgbLightningBinding', () => { expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) }) - it('does not replace the signer for an unrelated unlock failure', () => { + it('does not replace the signer for an unrelated unlock failure', async () => { const binding = makeBinding() const node = fakeNode() const signer = fakeSigner() @@ -217,12 +234,12 @@ describe('BareRgbLightningBinding', () => { binding._signer = signer binding._fallbackSeedHex = Buffer.from('seed-v1') - expect(() => binding.unlock({})).toThrow('backend unavailable') + await expect(binding.unlock({})).rejects.toBe('backend unavailable') expect(binding._signer).toBe(signer) expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) }) - it('reports both signer cleanup failures without replacing binding state', () => { + it('reports both signer cleanup failures without replacing binding state', async () => { const binding = makeBinding() const node = fakeNode() const primarySigner = fakeSigner() @@ -242,7 +259,7 @@ describe('BareRgbLightningBinding', () => { let thrown try { - binding.unlock({}) + await binding.unlock({}) } catch (error) { thrown = error } @@ -263,6 +280,8 @@ describe('BareRgbLightningBinding', () => { expect(binding.bootstrap()).toEqual({ node_id: '03beef' }) binding.clearVssFence('pw') expect(binding.vssBackup()).toEqual({ version: 7 }) + expect(binding.vssDeleteAll('pw')).toEqual({ deleted_keys: 12 }) + expect(node.vssDeleteAll).toHaveBeenCalledWith({ password: 'pw' }) expect(binding.vssStatus()).toEqual({ configured: true, url: 'https://vss.example', diff --git a/tests/native-operation.test.js b/tests/native-operation.test.js new file mode 100644 index 0000000..4e911d7 --- /dev/null +++ b/tests/native-operation.test.js @@ -0,0 +1,67 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. + +import { jest } from '@jest/globals' + +import { + validateNativeOperationStatus, + waitForNativeOperation +} from '../src/native-operation.js' + +function status (state, overrides = {}) { + return { + contract_version: 1, + operation_id: 'operation-1', + kind: 'unlock_with_native_external_signer', + state, + cancellation_requested: state === 'cancel_requested', + ...overrides + } +} + +describe('native operation lifecycle', () => { + it('polls an adoptable operation until native success', async () => { + const node = { + nativeOperationStatus: jest.fn() + .mockReturnValueOnce(status('running')) + .mockReturnValueOnce(status('succeeded')), + cancelNativeOperation: jest.fn() + } + + await expect(waitForNativeOperation(node, status('queued'))) + .resolves.toMatchObject({ state: 'succeeded' }) + expect(node.nativeOperationStatus).toHaveBeenCalledTimes(2) + expect(node.cancelNativeOperation).not.toHaveBeenCalled() + }) + + it('requests cancellation once and waits for the native terminal state', async () => { + const signal = { aborted: true } + const node = { + cancelNativeOperation: jest.fn(() => status('cancel_requested')), + nativeOperationStatus: jest.fn(() => status('cancelled', { + cancellation_requested: true + })) + } + + await expect(waitForNativeOperation(node, status('running'), signal)) + .rejects.toThrow('cancelled before it started') + expect(node.cancelNativeOperation).toHaveBeenCalledTimes(1) + expect(node.nativeOperationStatus).toHaveBeenCalledTimes(1) + }) + + it('surfaces the sanitized native terminal failure', async () => { + await expect(waitForNativeOperation({}, status('failed', { + error: 'INDEXER_UNAVAILABLE' + }))).rejects.toThrow('INDEXER_UNAVAILABLE') + }) + + it('rejects unknown states and unsupported contract versions', () => { + expect(() => validateNativeOperationStatus(status('unknown'))) + .toThrow('Unknown native operation state') + expect(() => validateNativeOperationStatus(status('queued', { + contract_version: 2 + }))).toThrow('Unsupported native operation contract version') + }) +}) diff --git a/tests/node-binding-methods.test.js b/tests/node-binding-methods.test.js index a2bfade..d054688 100644 --- a/tests/node-binding-methods.test.js +++ b/tests/node-binding-methods.test.js @@ -46,14 +46,31 @@ function realBootstrapPayload () { } function fakeNode () { - return { + let operationSequence = 0 + const node = { initWithNativeExternalSigner: jest.fn(), unlockWithNativeExternalSigner: jest.fn(), vssClearFence: jest.fn(), vssBackup: jest.fn(() => ({ version: 7 })), + vssDeleteAll: jest.fn(() => ({ deleted_keys: 12 })), apayNew: jest.fn(() => realAsyncOrderNewResponse()), shutdown: jest.fn() } + node.startUnlockWithNativeExternalSigner = jest.fn((signer, request) => { + node.unlockWithNativeExternalSigner(signer, request) + operationSequence += 1 + return { + contract_version: 1, + operation_id: `operation-${operationSequence}`, + kind: 'unlock', + state: 'succeeded', + cancellation_requested: false + } + }) + node.nativeOperationStatus = jest.fn() + node.adoptNativeOperation = jest.fn() + node.cancelNativeOperation = jest.fn() + return node } function fakeSigner () { @@ -177,13 +194,13 @@ describe('attachExternalSigner', () => { }) describe('unlock', () => { - it('throws when no signer has been attached', () => { + it('rejects when no signer has been attached', async () => { const b = makeBinding() b._node = fakeNode() - expect(() => b.unlock({})).toThrow('attachExternalSigner') + await expect(b.unlock({})).rejects.toThrow('attachExternalSigner') }) - it('runs init then unlock on the first call and sets _sdkInitDone', () => { + it('runs init then unlock on the first call and sets _sdkInitDone', async () => { const b = makeBinding() const node = fakeNode() const signer = fakeSigner() @@ -192,7 +209,7 @@ describe('unlock', () => { const fallbackSeed = Buffer.from('seed-v1') b._fallbackSeedHex = fallbackSeed const req = { mnemonic: 'm' } - b.unlock(req) + await b.unlock(req) expect(node.initWithNativeExternalSigner).toHaveBeenCalledWith(signer) expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledWith(signer, req) expect(b._sdkInitDone).toBe(true) @@ -200,40 +217,40 @@ describe('unlock', () => { expect(fallbackSeed.every((byte) => byte === 0)).toBe(true) }) - it('swallows a Conflict init error and still proceeds to unlock', () => { + it('swallows a Conflict init error and still proceeds to unlock', async () => { const b = makeBinding() const node = fakeNode() node.initWithNativeExternalSigner.mockImplementation(() => { throw new Error('Conflict: already initialized') }) b._node = node b._signer = fakeSigner() - expect(() => b.unlock({})).not.toThrow() + await expect(b.unlock({})).resolves.toBeUndefined() expect(node.unlockWithNativeExternalSigner).toHaveBeenCalled() expect(b._sdkInitDone).toBe(true) }) - it('rethrows a non-Conflict init error and does not unlock', () => { + it('rethrows a non-Conflict init error and does not unlock', async () => { const b = makeBinding() const node = fakeNode() node.initWithNativeExternalSigner.mockImplementation(() => { throw new Error('boom') }) b._node = node b._signer = fakeSigner() - expect(() => b.unlock({})).toThrow('boom') + await expect(b.unlock({})).rejects.toThrow('boom') expect(node.unlockWithNativeExternalSigner).not.toHaveBeenCalled() expect(b._sdkInitDone).toBe(false) }) - it('skips init on a second unlock once _sdkInitDone is set', () => { + it('skips init on a second unlock once _sdkInitDone is set', async () => { const b = makeBinding() const node = fakeNode() b._node = node b._signer = fakeSigner() - b.unlock({}) - b.unlock({}) + await b.unlock({}) + await b.unlock({}) expect(node.initWithNativeExternalSigner).toHaveBeenCalledTimes(1) expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(2) }) - it('retries with the legacy signer only for a persisted identity mismatch', () => { + it('retries with the legacy signer only for a persisted identity mismatch', async () => { const b = makeBinding() const node = fakeNode() const primarySigner = fakeSigner() @@ -252,7 +269,7 @@ describe('unlock', () => { b._seedHex = primarySeed b._fallbackSeedHex = fallbackSeed try { - expect(() => b.unlock({ rpc: true })).not.toThrow() + await expect(b.unlock({ rpc: true })).resolves.toBeUndefined() expect(primarySigner.destroy).toHaveBeenCalledTimes(1) expect(createSpy).toHaveBeenCalledWith( 'seed-v1', @@ -270,7 +287,7 @@ describe('unlock', () => { } }) - it('destroys a newly created fallback signer when the primary signer cannot be released', () => { + it('destroys a newly created fallback signer when the primary signer cannot be released', async () => { const b = makeBinding() const node = fakeNode() const primarySigner = fakeSigner() @@ -289,7 +306,7 @@ describe('unlock', () => { b._seedHex = primarySeed b._fallbackSeedHex = fallbackSeed try { - expect(() => b.unlock({})).toThrow(destroyError) + await expect(b.unlock({})).rejects.toBe(destroyError) expect(fallbackSigner.destroy).toHaveBeenCalledTimes(1) expect(b._signer).toBe(primarySigner) expect(b._seedHex).toBe(primarySeed) @@ -300,7 +317,7 @@ describe('unlock', () => { } }) - it('reports both native cleanup failures during fallback replacement', () => { + it('reports both native cleanup failures during fallback replacement', async () => { const b = makeBinding() const node = fakeNode() const primarySigner = fakeSigner() @@ -321,7 +338,7 @@ describe('unlock', () => { try { let thrown try { - b.unlock({}) + await b.unlock({}) } catch (error) { thrown = error } @@ -335,18 +352,18 @@ describe('unlock', () => { } }) - it('does not retry a generic unlock failure with the legacy signer', () => { + it('does not retry a generic unlock failure with the legacy signer', async () => { const b = makeBinding() const node = fakeNode() node.unlockWithNativeExternalSigner.mockImplementation(() => { throw new Error('backend unavailable') }) b._node = node b._signer = fakeSigner() b._fallbackSeedHex = Buffer.from('seed-v1') - expect(() => b.unlock({})).toThrow('backend unavailable') + await expect(b.unlock({})).rejects.toThrow('backend unavailable') expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) }) - it('does not retry an identity mismatch when no fallback seed is available', () => { + it('does not retry an identity mismatch when no fallback seed is available', async () => { const b = makeBinding() const node = fakeNode() node.unlockWithNativeExternalSigner.mockImplementation(() => { @@ -354,11 +371,11 @@ describe('unlock', () => { }) b._node = node b._signer = fakeSigner() - expect(() => b.unlock({})).toThrow('ExternalSignerMismatch') + await expect(b.unlock({})).rejects.toThrow('ExternalSignerMismatch') expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) }) - it('does not retry a thrown-string unlock failure with the legacy signer', () => { + it('does not retry a thrown-string unlock failure with the legacy signer', async () => { const b = makeBinding() const node = fakeNode() // eslint-disable-next-line no-throw-literal @@ -366,30 +383,30 @@ describe('unlock', () => { b._node = node b._signer = fakeSigner() b._fallbackSeedHex = Buffer.from('seed-v1') - expect(() => b.unlock({})).toThrow('backend unavailable') + await expect(b.unlock({})).rejects.toBe('backend unavailable') expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) }) - it('swallows a thrown string containing Conflict (no .message) and proceeds', () => { + it('swallows a thrown string containing Conflict (no .message) and proceeds', async () => { const b = makeBinding() const node = fakeNode() // eslint-disable-next-line no-throw-literal node.initWithNativeExternalSigner.mockImplementation(() => { throw 'Conflict: already initialized' }) b._node = node b._signer = fakeSigner() - expect(() => b.unlock({})).not.toThrow() + await expect(b.unlock({})).resolves.toBeUndefined() expect(node.unlockWithNativeExternalSigner).toHaveBeenCalledTimes(1) expect(b._sdkInitDone).toBe(true) }) - it('rethrows a thrown string without Conflict (no .message) and does not unlock', () => { + it('rethrows a thrown string without Conflict (no .message) and does not unlock', async () => { const b = makeBinding() const node = fakeNode() // eslint-disable-next-line no-throw-literal node.initWithNativeExternalSigner.mockImplementation(() => { throw 'plain boom' }) b._node = node b._signer = fakeSigner() - expect(() => b.unlock({})).toThrow('plain boom') + await expect(b.unlock({})).rejects.toBe('plain boom') expect(node.unlockWithNativeExternalSigner).not.toHaveBeenCalled() expect(b._sdkInitDone).toBe(false) }) @@ -484,6 +501,17 @@ describe('vssBackup', () => { }) }) +describe('vssDeleteAll', () => { + it('forwards the password to the verified native deletion operation', () => { + const binding = makeBinding() + const node = fakeNode() + binding._node = node + + expect(binding.vssDeleteAll('pw')).toEqual({ deleted_keys: 12 }) + expect(node.vssDeleteAll).toHaveBeenCalledWith({ password: 'pw' }) + }) +}) + describe('apayNew', () => { it('forwards the host node id and returns the node AsyncOrderNewResponse unchanged', () => { const b = makeBinding() diff --git a/tests/vss-status.test.js b/tests/vss-status.test.js index 947c062..07b6452 100644 --- a/tests/vss-status.test.js +++ b/tests/vss-status.test.js @@ -65,3 +65,36 @@ describe('vssBackup', () => { await expect(account.vssBackup()).resolves.toEqual({ version: 9 }) }) }) + +describe('vssDeleteAll', () => { + it('fails before native access when VSS is not configured', async () => { + const vssDeleteAll = jest.fn() + const account = makeAccount({ + vssStatus: () => ({ configured: false }), + vssDeleteAll + }) + + await expect(account.vssDeleteAll('pw')).rejects.toBeInstanceOf(VssNotConfiguredError) + expect(vssDeleteAll).not.toHaveBeenCalled() + }) + + it('returns the native verified deletion count', async () => { + const vssDeleteAll = jest.fn(() => ({ deleted_keys: 9 })) + const account = makeAccount({ + vssStatus: () => ({ configured: true }), + vssDeleteAll + }) + + await expect(account.vssDeleteAll('pw')).resolves.toEqual({ deleted_keys: 9 }) + expect(vssDeleteAll).toHaveBeenCalledWith('pw') + }) + + it('wraps native deletion failures as VssError', async () => { + const account = makeAccount({ + vssStatus: () => ({ configured: true }), + vssDeleteAll: () => { throw new Error('remote delete failed') } + }) + + await expect(account.vssDeleteAll('pw')).rejects.toBeInstanceOf(VssError) + }) +}) diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index 9eaff24..a9133a9 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -18,23 +18,39 @@ import { function syncResult (overrides = {}) { return { - contract_version: 1, + contract_version: 2, mode: 'routine', - vanilla: { status: 'succeeded' }, - colored: { status: 'succeeded' }, + vanilla: { + status: 'succeeded', + checkpoint: { network: 'regtest', height: 100, block_hash: 'a'.repeat(64) } + }, + colored: { + status: 'succeeded', + checkpoint: { network: 'regtest', height: 100, block_hash: 'a'.repeat(64) } + }, ...overrides } } function snapshot (overrides = {}) { return { - contract_version: 1, - native_source: 'rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1', + contract_version: 2, + native_source: 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v2', capture_sequence: '1', + capture_attempts: 2, + stable_capture_count: 2, started_at_ms: '1000', completed_at_ms: '1001', - network_before: { network: 'regtest', height: 100 }, - network_after: { network: 'regtest', height: 100 }, + network_before: { + network: 'regtest', + height: 100, + block_hash: 'a'.repeat(64) + }, + network_after: { + network: 'regtest', + height: 100, + block_hash: 'a'.repeat(64) + }, node: { pubkey: '02abc', num_channels: '1', @@ -91,10 +107,13 @@ function activitySnapshot (overrides = {}) { return snapshot({ transactions: [{ transaction_type: 'Incoming', + purpose: 'incoming_bitcoin', + direction: 'incoming', txid: 'txid-1', received: '42', sent: '0', fee: '0', + external_value: '42', confirmation_time: { height: 100, timestamp: '1000' } }], payments: [{ @@ -117,7 +136,7 @@ function activitySnapshot (overrides = {}) { updated_at: '1001', status: 'Settled', requested_assignment: null, - assignments: ['100'], + assignments: [{ kind: 'Fungible', amount: '100' }], kind: 'ReceiveWitness', txid: 'txid-1', recipient_id: null, @@ -210,15 +229,27 @@ describe('wallet snapshot response contract', () => { } }), options)).toThrow('snapshot.btc.vanilla.settled') expect(() => validateWalletSnapshotResponse(snapshot({ - network_before: { network: 'bitcoin', height: 100 } + network_before: { + network: 'bitcoin', + height: 100, + block_hash: 'a'.repeat(64) + } }), options)).toThrow('snapshot.network_before.network') }) it('canonicalizes recognized legacy native network casing without mutating input', () => { const options = normalizeWalletSnapshotOptions() const value = snapshot({ - network_before: { network: 'Regtest', height: 100 }, - network_after: { network: 'REGTEST', height: 100 } + network_before: { + network: 'Regtest', + height: 100, + block_hash: 'a'.repeat(64) + }, + network_after: { + network: 'REGTEST', + height: 100, + block_hash: 'a'.repeat(64) + } }) const result = validateWalletSnapshotResponse(value, options) @@ -233,7 +264,11 @@ describe('wallet snapshot response contract', () => { const options = normalizeWalletSnapshotOptions() expect(() => validateWalletSnapshotResponse(snapshot({ - network_before: { network: 'Bitcoin', height: 100 } + network_before: { + network: 'Bitcoin', + height: 100, + block_hash: 'a'.repeat(64) + } }), options)).toThrow('snapshot.network_before.network') }) @@ -294,7 +329,7 @@ describe('wallet snapshot response contract', () => { [snapshot({ node: { ...snapshot().node, pubkey: 42 } }), 'snapshot.node.pubkey'], [snapshot({ node: { ...snapshot().node, pubkey: 'a'.repeat(131) } }), 'snapshot.node.pubkey'], [snapshot({ assets: {} }), 'snapshot.assets'], - [snapshot({ contract_version: 2 }), 'snapshot.contract_version'], + [snapshot({ contract_version: 1 }), 'snapshot.contract_version'], [snapshot({ native_source: 'untrusted-native' }), 'snapshot.native_source'], [snapshot({ started_at_ms: '1002', completed_at_ms: '1001' }), 'snapshot.completed_at_ms'], [snapshot({ capture_sequence: '0' }), 'snapshot.capture_sequence'], @@ -348,7 +383,7 @@ describe('wallet snapshot response contract', () => { }) it.each([ - [syncResult({ contract_version: 2 }), 'sync.contract_version'], + [syncResult({ contract_version: 1 }), 'sync.contract_version'], [syncResult({ vanilla: { status: 'succeeded', error_code: 'IMPOSSIBLE' } }), 'sync.vanilla.error_code'], [null, 'sync'] ])('rejects malformed sync contract evidence', (value, path) => { @@ -359,6 +394,38 @@ describe('wallet snapshot response contract', () => { expect(() => validateWalletSyncResponse(syncResult(), 'recovery')) .toThrow('sync.mode') }) + + it('rejects keychains synchronized to different block hashes', () => { + const value = syncResult({ + colored: { + status: 'succeeded', + checkpoint: { + network: 'regtest', + height: 100, + block_hash: 'b'.repeat(64) + } + } + }) + + expect(() => validateWalletSyncResponse(value, 'routine')) + .toThrow('sync.colored.checkpoint') + }) + + it('rejects inconsistent transaction taxonomy and external value', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + const wrongPurpose = activitySnapshot() + wrongPurpose.transactions[0].purpose = 'outgoing_bitcoin' + expect(() => validateWalletSnapshotResponse(wrongPurpose, options)) + .toThrow('snapshot.transactions[0].purpose') + + const wrongValue = activitySnapshot() + wrongValue.transactions[0].external_value = '41' + expect(() => validateWalletSnapshotResponse(wrongValue, options)) + .toThrow('snapshot.transactions[0].external_value') + }) }) describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { @@ -380,7 +447,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { max_activity_items: 1000, include_activity: false }) - expect(result.contractVersion).toBe(1) + expect(result.contractVersion).toBe(2) expect(Object.isFrozen(result)).toBe(true) expect(Object.isFrozen(result.snapshot.btc)).toBe(true) }) @@ -436,7 +503,11 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { walletSnapshot: jest.fn() .mockReturnValueOnce(snapshot({ capture_sequence: '7', - network_after: { network: 'regtest', height: 101 } + network_after: { + network: 'regtest', + height: 101, + block_hash: 'b'.repeat(64) + } })) .mockReturnValueOnce(snapshot({ capture_sequence: '8' })) } @@ -456,7 +527,11 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { })), walletSnapshot: jest.fn(() => snapshot({ capture_sequence: '7', - network_after: { network: 'regtest', height: 101 } + network_after: { + network: 'regtest', + height: 101, + block_hash: 'b'.repeat(64) + } })) } const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) @@ -472,11 +547,19 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { walletSnapshot: jest.fn() .mockReturnValueOnce(snapshot({ capture_sequence: '7', - network_after: { network: 'regtest', height: 101 } + network_after: { + network: 'regtest', + height: 101, + block_hash: 'b'.repeat(64) + } })) .mockReturnValueOnce(snapshot({ capture_sequence: '8', - network_after: { network: 'regtest', height: 101 } + network_after: { + network: 'regtest', + height: 101, + block_hash: 'b'.repeat(64) + } })) } const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) @@ -489,7 +572,11 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { syncWallet: jest.fn(() => syncResult()), walletSnapshot: jest.fn(() => snapshot({ capture_sequence: '7', - network_after: { network: 'regtest', height: 101 } + network_after: { + network: 'regtest', + height: 101, + block_hash: 'b'.repeat(64) + } })) } const error = await accountWith(node).refreshWalletSnapshot().catch((reason) => reason) @@ -504,7 +591,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { it.each([ [new Error('native sync failed'), 'WALLET_SYNC_NATIVE_FAILURE'], - [syncResult({ contract_version: 2 }), 'WALLET_SYNC_CONTRACT_MISMATCH'] + [syncResult({ contract_version: 1 }), 'WALLET_SYNC_CONTRACT_MISMATCH'] ])('classifies native and contract sync failures', async (outcome, code) => { const node = { syncWallet: jest.fn(() => { @@ -518,18 +605,18 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { expect(error).toBeInstanceOf(WalletSyncError) expect(error.code).toBe(code) if (code === 'WALLET_SYNC_CONTRACT_MISMATCH') { - expect(error.message).toContain('sync.contract_version must equal 1') + expect(error.message).toContain('sync.contract_version must equal 2') expect(error.details).toEqual({ mode: 'routine', contractPath: 'sync.contract_version', - contractExpectation: 'must equal 1' + contractExpectation: 'must equal 2' }) } }) it.each([ [new Error('native snapshot failed'), 'WALLET_SNAPSHOT_NATIVE_FAILURE'], - [snapshot({ contract_version: 2 }), 'WALLET_SNAPSHOT_CONTRACT_MISMATCH'], + [snapshot({ contract_version: 1 }), 'WALLET_SNAPSHOT_CONTRACT_MISMATCH'], [new WalletSnapshotError('native typed failure', { code: 'NATIVE_TYPED_FAILURE' }), 'NATIVE_TYPED_FAILURE'] ])('classifies native, contract, and typed snapshot failures', async (outcome, code) => { const node = { @@ -544,10 +631,10 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { expect(error).toBeInstanceOf(WalletSnapshotError) expect(error.code).toBe(code) if (code === 'WALLET_SNAPSHOT_CONTRACT_MISMATCH') { - expect(error.message).toContain('snapshot.contract_version must equal 1') + expect(error.message).toContain('snapshot.contract_version must equal 2') expect(error.details).toEqual({ contractPath: 'snapshot.contract_version', - contractExpectation: 'must equal 1' + contractExpectation: 'must equal 2' }) } }) @@ -579,7 +666,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { it('keeps the serialized refresh queue usable after a failed request', async () => { const node = { syncWallet: jest.fn() - .mockReturnValueOnce(syncResult({ contract_version: 2 })) + .mockReturnValueOnce(syncResult({ contract_version: 1 })) .mockReturnValueOnce(syncResult({ mode: 'recovery' })), walletSnapshot: jest.fn(() => snapshot()) } From 20ce17be1a9e8f95344781b648fe12e129879fb4 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 30 Jul 2026 06:30:23 +0530 Subject: [PATCH 21/34] fix: enforce native operation lifecycle contract --- src/native-operation.js | 145 +++++++++++++++++++++++++++-- tests/bare-binding-methods.test.js | 13 ++- tests/native-operation.test.js | 44 ++++++++- tests/node-binding-methods.test.js | 13 ++- 4 files changed, 201 insertions(+), 14 deletions(-) diff --git a/src/native-operation.js b/src/native-operation.js index 67088a1..987c1f4 100644 --- a/src/native-operation.js +++ b/src/native-operation.js @@ -6,6 +6,25 @@ const POLL_INTERVAL_MS = 100 const TERMINAL_STATES = new Set(['succeeded', 'failed', 'cancelled']) +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const UNSIGNED_DECIMAL = /^(0|[1-9][0-9]*)$/ +const REQUIRED_FIELDS = new Set([ + 'contract_version', + 'operation_id', + 'kind', + 'state', + 'created_at_ms', + 'updated_at_ms', + 'cancellation_requested', + 'can_cancel_immediately', + 'adoption_count' +]) +const OPTIONAL_FIELDS = new Set([ + 'started_at_ms', + 'finished_at_ms', + 'error', + 'adopted_existing' +]) const KNOWN_STATES = new Set([ 'queued', 'running', @@ -17,22 +36,130 @@ function sleep (durationMs) { return new Promise(resolve => setTimeout(resolve, durationMs)) } -function assertStatus (status) { - if (!status || typeof status !== 'object') { +function decimal (value, field) { + if (typeof value !== 'string' || !UNSIGNED_DECIMAL.test(value)) { + throw new Error(`Native operation status has invalid ${field}`) + } + return BigInt(value) +} + +function assertStatus (status, expected) { + if (!status || typeof status !== 'object' || Array.isArray(status)) { throw new Error('Native operation status must be an object') } + for (const field of REQUIRED_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(status, field)) { + throw new Error(`Native operation status is missing ${field}`) + } + } + for (const field of Object.keys(status)) { + if (!REQUIRED_FIELDS.has(field) && !OPTIONAL_FIELDS.has(field)) { + throw new Error(`Native operation status has unknown field ${field}`) + } + } if (status.contract_version !== 1) { throw new Error('Unsupported native operation contract version') } - if (typeof status.operation_id !== 'string' || status.operation_id.length === 0) { - throw new Error('Native operation status is missing operation_id') + if (typeof status.operation_id !== 'string' || !UUID.test(status.operation_id)) { + throw new Error('Native operation status has invalid operation_id') + } + if (status.kind !== 'unlock_with_native_external_signer') { + throw new Error('Native operation status has an unknown operation kind') } if (!KNOWN_STATES.has(status.state)) { throw new Error(`Unknown native operation state: ${String(status.state)}`) } + if ( + typeof status.cancellation_requested !== 'boolean' || + typeof status.can_cancel_immediately !== 'boolean' || + !Number.isSafeInteger(status.adoption_count) || + status.adoption_count < 0 + ) { + throw new Error('Native operation status has invalid lifecycle metadata') + } + if ( + Object.prototype.hasOwnProperty.call(status, 'adopted_existing') && + typeof status.adopted_existing !== 'boolean' + ) { + throw new Error('Native operation status has invalid adoption metadata') + } + + const createdAt = decimal(status.created_at_ms, 'created_at_ms') + const updatedAt = decimal(status.updated_at_ms, 'updated_at_ms') + const startedAt = status.started_at_ms === undefined + ? null + : decimal(status.started_at_ms, 'started_at_ms') + const finishedAt = status.finished_at_ms === undefined + ? null + : decimal(status.finished_at_ms, 'finished_at_ms') + if ( + updatedAt < createdAt || + (startedAt !== null && (startedAt < createdAt || startedAt > updatedAt)) || + (finishedAt !== null && ( + finishedAt < (startedAt ?? createdAt) || + finishedAt > updatedAt + )) + ) { + throw new Error('Native operation status has inconsistent timestamps') + } + + const terminal = TERMINAL_STATES.has(status.state) + if ( + (status.state !== 'queued' && startedAt === null && status.state !== 'cancelled') || + (terminal !== (finishedAt !== null)) || + (status.can_cancel_immediately !== (status.state === 'queued')) || + (['queued', 'running'].includes(status.state) && status.cancellation_requested) || + (['cancel_requested', 'cancelled'].includes(status.state) && + !status.cancellation_requested) || + (status.state === 'failed') !== ( + typeof status.error === 'string' && status.error.length > 0 + ) + ) { + throw new Error('Native operation status has inconsistent state metadata') + } + + if (expected) { + if ( + status.operation_id !== expected.operationId || + status.kind !== expected.kind || + status.created_at_ms !== expected.createdAt + ) { + throw new Error('Native operation identity changed while polling') + } + if ( + updatedAt < expected.updatedAt || + status.adoption_count < expected.adoptionCount + ) { + throw new Error('Native operation lifecycle regressed while polling') + } + } return status } +function canTransition (from, to) { + if (from === to) return true + if (from === 'queued') return true + if (from === 'running') { + return to === 'cancel_requested' || TERMINAL_STATES.has(to) + } + if (from === 'cancel_requested') return TERMINAL_STATES.has(to) + return false +} + +function nextStatus (previous, candidate) { + const current = assertStatus(candidate, { + operationId: previous.operation_id, + kind: previous.kind, + createdAt: previous.created_at_ms, + updatedAt: BigInt(previous.updated_at_ms), + adoptionCount: previous.adoption_count + }) + if (!canTransition(previous.state, current.state)) { + throw new Error('Native operation state regressed while polling') + } + return current +} + /** * Poll an adoptable native operation until its native terminal state is known. * Cancellation never invents an early terminal result: a running operation @@ -43,11 +170,17 @@ export async function waitForNativeOperation (node, initialStatus, signal) { while (!TERMINAL_STATES.has(status.state)) { if (signal?.aborted && !status.cancellation_requested) { - status = assertStatus(node.cancelNativeOperation(status.operation_id)) + status = nextStatus( + status, + node.cancelNativeOperation(status.operation_id) + ) } if (!TERMINAL_STATES.has(status.state)) { await sleep(POLL_INTERVAL_MS) - status = assertStatus(node.nativeOperationStatus(status.operation_id)) + status = nextStatus( + status, + node.nativeOperationStatus(status.operation_id) + ) } } diff --git a/tests/bare-binding-methods.test.js b/tests/bare-binding-methods.test.js index e0e145a..07f1f46 100644 --- a/tests/bare-binding-methods.test.js +++ b/tests/bare-binding-methods.test.js @@ -27,10 +27,17 @@ function fakeNode () { operationSequence += 1 return { contract_version: 1, - operation_id: `operation-${operationSequence}`, - kind: 'unlock', + operation_id: `123e4567-e89b-42d3-a456-${String(operationSequence).padStart(12, '0')}`, + kind: 'unlock_with_native_external_signer', state: 'succeeded', - cancellation_requested: false + created_at_ms: '1000', + started_at_ms: '1001', + finished_at_ms: '1002', + updated_at_ms: '1002', + cancellation_requested: false, + can_cancel_immediately: false, + adoption_count: 0, + adopted_existing: false } }) node.nativeOperationStatus = jest.fn() diff --git a/tests/native-operation.test.js b/tests/native-operation.test.js index 4e911d7..21ac083 100644 --- a/tests/native-operation.test.js +++ b/tests/native-operation.test.js @@ -11,12 +11,21 @@ import { } from '../src/native-operation.js' function status (state, overrides = {}) { + const terminal = ['succeeded', 'failed', 'cancelled'].includes(state) + const started = state !== 'queued' return { contract_version: 1, - operation_id: 'operation-1', + operation_id: '123e4567-e89b-42d3-a456-426614174000', kind: 'unlock_with_native_external_signer', state, + created_at_ms: '1000', + ...(started ? { started_at_ms: '1001' } : {}), + ...(terminal ? { finished_at_ms: '1002' } : {}), + updated_at_ms: terminal ? '1002' : started ? '1001' : '1000', cancellation_requested: state === 'cancel_requested', + can_cancel_immediately: state === 'queued', + adoption_count: 0, + ...(state === 'failed' ? { error: 'NATIVE_OPERATION_FAILED' } : {}), ...overrides } } @@ -41,7 +50,8 @@ describe('native operation lifecycle', () => { const node = { cancelNativeOperation: jest.fn(() => status('cancel_requested')), nativeOperationStatus: jest.fn(() => status('cancelled', { - cancellation_requested: true + cancellation_requested: true, + started_at_ms: '1001' })) } @@ -64,4 +74,34 @@ describe('native operation lifecycle', () => { contract_version: 2 }))).toThrow('Unsupported native operation contract version') }) + + it('rejects identity changes and lifecycle regressions while polling', async () => { + const node = { + nativeOperationStatus: jest.fn() + .mockReturnValueOnce(status('running')) + .mockReturnValueOnce(status('queued', { updated_at_ms: '1001' })) + } + + await expect(waitForNativeOperation(node, status('queued'))) + .rejects.toThrow('state regressed') + + node.nativeOperationStatus.mockReset() + node.nativeOperationStatus.mockReturnValue(status('succeeded', { + operation_id: '123e4567-e89b-42d3-a456-426614174001' + })) + await expect(waitForNativeOperation(node, status('running'))) + .rejects.toThrow('identity changed') + }) + + it('rejects incomplete and internally inconsistent status objects', () => { + expect(() => validateNativeOperationStatus(status('queued', { + created_at_ms: undefined + }))).toThrow('invalid created_at_ms') + expect(() => validateNativeOperationStatus(status('running', { + can_cancel_immediately: true + }))).toThrow('inconsistent state metadata') + expect(() => validateNativeOperationStatus(status('succeeded', { + error: 'unexpected' + }))).toThrow('inconsistent state metadata') + }) }) diff --git a/tests/node-binding-methods.test.js b/tests/node-binding-methods.test.js index d054688..c6a74cf 100644 --- a/tests/node-binding-methods.test.js +++ b/tests/node-binding-methods.test.js @@ -61,10 +61,17 @@ function fakeNode () { operationSequence += 1 return { contract_version: 1, - operation_id: `operation-${operationSequence}`, - kind: 'unlock', + operation_id: `123e4567-e89b-42d3-a456-${String(operationSequence).padStart(12, '0')}`, + kind: 'unlock_with_native_external_signer', state: 'succeeded', - cancellation_requested: false + created_at_ms: '1000', + started_at_ms: '1001', + finished_at_ms: '1002', + updated_at_ms: '1002', + cancellation_requested: false, + can_cancel_immediately: false, + adoption_count: 0, + adopted_existing: false } }) node.nativeOperationStatus = jest.fn() From d98af10814189b0275771ff22912933c42943830 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Thu, 30 Jul 2026 15:20:24 +0530 Subject: [PATCH 22/34] fix: serialize address discovery with native unlock --- src/wallet-account-rgb-lightning.js | 34 ++++++++++--- tests/wallet-account-surface.test.js | 74 ++++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index b2a48ee..8caeb3f 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -134,6 +134,8 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL /** @private */ this._autoRecoverStaleVssFence = bindings.autoRecoverStaleVssFence === true /** @private @type {{ request: object, promise: Promise<{ ok: true }> } | null} */ this._unlockInFlight = null + /** @private @type {Promise | null} */ + this._addressInFlight = null /** @private @type {WalletAccountReadOnlyRgbLightning | null} */ this._readOnlyAccount = null /** @private @type {Promise} */ @@ -218,19 +220,35 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL * WDK React Native Core discovers an account by loading its address before * exposing extension methods. When explicitly configured, activate the full * RGB node at that boundary and then return only the real native address. + * Address discovery is single-flight because WDK Core can mount multiple + * consumers in one render. A read that overlaps native unlock must join that + * transition instead of calling another native API while RLN changes state. * Standalone and read-only consumers retain the ordinary locked error. */ async getAddress () { - try { - return await super.getAddress() - } catch (error) { - if (!(error instanceof AccountLockedError) || !this._autoUnlockRequest) { - throw error + if (this._addressInFlight) return this._addressInFlight + + const operation = (async () => { + if (this._unlockInFlight) await this._unlockInFlight.promise + + try { + return await super.getAddress() + } catch (error) { + if (!(error instanceof AccountLockedError) || !this._autoUnlockRequest) { + throw error + } } - } - await this.unlock(this._autoUnlockRequest) - return super.getAddress() + await this.unlock(this._autoUnlockRequest) + return super.getAddress() + })() + + this._addressInFlight = operation + try { + return await operation + } finally { + if (this._addressInFlight === operation) this._addressInFlight = null + } } /** Idempotent shutdown. */ diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 1c63f46..02fb9d0 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -450,33 +450,81 @@ describe('getAddress', () => { await expect(account.getAddress()).rejects.toBeInstanceOf(AccountLockedError) }) - it('coalesces configured activation and returns only the real native address', async () => { - let unlocked = false + it('coalesces address discovery across the native auto-unlock transition', async () => { + let lifecycle = 'locked' + let releaseUnlock + const unlockPending = new Promise((resolve) => { releaseUnlock = resolve }) const address = jest.fn(() => { - if (!unlocked) throw new Error('SdkNode not created — call unlock() first') + if (lifecycle === 'unlocking') { + throw new Error('Cannot call other APIs while node is changing state') + } + if (lifecycle === 'locked') { + throw new Error('SdkNode not created — call unlock() first') + } return { address: 'tb1qactivated' } }) - const unlock = jest.fn(() => { unlocked = true }) + const unlock = jest.fn(async () => { + lifecycle = 'unlocking' + await unlockPending + lifecycle = 'unlocked' + }) const account = makeAccount( { node: makeNode({ address }), unlock }, { autoUnlockRequest: AUTO_UNLOCK_REQUEST } ) - await expect(Promise.all([ - account.getAddress(), - account.getAddress() - ])).resolves.toEqual(['tb1qactivated', 'tb1qactivated']) + const first = account.getAddress() + await Promise.resolve() + await Promise.resolve() + expect(unlock).toHaveBeenCalledTimes(1) + + const second = account.getAddress() + releaseUnlock() + + await expect(Promise.all([first, second])) + .resolves.toEqual(['tb1qactivated', 'tb1qactivated']) expect(unlock).toHaveBeenCalledTimes(1) expect(unlock).toHaveBeenCalledWith(AUTO_UNLOCK_REQUEST) - expect(address).toHaveBeenCalledTimes(4) + expect(address).toHaveBeenCalledTimes(2) + }) + + it('waits for an explicit unlock before reading the native address', async () => { + let lifecycle = 'locked' + let releaseUnlock + const unlockPending = new Promise((resolve) => { releaseUnlock = resolve }) + const address = jest.fn(() => { + if (lifecycle !== 'unlocked') { + throw new Error('Cannot call other APIs while node is changing state') + } + return { address: 'tb1qready' } + }) + const unlock = jest.fn(async () => { + lifecycle = 'unlocking' + await unlockPending + lifecycle = 'unlocked' + }) + const account = makeAccount({ node: makeNode({ address }), unlock }) + + const unlocking = account.unlock(AUTO_UNLOCK_REQUEST) + await Promise.resolve() + expect(unlock).toHaveBeenCalledTimes(1) + + const pendingAddress = account.getAddress() + expect(address).not.toHaveBeenCalled() + releaseUnlock() + + await expect(unlocking).resolves.toEqual({ ok: true }) + await expect(pendingAddress).resolves.toBe('tb1qready') + expect(address).toHaveBeenCalledTimes(1) }) it('does not hide an automatic activation failure behind an address marker', async () => { + const unlock = jest.fn(() => { throw new Error('indexer unavailable') }) const account = makeAccount( { node: makeNode({ address: () => { throw new Error('LockedNode') } }), - unlock: () => { throw new Error('indexer unavailable') } + unlock }, { autoUnlockRequest: AUTO_UNLOCK_REQUEST } ) @@ -486,6 +534,12 @@ describe('getAddress', () => { code: 'UNLOCK_FAILED', message: 'indexer unavailable' }) + await expect(account.getAddress()).rejects.toMatchObject({ + name: 'UnlockError', + code: 'UNLOCK_FAILED', + message: 'indexer unavailable' + }) + expect(unlock).toHaveBeenCalledTimes(2) }) it('returns a non-throwing locked state for pre-unlock UI loaders', async () => { From e5ff92a4303d2fb21accb64e7a45bf958b438114 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 31 Jul 2026 21:41:39 +0530 Subject: [PATCH 23/34] fix: propagate Lightning failures and teardown --- CHANGELOG.md | 3 ++ index.d.ts | 4 +++ src/bare-binding.js | 11 ++++--- src/node-binding.js | 11 ++++--- src/wallet-manager-rgb-lightning.js | 51 ++++++++++++++++++++--------- tests/bare-binding-methods.test.js | 15 +++++++++ tests/node-binding-methods.test.js | 4 +++ tests/wallet-manager.test.js | 25 ++++++++------ 8 files changed, 91 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d564a40..99c2a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Stable native Lightning `failure_code` fields on immediate send results and + persisted payment records, allowing callers to distinguish route, expiry, + duplicate-payment, recipient, retry, and restart-abandonment failures. - Exact `DecodedRgbInvoice` and tagged `DecodedRgbAssignment` typing across the WDK account boundary. - Exact `DecodedLightningInvoice` typing across the WDK account boundary, diff --git a/index.d.ts b/index.d.ts index 4786999..e3470f0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -398,6 +398,8 @@ export interface SendPaymentResult { payment_hash: string | null payment_secret: string | null status: LightningPaymentStatus + /** Stable machine-readable native failure reason when status is Failed. */ + failure_code: string | null } export interface LightningPayment { @@ -414,6 +416,8 @@ export interface LightningPayment { description_hash: string | null /** Actual routing fee reported by LDK after a successful outbound payment. */ fee_paid_msat: number | null + /** Stable machine-readable native failure reason when status is Failed. */ + failure_code: string | null } /** RGB assignment discriminant accepted by RLN's `parse_assignment_kind`. */ diff --git a/src/bare-binding.js b/src/bare-binding.js index 0f543f0..3fc52d0 100644 --- a/src/bare-binding.js +++ b/src/bare-binding.js @@ -345,12 +345,12 @@ export class BareRgbLightningBinding { * @throws {Error} - If native node shutdown or signer destruction fails. */ shutdown () { - let failure + const failures = [] if (this._node) { try { this._node.shutdown() } catch (error) { - failure = error + failures.push(error) } finally { this._node = null } @@ -359,7 +359,7 @@ export class BareRgbLightningBinding { try { this._signer.destroy() } catch (error) { - failure ??= error + failures.push(error) } finally { this._signer = null } @@ -369,7 +369,10 @@ export class BareRgbLightningBinding { wipeSecret(this._fallbackSeedHex) this._seedHex = undefined this._fallbackSeedHex = undefined - if (failure) throw failure + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, 'RGB Lightning shutdown failed') + } } /** @returns {string} - Native module health status. */ diff --git a/src/node-binding.js b/src/node-binding.js index dce6d54..6ed63be 100644 --- a/src/node-binding.js +++ b/src/node-binding.js @@ -319,12 +319,12 @@ export class NodeRgbLightningBinding { * @throws {Error} - If native node shutdown or signer destruction fails. */ shutdown () { - let failure + const failures = [] if (this._node) { try { this._node.shutdown() } catch (error) { - failure = error + failures.push(error) } finally { this._node = null } @@ -333,7 +333,7 @@ export class NodeRgbLightningBinding { try { this._signer.destroy() } catch (error) { - failure ??= error + failures.push(error) } finally { this._signer = null } @@ -343,7 +343,10 @@ export class NodeRgbLightningBinding { wipeSecret(this._fallbackSeedHex) this._seedHex = undefined this._fallbackSeedHex = undefined - if (failure) throw failure + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, 'RGB Lightning shutdown failed') + } } /** @returns {string} - Native module health status. */ diff --git a/src/wallet-manager-rgb-lightning.js b/src/wallet-manager-rgb-lightning.js index 55ccc1a..6ff4cd0 100644 --- a/src/wallet-manager-rgb-lightning.js +++ b/src/wallet-manager-rgb-lightning.js @@ -221,33 +221,54 @@ export default class WalletManagerRgbLightning extends WalletManager { } dispose () { - let accountDisposalError - let bindingShutdownError + const failures = [] - // The base manager inspects account.keyPair while clearing its account - // cache. RGB Lightning derives that public identity from the external - // signer, so the signer must remain attached until base disposal finishes. - try { - super.dispose() - } catch (error) { - accountDisposalError = error + // WalletManager.dispose() probes account.keyPair before deciding whether + // to call account.dispose(). That is not valid for this manager after the + // app has explicitly shut down its external signer at the lock boundary. + // RGB Lightning owns every account in this cache, so dispose them directly + // without touching native identity state. + for (const account of Object.values(this._accounts)) { + try { + account.dispose() + } catch (error) { + failures.push(error) + } + } + this._accounts = {} + + if (this._defaultSigner) { + try { + this._defaultSigner.dispose() + } catch (error) { + failures.push(error) + } + } + this._defaultSigner = undefined + + for (const signer of Object.values(this._signers)) { + try { + signer.dispose() + } catch (error) { + failures.push(error) + } } + this._signers = {} try { this._binding?.shutdown() } catch (error) { - bindingShutdownError = error + failures.push(error) } finally { this._binding = null } - if (accountDisposalError && bindingShutdownError) { + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { throw new AggregateError( - [accountDisposalError, bindingShutdownError], - 'Failed to dispose RGB Lightning accounts and binding' + failures, + 'Failed to dispose RGB Lightning accounts, signers, and binding' ) } - if (accountDisposalError) throw accountDisposalError - if (bindingShutdownError) throw bindingShutdownError } } diff --git a/tests/bare-binding-methods.test.js b/tests/bare-binding-methods.test.js index 07f1f46..378d013 100644 --- a/tests/bare-binding-methods.test.js +++ b/tests/bare-binding-methods.test.js @@ -20,6 +20,7 @@ function fakeNode () { vssBackup: jest.fn(() => ({ version: 7 })), vssDeleteAll: jest.fn(() => ({ deleted_keys: 12 })), apayNew: jest.fn(() => ({ order_id: 'order-1' })), + detachExternalSigner: jest.fn(), shutdown: jest.fn() } node.startUnlockWithNativeExternalSigner = jest.fn((signer, request) => { @@ -335,6 +336,20 @@ describe('BareRgbLightningBinding', () => { expect(fallbackSeed.every((byte) => byte === 0)).toBe(true) }) + it('stops the node before destroying its signer without reusing the node handle', () => { + const binding = makeBinding() + const node = fakeNode() + const signer = fakeSigner() + binding._node = node + binding._signer = signer + + binding.shutdown() + + expect(node.detachExternalSigner).not.toHaveBeenCalled() + expect(node.shutdown.mock.invocationCallOrder[0]) + .toBeLessThan(signer.destroy.mock.invocationCallOrder[0]) + }) + it('wipes retained seeds when signer destruction fails', () => { const binding = makeBinding() const signer = fakeSigner() diff --git a/tests/node-binding-methods.test.js b/tests/node-binding-methods.test.js index c6a74cf..e6e0587 100644 --- a/tests/node-binding-methods.test.js +++ b/tests/node-binding-methods.test.js @@ -54,6 +54,7 @@ function fakeNode () { vssBackup: jest.fn(() => ({ version: 7 })), vssDeleteAll: jest.fn(() => ({ deleted_keys: 12 })), apayNew: jest.fn(() => realAsyncOrderNewResponse()), + detachExternalSigner: jest.fn(), shutdown: jest.fn() } node.startUnlockWithNativeExternalSigner = jest.fn((signer, request) => { @@ -565,8 +566,11 @@ describe('shutdown', () => { b._seedHex = primarySeed b._fallbackSeedHex = fallbackSeed b.shutdown() + expect(node.detachExternalSigner).not.toHaveBeenCalled() expect(node.shutdown).toHaveBeenCalledTimes(1) expect(signer.destroy).toHaveBeenCalledTimes(1) + expect(node.shutdown.mock.invocationCallOrder[0]) + .toBeLessThan(signer.destroy.mock.invocationCallOrder[0]) expect(b._node).toBeNull() expect(b._signer).toBeNull() expect(b._sdkInitDone).toBe(false) diff --git a/tests/wallet-manager.test.js b/tests/wallet-manager.test.js index f707bc5..a3e6b88 100644 --- a/tests/wallet-manager.test.js +++ b/tests/wallet-manager.test.js @@ -155,28 +155,33 @@ describe('WalletManagerRgbLightning', () => { const binding = FakeBinding.instances[0] expect(account.keyPair.privateKey).toBeNull() manager.dispose() - expect(binding.bootstrap).toHaveBeenCalledTimes(2) + expect(binding.bootstrap).toHaveBeenCalledTimes(1) expect(binding.shutdown).toHaveBeenCalledTimes(1) - expect(binding.bootstrap.mock.invocationCallOrder[1]) - .toBeLessThan(binding.shutdown.mock.invocationCallOrder[0]) }) - it('still destroys the signer when base account cleanup fails', async () => { + it('still destroys the signer when account cleanup fails', async () => { const manager = new TestManager(MNEMONIC, { network: 'regtest', dataDir: '/wallet' }) const account = await manager.getAccount() const binding = FakeBinding.instances[0] - Object.defineProperty(account, 'keyPair', { - configurable: true, - get: () => { - throw new Error('account cleanup failed') - } - }) + account.dispose = jest.fn(() => { throw new Error('account cleanup failed') }) expect(() => manager.dispose()).toThrow('account cleanup failed') expect(binding.shutdown).toHaveBeenCalledTimes(1) expect(manager._binding).toBeNull() }) + it('can dispose after an explicit account shutdown released native identity', async () => { + const manager = new TestManager(MNEMONIC, { network: 'regtest', dataDir: '/wallet' }) + const account = await manager.getAccount() + const binding = FakeBinding.instances[0] + + await account.shutdown() + + expect(() => manager.dispose()).not.toThrow() + expect(binding.shutdown).toHaveBeenCalledTimes(2) + expect(manager._binding).toBeNull() + }) + it('dispose is a no-op before an account has created a binding', () => { const manager = new TestManager(MNEMONIC, { network: 'regtest', dataDir: '/wallet' }) expect(() => manager.dispose()).not.toThrow() From d8a1660148495013a1de68032ba8227235df77dc Mon Sep 17 00:00:00 2001 From: Jainakin Date: Sat, 1 Aug 2026 00:53:16 +0530 Subject: [PATCH 24/34] Retry transient partial wallet synchronization --- src/wallet-account-rgb-lightning.js | 95 ++++++++++++++++---------- tests/wallet-snapshot-contract.test.js | 40 ++++++++++- 2 files changed, 97 insertions(+), 38 deletions(-) diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 8caeb3f..3db1199 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -86,6 +86,11 @@ function sameUnlockRequest (left, right) { } const STALE_VSS_FENCE_PATTERN = /VSS store_id is owned by another rgb-lightning-node instance|__rln_instance__/i +const WALLET_SYNC_PARTIAL_RETRY_DELAYS_MS = Object.freeze([250, 750]) + +function wait (durationMs) { + return new Promise((resolve) => setTimeout(resolve, durationMs)) +} function isStaleVssFenceError (error) { let current = error @@ -652,45 +657,61 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL /** @private */ async _synchronizeWalletForSnapshot (node, mode) { + const partialAttempts = [] let sync - try { - sync = validateWalletSyncResponse( - await node.syncWallet({ mode }), - mode - ) - } catch (error) { - const contractFailure = error instanceof WalletSnapshotContractError - throw new WalletSyncError( - contractFailure - ? `The native wallet sync response does not match contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}: ${error.message}.` - : 'The native wallet synchronization failed.', - { - code: contractFailure - ? 'WALLET_SYNC_CONTRACT_MISMATCH' - : 'WALLET_SYNC_NATIVE_FAILURE', - cause: error, - details: Object.freeze({ - mode, - ...(contractFailure - ? { contractPath: error.path, contractExpectation: error.expectation } - : {}) - }) - } - ) - } - if (sync.vanilla.status !== 'succeeded' || sync.colored.status !== 'succeeded') { - throw new WalletSyncError( - 'The native wallet synchronization did not complete for both keychains.', - { - code: 'WALLET_SYNC_PARTIAL_FAILURE', - details: Object.freeze({ - mode, - vanilla: sync.vanilla, - colored: sync.colored - }) - } - ) + for (let attempt = 0; ; attempt += 1) { + try { + sync = validateWalletSyncResponse( + await node.syncWallet({ mode }), + mode + ) + } catch (error) { + const contractFailure = error instanceof WalletSnapshotContractError + throw new WalletSyncError( + contractFailure + ? `The native wallet sync response does not match contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}: ${error.message}.` + : 'The native wallet synchronization failed.', + { + code: contractFailure + ? 'WALLET_SYNC_CONTRACT_MISMATCH' + : 'WALLET_SYNC_NATIVE_FAILURE', + cause: error, + details: Object.freeze({ + mode, + ...(contractFailure + ? { contractPath: error.path, contractExpectation: error.expectation } + : {}) + }) + } + ) + } + + if (sync.vanilla.status === 'succeeded' && sync.colored.status === 'succeeded') { + break + } + + partialAttempts.push(Object.freeze({ + attempt: attempt + 1, + vanilla: sync.vanilla, + colored: sync.colored + })) + const retryDelayMs = WALLET_SYNC_PARTIAL_RETRY_DELAYS_MS[attempt] + if (retryDelayMs === undefined) { + throw new WalletSyncError( + 'The native wallet synchronization did not complete for both keychains.', + { + code: 'WALLET_SYNC_PARTIAL_FAILURE', + details: Object.freeze({ + mode, + vanilla: sync.vanilla, + colored: sync.colored, + attempts: Object.freeze(partialAttempts) + }) + } + ) + } + await wait(retryDelayMs) } try { diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index a9133a9..4c2a9fc 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -467,10 +467,47 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { status: 'failed', error_code: 'FAILED_BDK_SYNC' }) + expect(error.details.attempts).toHaveLength(3) + expect(error.details.attempts).toEqual([ + { + attempt: 1, + vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' }, + colored: syncResult().colored + }, + { + attempt: 2, + vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' }, + colored: syncResult().colored + }, + { + attempt: 3, + vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' }, + colored: syncResult().colored + } + ]) + expect(node.syncWallet).toHaveBeenCalledTimes(3) expect(node.walletSnapshot).not.toHaveBeenCalled() expect(node.refreshTransfers).not.toHaveBeenCalled() }) + it('recovers a transient partial keychain synchronization', async () => { + const node = { + syncWallet: jest.fn() + .mockReturnValueOnce(syncResult({ + vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' } + })) + .mockReturnValueOnce(syncResult()), + walletSnapshot: jest.fn(() => snapshot()) + } + + const result = await accountWith(node).refreshWalletSnapshot() + + expect(result.sync.vanilla.status).toBe('succeeded') + expect(node.syncWallet).toHaveBeenCalledTimes(2) + expect(node.refreshTransfers).toHaveBeenCalledTimes(1) + expect(node.walletSnapshot).toHaveBeenCalledTimes(1) + }) + it('fails closed when RGB consignment refresh fails', async () => { const node = { syncWallet: jest.fn(() => syncResult()), @@ -522,7 +559,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { const node = { syncWallet: jest.fn() .mockReturnValueOnce(syncResult()) - .mockReturnValueOnce(syncResult({ + .mockReturnValue(syncResult({ vanilla: { status: 'failed', error_code: 'FAILED_BDK_SYNC' } })), walletSnapshot: jest.fn(() => snapshot({ @@ -538,6 +575,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { expect(error).toBeInstanceOf(WalletSyncError) expect(error.code).toBe('WALLET_SYNC_PARTIAL_FAILURE') + expect(node.syncWallet).toHaveBeenCalledTimes(4) expect(node.walletSnapshot).toHaveBeenCalledTimes(1) }) From c41498ae0614ac42832a023e6f67719c9c5003aa Mon Sep 17 00:00:00 2001 From: Jainakin Date: Sat, 1 Aug 2026 15:28:10 +0530 Subject: [PATCH 25/34] fix: validate durable wallet snapshots --- index.d.ts | 5 +++ src/wallet-account-rgb-lightning.js | 7 ++++- src/wallet-snapshot-contract.js | 26 ++++++++++++---- tests/wallet-account-surface.test.js | 14 ++++++++- tests/wallet-snapshot-contract.test.js | 43 ++++++++++++++++++++++---- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/index.d.ts b/index.d.ts index e3470f0..93ea06a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -135,13 +135,16 @@ export interface WalletSnapshotPayment { amt_msat: DecimalString | null asset_amount: DecimalString | null asset_id: string | null + carrier_msat: DecimalString | null payment_hash: string payment_type: 'Outbound' | 'InboundAutoClaim' | 'InboundHodl' status: 'Pending' | 'Claimable' | 'Claiming' | 'Succeeded' | 'Cancelled' | 'Failed' created_at: DecimalString updated_at: DecimalString + expires_at: DecimalString | null payee_pubkey: string fee_paid_msat: DecimalString | null + failure_code: string | null } export interface WalletSnapshotTransferEndpoint { @@ -406,11 +409,13 @@ export interface LightningPayment { amt_msat: number | null asset_amount: number | null asset_id: string | null + carrier_msat: number | null payment_hash: string payment_type: RgbPaymentType status: LightningPaymentStatus created_at: number updated_at: number + expires_at: number | null payee_pubkey: string preimage: string | null description_hash: string | null diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 3db1199..3772617 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -37,6 +37,7 @@ import { } from './errors.js' import { WALLET_SNAPSHOT_CONTRACT_VERSION, + WALLET_SYNC_CONTRACT_VERSION, WalletSnapshotContractError, isCoherentWalletSnapshot, normalizeWalletSnapshotOptions, @@ -670,7 +671,7 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL const contractFailure = error instanceof WalletSnapshotContractError throw new WalletSyncError( contractFailure - ? `The native wallet sync response does not match contract v${WALLET_SNAPSHOT_CONTRACT_VERSION}: ${error.message}.` + ? `The native wallet sync response does not match contract v${WALLET_SYNC_CONTRACT_VERSION}: ${error.message}.` : 'The native wallet synchronization failed.', { code: contractFailure @@ -1157,6 +1158,10 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL if (typeof address !== 'string' || address.length === 0) { throw new Error('RGB Lightning node returned an invalid rotated address') } + const currentAddress = await super.getAddress() + if (currentAddress !== address) { + throw new Error('RGB Lightning node did not persist the rotated address') + } return address } diff --git a/src/wallet-snapshot-contract.js b/src/wallet-snapshot-contract.js index fad9054..672e99b 100644 --- a/src/wallet-snapshot-contract.js +++ b/src/wallet-snapshot-contract.js @@ -4,8 +4,9 @@ // you may not use this file except in compliance with the License. 'use strict' -export const WALLET_SNAPSHOT_CONTRACT_VERSION = 2 -export const WALLET_SNAPSHOT_NATIVE_SOURCE = 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v2' +export const WALLET_SNAPSHOT_CONTRACT_VERSION = 3 +export const WALLET_SYNC_CONTRACT_VERSION = 2 +export const WALLET_SNAPSHOT_NATIVE_SOURCE = 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v3' const NATIVE_LIMITS = Object.freeze({ assets: 128, @@ -204,8 +205,8 @@ function syncKeychain (value, path) { export function validateWalletSyncResponse (value, expectedMode) { const response = record(value, 'sync') exactKeys(response, ['contract_version', 'mode', 'vanilla', 'colored'], [], 'sync') - if (response.contract_version !== WALLET_SNAPSHOT_CONTRACT_VERSION) { - fail('sync.contract_version', `must equal ${WALLET_SNAPSHOT_CONTRACT_VERSION}`) + if (response.contract_version !== WALLET_SYNC_CONTRACT_VERSION) { + fail('sync.contract_version', `must equal ${WALLET_SYNC_CONTRACT_VERSION}`) } if (response.mode !== expectedMode) fail('sync.mode', `must equal ${expectedMode}`) syncKeychain(response.vanilla, 'sync.vanilla') @@ -382,20 +383,33 @@ function snapshotTransaction (value, path) { function snapshotPayment (value, path) { const item = record(value, path) const fields = [ - 'amt_msat', 'asset_amount', 'asset_id', 'payment_hash', 'payment_type', - 'status', 'created_at', 'updated_at', 'payee_pubkey', 'fee_paid_msat' + 'amt_msat', 'asset_amount', 'asset_id', 'carrier_msat', 'payment_hash', + 'payment_type', 'status', 'created_at', 'updated_at', 'expires_at', + 'payee_pubkey', 'fee_paid_msat', 'failure_code' ] exactKeys(item, fields, [], path) nullableDecimal(item.amt_msat, `${path}.amt_msat`) nullableDecimal(item.asset_amount, `${path}.asset_amount`) nullableText(item.asset_id, `${path}.asset_id`, 256) + nullableDecimal(item.carrier_msat, `${path}.carrier_msat`) text(item.payment_hash, `${path}.payment_hash`, 128) oneOf(item.payment_type, ['Outbound', 'InboundAutoClaim', 'InboundHodl'], `${path}.payment_type`) oneOf(item.status, ['Pending', 'Claimable', 'Claiming', 'Succeeded', 'Cancelled', 'Failed'], `${path}.status`) decimal(item.created_at, `${path}.created_at`) decimal(item.updated_at, `${path}.updated_at`) + nullableDecimal(item.expires_at, `${path}.expires_at`) text(item.payee_pubkey, `${path}.payee_pubkey`, 130) nullableDecimal(item.fee_paid_msat, `${path}.fee_paid_msat`) + nullableText(item.failure_code, `${path}.failure_code`, 128) + if (item.asset_id === null && (item.asset_amount !== null || item.carrier_msat !== null)) { + fail(path, 'must not contain RGB amount or carrier metadata without an asset ID') + } + if (item.asset_id !== null && item.carrier_msat !== item.amt_msat) { + fail(`${path}.carrier_msat`, 'must equal amt_msat for an RGB payment') + } + if (item.expires_at !== null && BigInt(item.expires_at) < BigInt(item.created_at)) { + fail(`${path}.expires_at`, 'must not precede created_at') + } } function transferEndpoint (value, path) { diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 02fb9d0..c65b880 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -1134,10 +1134,22 @@ describe('BTC ops', () => { }) it('rotateAddress accepts the native string response form', async () => { - const account = makeAccount({ node: makeNode({ rotateAddress: () => 'tb1qstringrotated' }) }) + const account = makeAccount({ + node: makeNode({ + address: () => ({ address: 'tb1qstringrotated' }), + rotateAddress: () => 'tb1qstringrotated' + }) + }) await expect(account.rotateAddress()).resolves.toBe('tb1qstringrotated') }) + it('rotateAddress rejects a native rotation that was not persisted as current', async () => { + const account = makeAccount({ node: makeNode() }) + await expect(account.rotateAddress()).rejects.toThrow( + 'did not persist the rotated address' + ) + }) + it('getTransactions forwards to node.listTransactions with coerced skipSync', async () => { const listTransactions = jest.fn(() => ({ transactions: [] })) const account = makeAccount({ node: makeNode({ listTransactions }) }) diff --git a/tests/wallet-snapshot-contract.test.js b/tests/wallet-snapshot-contract.test.js index 4c2a9fc..591876e 100644 --- a/tests/wallet-snapshot-contract.test.js +++ b/tests/wallet-snapshot-contract.test.js @@ -34,8 +34,8 @@ function syncResult (overrides = {}) { function snapshot (overrides = {}) { return { - contract_version: 2, - native_source: 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v2', + contract_version: 3, + native_source: 'rgb-lightning-node-v0.10.0-beta.3+utexo-wallet-v3', capture_sequence: '1', capture_attempts: 2, stable_capture_count: 2, @@ -120,13 +120,16 @@ function activitySnapshot (overrides = {}) { amt_msat: '1000', asset_amount: null, asset_id: null, + carrier_msat: null, payment_hash: 'hash-1', payment_type: 'InboundAutoClaim', status: 'Succeeded', created_at: '1000', updated_at: '1001', + expires_at: '4600', payee_pubkey: '02abc', - fee_paid_msat: null + fee_paid_msat: null, + failure_code: null }], transfers: [{ asset_id: 'asset-1', @@ -295,6 +298,34 @@ describe('wallet snapshot response contract', () => { .toBe('1250') }) + it('requires RGB carrier metadata to remain bound to the RGB payment', () => { + const options = normalizeWalletSnapshotOptions({ + includeActivity: true, + assetIds: ['asset-1'] + }) + const value = activitySnapshot() + Object.assign(value.payments[0], { + asset_id: 'asset-1', + asset_amount: '2500000', + carrier_msat: '1000' + }) + + expect(validateWalletSnapshotResponse(value, options).payments[0]).toMatchObject({ + asset_id: 'asset-1', + asset_amount: '2500000', + carrier_msat: '1000' + }) + + const invalid = activitySnapshot() + Object.assign(invalid.payments[0], { + asset_id: 'asset-1', + asset_amount: '2500000', + carrier_msat: '999' + }) + expect(() => validateWalletSnapshotResponse(invalid, options)) + .toThrow('snapshot.payments[0].carrier_msat') + }) + it('accepts transfer endpoint metadata and nullable transfer fields', () => { const options = normalizeWalletSnapshotOptions({ includeActivity: true, @@ -447,7 +478,7 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { max_activity_items: 1000, include_activity: false }) - expect(result.contractVersion).toBe(2) + expect(result.contractVersion).toBe(3) expect(Object.isFrozen(result)).toBe(true) expect(Object.isFrozen(result.snapshot.btc)).toBe(true) }) @@ -669,10 +700,10 @@ describe('WalletAccountRgbLightning.refreshWalletSnapshot', () => { expect(error).toBeInstanceOf(WalletSnapshotError) expect(error.code).toBe(code) if (code === 'WALLET_SNAPSHOT_CONTRACT_MISMATCH') { - expect(error.message).toContain('snapshot.contract_version must equal 2') + expect(error.message).toContain('snapshot.contract_version must equal 3') expect(error.details).toEqual({ contractPath: 'snapshot.contract_version', - contractExpectation: 'must equal 2' + contractExpectation: 'must equal 3' }) } }) From 83d0a723295da87abb37f6afa85d85048185e802 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Sun, 2 Aug 2026 13:17:29 +0530 Subject: [PATCH 26/34] Fix mutually exclusive chain backend config --- README.md | 7 ++-- index.d.ts | 23 +++++++++-- src/node-unlock-request.js | 59 ++++++++++++++++++++-------- tests/wallet-account-surface.test.js | 1 - tests/wallet-manager.test.js | 57 ++++++++++++++++++++++++--- 5 files changed, 116 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1d23e94..f472ecc 100644 --- a/README.md +++ b/README.md @@ -110,16 +110,15 @@ const manager = new WalletManagerRgbLightning(seedPhrase, { const account = await manager.getAccount(0) // RGB Lightning is single-account await account.unlock({ - bitcoind_rpc_username: 'user', - bitcoind_rpc_password: 'pass', - bitcoind_rpc_host: '127.0.0.1', - bitcoind_rpc_port: 18443, indexer_url: 'tcp://localhost:50001', proxy_endpoint: 'rpc://localhost:3000/json-rpc', announce_addresses: [], announce_alias: 'my-node' }) +// Configure exactly one chain backend: either `indexer_url`, as above, or all +// four `bitcoind_rpc_*` fields. The native node rejects requests with both. + const info = await account.getNodeInfo() console.log(info.pubkey) diff --git a/index.d.ts b/index.d.ts index 93ea06a..02ee9a7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -601,17 +601,32 @@ export interface RgbLightningWalletConfig extends RgbLightningBindingConfig { announceAlias?: string } -export interface RgbLightningNodeUnlockRequest { +interface RgbLightningNodeUnlockRequestBase { + proxy_endpoint: string + announce_addresses: string[] + announce_alias: string +} + +export interface RgbLightningBitcoindUnlockRequest extends RgbLightningNodeUnlockRequestBase { bitcoind_rpc_username: string bitcoind_rpc_password: string bitcoind_rpc_host: string bitcoind_rpc_port: number + indexer_url?: never +} + +export interface RgbLightningIndexerUnlockRequest extends RgbLightningNodeUnlockRequestBase { indexer_url: string - proxy_endpoint: string - announce_addresses: string[] - announce_alias: string + bitcoind_rpc_username?: never + bitcoind_rpc_password?: never + bitcoind_rpc_host?: never + bitcoind_rpc_port?: never } +export type RgbLightningNodeUnlockRequest = + | RgbLightningBitcoindUnlockRequest + | RgbLightningIndexerUnlockRequest + export type NativeOperationState = | 'queued' | 'running' diff --git a/src/node-unlock-request.js b/src/node-unlock-request.js index 8b42f5a..074c34b 100644 --- a/src/node-unlock-request.js +++ b/src/node-unlock-request.js @@ -5,13 +5,18 @@ 'use strict' const REQUIRED_STRING_FIELDS = Object.freeze([ - 'bitcoind_rpc_username', - 'bitcoind_rpc_password', - 'bitcoind_rpc_host', - 'indexer_url', 'proxy_endpoint', 'announce_alias' ]) +const BITCOIND_STRING_FIELDS = Object.freeze([ + 'bitcoind_rpc_username', + 'bitcoind_rpc_password', + 'bitcoind_rpc_host' +]) + +function nonEmptyString (value) { + return typeof value === 'string' && value.length > 0 +} /** * Validate and defensively copy the request retained for automatic account @@ -27,17 +32,34 @@ export function normalizeAutoUnlockRequest (value) { } for (const field of REQUIRED_STRING_FIELDS) { - if (typeof value[field] !== 'string' || value[field].length === 0) { + if (!nonEmptyString(value[field])) { throw new TypeError(`autoUnlockRequest.${field} must be a non-empty string`) } } - if ( - !Number.isInteger(value.bitcoind_rpc_port) || - value.bitcoind_rpc_port < 1 || - value.bitcoind_rpc_port > 65_535 - ) { - throw new TypeError('autoUnlockRequest.bitcoind_rpc_port must be a valid TCP port') + const hasIndexer = nonEmptyString(value.indexer_url) + const hasAnyBitcoindField = BITCOIND_STRING_FIELDS.some((field) => value[field] !== undefined) || + value.bitcoind_rpc_port !== undefined + + if (hasIndexer === hasAnyBitcoindField) { + throw new TypeError( + 'autoUnlockRequest must provide exactly one chain backend: indexer_url or all bitcoind RPC fields' + ) + } + + if (hasAnyBitcoindField) { + for (const field of BITCOIND_STRING_FIELDS) { + if (!nonEmptyString(value[field])) { + throw new TypeError(`autoUnlockRequest.${field} must be a non-empty string`) + } + } + if ( + !Number.isInteger(value.bitcoind_rpc_port) || + value.bitcoind_rpc_port < 1 || + value.bitcoind_rpc_port > 65_535 + ) { + throw new TypeError('autoUnlockRequest.bitcoind_rpc_port must be a valid TCP port') + } } if ( @@ -47,12 +69,17 @@ export function normalizeAutoUnlockRequest (value) { throw new TypeError('autoUnlockRequest.announce_addresses must be an array of non-empty strings') } + const chainBackend = hasIndexer + ? { indexer_url: value.indexer_url } + : { + bitcoind_rpc_username: value.bitcoind_rpc_username, + bitcoind_rpc_password: value.bitcoind_rpc_password, + bitcoind_rpc_host: value.bitcoind_rpc_host, + bitcoind_rpc_port: value.bitcoind_rpc_port + } + return Object.freeze({ - bitcoind_rpc_username: value.bitcoind_rpc_username, - bitcoind_rpc_password: value.bitcoind_rpc_password, - bitcoind_rpc_host: value.bitcoind_rpc_host, - bitcoind_rpc_port: value.bitcoind_rpc_port, - indexer_url: value.indexer_url, + ...chainBackend, proxy_endpoint: value.proxy_endpoint, announce_addresses: Object.freeze([...value.announce_addresses]), announce_alias: value.announce_alias diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index c65b880..990cdaf 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -29,7 +29,6 @@ const AUTO_UNLOCK_REQUEST = Object.freeze({ bitcoind_rpc_password: 'password', bitcoind_rpc_host: '127.0.0.1', bitcoind_rpc_port: 18443, - indexer_url: 'tcp://127.0.0.1:50001', proxy_endpoint: 'rpc://127.0.0.1:3000/json-rpc', announce_addresses: Object.freeze([]), announce_alias: 'wallet-test' diff --git a/tests/wallet-manager.test.js b/tests/wallet-manager.test.js index a3e6b88..0711809 100644 --- a/tests/wallet-manager.test.js +++ b/tests/wallet-manager.test.js @@ -15,10 +15,6 @@ const WDK_SEED_HEX = '5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6 const NODE_SEED_V2 = WDK_SEED_HEX.slice(0, 64) const NODE_SEED_V1 = 'd6560f02547828d8d76fc84ea68e74dcccea5599e735cee1fa5f2742289cda58' const AUTO_UNLOCK_REQUEST = { - bitcoind_rpc_username: 'user', - bitcoind_rpc_password: 'password', - bitcoind_rpc_host: '127.0.0.1', - bitcoind_rpc_port: 18443, indexer_url: 'tcp://127.0.0.1:50001', proxy_endpoint: 'rpc://127.0.0.1:3000/json-rpc', announce_addresses: [], @@ -111,8 +107,57 @@ describe('WalletManagerRgbLightning', () => { expect(() => new TestManager(MNEMONIC, { network: 'regtest', dataDir: '/wallet', - autoUnlockRequest: { ...AUTO_UNLOCK_REQUEST, bitcoind_rpc_port: 0 } - })).toThrow('autoUnlockRequest.bitcoind_rpc_port must be a valid TCP port') + autoUnlockRequest: { + ...AUTO_UNLOCK_REQUEST, + bitcoind_rpc_username: 'user', + bitcoind_rpc_password: 'password', + bitcoind_rpc_host: '127.0.0.1', + bitcoind_rpc_port: 18443 + } + })).toThrow('autoUnlockRequest must provide exactly one chain backend') + }) + + it('accepts a complete bitcoind backend without an indexer', async () => { + const bitcoindRequest = { + bitcoind_rpc_username: 'user', + bitcoind_rpc_password: 'password', + bitcoind_rpc_host: '127.0.0.1', + bitcoind_rpc_port: 18443, + proxy_endpoint: 'rpc://127.0.0.1:3000/json-rpc', + announce_addresses: [], + announce_alias: 'wallet-test' + } + const manager = new TestManager(MNEMONIC, { + network: 'regtest', + dataDir: '/wallet', + autoUnlockRequest: bitcoindRequest + }) + + expect((await manager.getAccount())._autoUnlockRequest).toEqual(bitcoindRequest) + }) + + it('rejects incomplete and absent chain backends', () => { + const commonRequest = { + proxy_endpoint: AUTO_UNLOCK_REQUEST.proxy_endpoint, + announce_addresses: AUTO_UNLOCK_REQUEST.announce_addresses, + announce_alias: AUTO_UNLOCK_REQUEST.announce_alias + } + + expect(() => new TestManager(MNEMONIC, { + network: 'regtest', + dataDir: '/wallet', + autoUnlockRequest: commonRequest + })).toThrow('autoUnlockRequest must provide exactly one chain backend') + + expect(() => new TestManager(MNEMONIC, { + network: 'regtest', + dataDir: '/wallet', + autoUnlockRequest: { + ...commonRequest, + bitcoind_rpc_username: 'user', + bitcoind_rpc_port: 18443 + } + })).toThrow('autoUnlockRequest.bitcoind_rpc_password must be a non-empty string') }) it('supports explicit v2-only and legacy-only modes', async () => { From 1ae11834eb78e48ba0017d15233a42010b3122c9 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 4 Aug 2026 18:14:44 +0530 Subject: [PATCH 27/34] Expose RGB transfer consignment import in WDK --- README.md | 2 +- index.d.ts | 13 +++++++++++ src/wallet-account-rgb-lightning.js | 17 +++++++++++++++ tests/wallet-account-surface.test.js | 32 ++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f472ecc..85b40ad 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ are async and forward to the active binding. | HODL invoices | `createHodlInvoice({ paymentHash, ... })`, `cancelHodlInvoice(request)`, `claimHodlInvoice(request)` | | Payments | `sendPayment(request)`, `keysend(request)`, `listPayments()`, `getPayment(hash, type)` | | RGB assets | `listAssets(filter?)`, `getAssetBalance(id)`, `getAssetMetadata(id)`, `listTransfers(id)`, `listTransfersByTxid(txid)`, `refreshTransfers(req)`, `failTransfers(req)` | -| RGB invoices/transfers | `createRgbInvoice(request)`, `decodeRgbInvoice(invoice)`, `sendRgbAsset(request)`, `getAssetMedia(digest)`, `postAssetMedia(request)` | +| RGB invoices/transfers | `createRgbInvoice(request)`, `decodeRgbInvoice(invoice)`, `importRgbTransferConsignment(request)`, `sendRgbAsset(request)`, `getAssetMedia(digest)`, `postAssetMedia(request)` | | RGB issuance (forwarded) | `issueAssetNia(request)`, `issueAssetUda(request)`, `issueAssetCfa(request)`, `issueAssetIfa(request)`, `inflate(request)` — forward to the binding; `@utexo/wdk-wallet-rgb` is the supported path (see note) | | BTC | `getBalance(skipSync?)`, `getBalanceDetails(skipSync?)`, `sendTransaction({ to, value, ... })`, `sendBtc(nativeRequest)`, `prepareBtcSend(request)`, `commitPreparedBtcSend(request)`, `cancelBtcSendPlan(request)`, `getTransactions(skipSync?)`, `getTransactionsByTxid(txid)`, `listUnspents(skipSync?)`, `createUtxos(request)`, `prepareCreateUtxos(request)`, `commitPreparedCreateUtxos(request)`, `cancelCreateUtxosPlan(request)`, `estimateFee(blocks)` | | WDK-standard | `index`, `path`, `keyPair`, `sign(message)`, `verify(message, signature)`, `transfer(options)`, `quoteTransfer(options)`, `quoteSendTransaction(tx)`, `getTransactionReceipt(hash)`, `toReadOnlyAccount()` | diff --git a/index.d.ts b/index.d.ts index 02ee9a7..ab5dba6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -447,6 +447,18 @@ export interface SendRgbAssetRequest { recipient_groups: Array<{ asset_id: string; recipients: RgbSendRecipient[] }> } +export interface ImportRgbTransferConsignmentRequest { + consignment_base64: string + offchain_txid: string + expected_asset_id?: string +} + +export interface ImportRgbTransferConsignmentResult { + asset_id: string + already_imported: boolean + metadata: object +} + export interface BtcSendRequest { amount: number address: string @@ -916,6 +928,7 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning refreshTransfers(request: object): Promise<{ ok: true }> failTransfers(request: object): Promise createRgbInvoice(request: CreateRgbInvoiceRequest | object): Promise + importRgbTransferConsignment(request: ImportRgbTransferConsignmentRequest): Promise sendRgbAsset(request: SendRgbAssetRequest | object): Promise prepareRgbSend(request: SendRgbAssetRequest): Promise commitPreparedRgbSend(request: CommitPreparedSendRequest): Promise diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 3772617..8fd862d 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -983,6 +983,23 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL */ async createRgbInvoice (request) { return this._node.rgbInvoice(request) } + /** + * Import and persist an RGB asset from a transfer consignment. This is a + * mutating wallet operation and requires a native binding that exposes + * `importRgbTransferConsignment`. + * + * @param {Object} request + * @returns {Promise} + */ + async importRgbTransferConsignment (request) { + if (typeof this._node.importRgbTransferConsignment !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose importRgbTransferConsignment()' + ) + } + return this._node.importRgbTransferConsignment(request) + } + /** * Send an RGB asset. Forwarded verbatim to RLN's `sendRgb` * (`JsonSendRgbRequest`): diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 990cdaf..f10d2fd 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -98,6 +98,11 @@ function makeNode (overrides = {}) { rgbInvoice: jest.fn((r) => ({ rgbinv: r })), decodeRgbInvoice: jest.fn(() => DECODED_RGB_INVOICE), sendRgb: jest.fn((r) => ({ txid: 'rgbtx', echo: r })), + importRgbTransferConsignment: jest.fn((r) => ({ + asset_id: r.expected_asset_id ?? 'rgb:asset', + already_imported: false, + metadata: { name: 'Asset' } + })), prepareRgbSend: jest.fn(() => ({ plan_id: 'ab'.repeat(32), batch_transfer_idx: 7, @@ -894,6 +899,33 @@ describe('RGB invoices / transfers / media', () => { expect(node.sendRgb).toHaveBeenCalledWith(req) }) + it('importRgbTransferConsignment forwards to node.importRgbTransferConsignment', async () => { + const node = makeNode() + const account = makeAccount({ node }) + const req = { + consignment_base64: 'Y29uc2lnbm1lbnQ=', + offchain_txid: '11'.repeat(32), + expected_asset_id: 'rgb:asset' + } + + await expect(account.importRgbTransferConsignment(req)).resolves.toEqual({ + asset_id: 'rgb:asset', + already_imported: false, + metadata: { name: 'Asset' } + }) + expect(node.importRgbTransferConsignment).toHaveBeenCalledWith(req) + }) + + it('fails closed when the native binding lacks importRgbTransferConsignment()', async () => { + const node = makeNode({ importRgbTransferConsignment: undefined }) + const account = makeAccount({ node }) + + await expect(account.importRgbTransferConsignment({ + consignment_base64: 'Y29uc2lnbm1lbnQ=', + offchain_txid: '11'.repeat(32) + })).rejects.toThrow('does not expose importRgbTransferConsignment()') + }) + it('prepares and commits an exact RGB transaction plan', async () => { const node = makeNode() const account = makeAccount({ node }) From 96e399695226c4e303734d12308f0bcef28deebd Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 4 Aug 2026 18:53:27 +0530 Subject: [PATCH 28/34] Consume typed LSP discovery policy --- index-bare.js | 1 + index-node.js | 1 + index.d.ts | 36 +++++- src/lsp-client.js | 9 +- src/lsp-info.js | 183 +++++++++++++++++++++++++++ src/wallet-account-rgb-lightning.js | 35 +++-- tests/lsp-client.test.js | 62 ++++++++- tests/wallet-account-surface.test.js | 43 +++++-- 8 files changed, 341 insertions(+), 29 deletions(-) create mode 100644 src/lsp-info.js diff --git a/index-bare.js b/index-bare.js index bba83c9..5a6c023 100644 --- a/index-bare.js +++ b/index-bare.js @@ -38,6 +38,7 @@ export { // installed by ./bare.js) and Node ≥18 (native fetch) without per-runtime // branches. export { LspClient, LspError } from './src/lsp-client.js' +export { parseLspInfo } from './src/lsp-info.js' export { LnurlPayError, UMA_PREFIX, diff --git a/index-node.js b/index-node.js index 6466d8c..d17fffe 100644 --- a/index-node.js +++ b/index-node.js @@ -38,6 +38,7 @@ export { // LSP client surface — see ./src/lsp-client.js, lnurl-pay.js, lsp-helpers.js. // Pure-fetch implementations; identical module under Bare (./index-bare.js). export { LspClient, LspError } from './src/lsp-client.js' +export { parseLspInfo } from './src/lsp-info.js' export { LnurlPayError, UMA_PREFIX, diff --git a/index.d.ts b/index.d.ts index ab5dba6..dcb951d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,6 +24,38 @@ export type Network = 'mainnet' | 'testnet' | 'regtest' | 'signet' /** Integer encoded as base-10 text so values never cross JS's safe-number boundary. */ export type DecimalString = `${bigint}` +export type LspAssetSchema = 'Nia' | 'Uda' | 'Cfa' | 'Ifa' + +export interface LspSupportedAsset { + asset_id: string + schema: LspAssetSchema + ticker?: string + name: string + precision: number +} + +export interface LspInfo { + api_version: 1 + pubkey: string + network: Network + host?: string + port?: number + supported_assets: readonly LspSupportedAsset[] + min_payment_size_msat: DecimalString + max_payment_size_msat: DecimalString + min_channel_balance_sat: DecimalString + max_channel_balance_sat: DecimalString + min_initial_client_balance_msat: DecimalString + max_initial_client_balance_msat: DecimalString + min_channel_asset_amount: DecimalString + max_channel_asset_amount: DecimalString + virtual_channel_mode?: string + lightning_address_min_sendable_msat: DecimalString + lightning_address_max_sendable_msat: DecimalString +} + +export function parseLspInfo(value: unknown): LspInfo + export type WalletSyncMode = 'routine' | 'recovery' export type WalletSyncKeychainResult = @@ -890,6 +922,8 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning }): Promise /** The lspBaseUrl / lspBearerToken this node was constructed with. */ getLspConfig(): { baseUrl: string | null; bearerToken: string | null } + /** Fetch and validate the configured LSP discovery document. */ + getLspInfo(opts?: { timeoutMs?: number }): Promise /** * Build the composed {@link UtexoLsp} flow object. No-arg form * auto-discovers the peer from the wallet's lspBaseUrl. @@ -1041,7 +1075,7 @@ export interface LspBridgeResult { export class LspClient { constructor(opts: LspClientOptions) health(opts?: { timeoutMs?: number }): Promise - getInfo(opts?: { timeoutMs?: number }): Promise + getInfo(opts?: { timeoutMs?: number }): Promise lnurlDiscovery(username: string, opts?: { timeoutMs?: number }): Promise lnurlCallback(username: string, amountMsat: bigint | number | string, opts?: { assetId?: string; assetAmount?: bigint | number | string; timeoutMs?: number }): Promise<{ pr: string; routes?: unknown[] }> /** Full LUD-06 resolution routed through this LSP's baseUrl (discovery + callback). */ diff --git a/src/lsp-client.js b/src/lsp-client.js index 732a3fc..d4c011a 100644 --- a/src/lsp-client.js +++ b/src/lsp-client.js @@ -10,6 +10,7 @@ import { snakeCaseRgbParams, toUint64String } from './lsp-utils.js' +import { parseLspInfo } from './lsp-info.js' // Thin typed wrapper around utexo-lsp's HTTP API. Side-effect free: // methods build URLs, send JSON, validate response status, and return @@ -177,15 +178,17 @@ export class LspClient { health (opts = {}) { return this._req('GET', '/health', undefined, opts) } /** - * Returns the LSP's view of its upstream RLN node (pubkey, channel summary, etc). + * Returns the LSP's public identity, supported assets, and operating policy. * * @param {object} [opts] - Per-call request options. * @param {number} [opts.timeoutMs] - Override the constructor's timeout in * milliseconds. - * @returns {Promise} - Parsed LSP node information. + * @returns {Promise} - Validated LSP information. * @throws {LspError} - If transport, HTTP, size, or JSON validation fails. */ - getInfo (opts = {}) { return this._req('GET', '/get_info', undefined, opts) } + async getInfo (opts = {}) { + return parseLspInfo(await this._req('GET', '/get_info', undefined, opts)) + } /** * LUD-06 discovery for a Lightning Address hosted by this LSP. diff --git a/src/lsp-info.js b/src/lsp-info.js new file mode 100644 index 0000000..235c20f --- /dev/null +++ b/src/lsp-info.js @@ -0,0 +1,183 @@ +// Copyright 2026 UTEXO. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +'use strict' + +const COMPRESSED_PUBLIC_KEY = /^(02|03)[0-9a-f]{64}$/i +const DECIMAL_STRING = /^(0|[1-9][0-9]*)$/ +const ASSET_SCHEMAS = new Set(['Nia', 'Uda', 'Cfa', 'Ifa']) +const NETWORKS = new Set(['mainnet', 'testnet', 'regtest', 'signet']) +const MAX_INFO_FIELDS = 64 +const MAX_SUPPORTED_ASSETS = 512 +const MAX_ASSET_FIELDS = 8 +const MAX_ASSET_ID_LENGTH = 512 +const MAX_TEXT_LENGTH = 128 + +function invalid (field) { + throw new TypeError(`LSP /get_info returned an invalid ${field}`) +} + +function plainRecord (value, field, maximumFields) { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).length > maximumFields + ) { + return invalid(field) + } + return value +} + +function boundedText (value, field, maximum = MAX_TEXT_LENGTH) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maximum || + value !== value.trim() + ) { + return invalid(field) + } + return value +} + +function decimalString (value, field) { + if (typeof value !== 'string' || !DECIMAL_STRING.test(value)) { + return invalid(field) + } + return value +} + +function optionalPeerAddress (info) { + const hostPresent = info.host !== undefined && info.host !== null && info.host !== '' + const portPresent = info.port !== undefined && info.port !== null && info.port !== 0 + if (hostPresent !== portPresent) return invalid('peer address') + if (!hostPresent) return {} + + const host = boundedText(info.host, 'host', 253) + if ( + /\s|@|\/|\?|#/.test(host) || + host.includes('://') || + (host.includes(':') && !/^[0-9a-f:]+$/i.test(host)) + ) { + return invalid('host') + } + if (!Number.isSafeInteger(info.port) || info.port < 1 || info.port > 65_535) { + return invalid('port') + } + return { host, port: info.port } +} + +function supportedAsset (value) { + const asset = plainRecord(value, 'supported asset', MAX_ASSET_FIELDS) + const assetId = boundedText(asset.asset_id, 'supported asset id', MAX_ASSET_ID_LENGTH) + if (/\s/.test(assetId)) return invalid('supported asset id') + const schema = boundedText(asset.schema, 'supported asset schema', 16) + if (!ASSET_SCHEMAS.has(schema)) return invalid('supported asset schema') + const name = boundedText(asset.name, 'supported asset name') + const ticker = asset.ticker === undefined + ? undefined + : boundedText(asset.ticker, 'supported asset ticker', 16) + if ( + !Number.isSafeInteger(asset.precision) || + asset.precision < 0 || + asset.precision > 255 + ) { + return invalid('supported asset precision') + } + return Object.freeze({ + asset_id: assetId, + schema, + ...(ticker === undefined ? {} : { ticker }), + name, + precision: asset.precision + }) +} + +function supportedAssets (value) { + if (!Array.isArray(value) || value.length > MAX_SUPPORTED_ASSETS) { + return invalid('supported assets') + } + const seen = new Set() + const assets = value.map((entry) => { + const asset = supportedAsset(entry) + if (seen.has(asset.asset_id)) return invalid('supported assets') + seen.add(asset.asset_id) + return asset + }) + return Object.freeze(assets) +} + +function orderedRange (info, minimumField, maximumField) { + const minimum = decimalString(info[minimumField], minimumField) + const maximum = decimalString(info[maximumField], maximumField) + if (BigInt(minimum) > BigInt(maximum)) return invalid(`${minimumField}/${maximumField} range`) + return [minimum, maximum] +} + +/** + * Validate and freeze the v1 public LSP discovery document. + * Unknown fields are ignored so additive server changes remain compatible. + * + * @param {unknown} value + * @returns {import('../index.js').LspInfo} + */ +export function parseLspInfo (value) { + const info = plainRecord(value, 'response', MAX_INFO_FIELDS) + if (info.api_version !== 1) return invalid('api_version') + + const pubkey = boundedText(info.pubkey, 'pubkey', 66).toLowerCase() + if (!COMPRESSED_PUBLIC_KEY.test(pubkey)) return invalid('pubkey') + const network = boundedText(info.network, 'network', 16).toLowerCase() + if (!NETWORKS.has(network)) return invalid('network') + const peerAddress = optionalPeerAddress(info) + const assets = supportedAssets(info.supported_assets) + const [minPaymentSizeMsat, maxPaymentSizeMsat] = orderedRange( + info, + 'min_payment_size_msat', + 'max_payment_size_msat' + ) + const [minChannelBalanceSat, maxChannelBalanceSat] = orderedRange( + info, + 'min_channel_balance_sat', + 'max_channel_balance_sat' + ) + const [minInitialClientBalanceMsat, maxInitialClientBalanceMsat] = orderedRange( + info, + 'min_initial_client_balance_msat', + 'max_initial_client_balance_msat' + ) + const [minChannelAssetAmount, maxChannelAssetAmount] = orderedRange( + info, + 'min_channel_asset_amount', + 'max_channel_asset_amount' + ) + const [lightningAddressMinSendableMsat, lightningAddressMaxSendableMsat] = orderedRange( + info, + 'lightning_address_min_sendable_msat', + 'lightning_address_max_sendable_msat' + ) + const virtualChannelMode = info.virtual_channel_mode === undefined + ? undefined + : boundedText(info.virtual_channel_mode, 'virtual_channel_mode', 64) + + return Object.freeze({ + api_version: 1, + pubkey, + network, + ...peerAddress, + supported_assets: assets, + min_payment_size_msat: minPaymentSizeMsat, + max_payment_size_msat: maxPaymentSizeMsat, + min_channel_balance_sat: minChannelBalanceSat, + max_channel_balance_sat: maxChannelBalanceSat, + min_initial_client_balance_msat: minInitialClientBalanceMsat, + max_initial_client_balance_msat: maxInitialClientBalanceMsat, + min_channel_asset_amount: minChannelAssetAmount, + max_channel_asset_amount: maxChannelAssetAmount, + ...(virtualChannelMode === undefined ? {} : { virtual_channel_mode: virtualChannelMode }), + lightning_address_min_sendable_msat: lightningAddressMinSendableMsat, + lightning_address_max_sendable_msat: lightningAddressMaxSendableMsat + }) +} diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 8fd862d..99b448e 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -482,6 +482,23 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } } + /** + * Fetch and validate this account's configured LSP discovery document. + * + * @param {{ timeoutMs?: number }} [opts] + * @returns {Promise} + */ + async getLspInfo (opts = {}) { + const { baseUrl, bearerToken } = this.getLspConfig() + if (!baseUrl) { + throw new Error('getLspInfo: lspBaseUrl not set') + } + return new LspClient({ + baseUrl, + defaultHeaders: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : undefined + }).getInfo(opts) + } + /** * Build a {@link UtexoLsp} — the composed LSP flow object (connect, * wait-for-channel, receive/send asset, pay address, enable Lightning @@ -489,36 +506,32 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL * `wallet.createLsp(peer?)`. * * No-arg form auto-discovers the peer from the wallet's `lspBaseUrl`: - * pubkey via `GET /get_info`, host from the base URL, port from - * `peerPort` (default 9735). + * pubkey, host, and port via `GET /get_info`. A caller-supplied + * `peerPort` overrides the advertised port for legacy deployments. * * Explicit form takes a full LspPeer * (`{ baseUrl, peerPubkey, peerHost, peerPort, bearerToken?, timeoutMs?, allowHttp? }`). * * @param {object} [peer] - * @param {number} [peerPort=9735] Used only by the auto-discover form. + * @param {number} [peerPort] Used only by the auto-discover form. * @returns {Promise} */ - async createLsp (peer, peerPort = 9735) { + async createLsp (peer, peerPort) { if (peer) return new UtexoLsp(this, peer) const { baseUrl, bearerToken } = this.getLspConfig() if (!baseUrl) { throw new Error('createLsp: lspBaseUrl not set — pass a peer explicitly or construct the wallet with lspBaseUrl') } - const http = new LspClient({ - baseUrl, - defaultHeaders: bearerToken ? { Authorization: `Bearer ${bearerToken}` } : undefined - }) - const info = await http.getInfo() + const info = await this.getLspInfo() if (!info || typeof info.pubkey !== 'string' || info.pubkey.length === 0) { throw new Error('createLsp: LSP /get_info returned no pubkey') } return new UtexoLsp(this, { baseUrl, peerPubkey: info.pubkey, - peerHost: new URL(baseUrl).hostname, - peerPort, + peerHost: info.host ?? new URL(baseUrl).hostname, + peerPort: peerPort ?? info.port ?? 9735, bearerToken: bearerToken ?? undefined }) } diff --git a/tests/lsp-client.test.js b/tests/lsp-client.test.js index e35c982..8166f8b 100644 --- a/tests/lsp-client.test.js +++ b/tests/lsp-client.test.js @@ -12,6 +12,29 @@ import { LspError, LspClient } from '../src/lsp-client.js' const BASE = 'https://lsp.utexo.io' +function lspInfo (overrides = {}) { + return { + api_version: 1, + pubkey: '02' + 'ab'.repeat(32), + network: 'signet', + host: 'lsp.utexo.io', + port: 9735, + supported_assets: [], + min_payment_size_msat: '1000', + max_payment_size_msat: '20000000', + min_channel_balance_sat: '200000', + max_channel_balance_sat: '200000', + min_initial_client_balance_msat: '30000000', + max_initial_client_balance_msat: '30000000', + min_channel_asset_amount: '1', + max_channel_asset_amount: '1', + virtual_channel_mode: 'trusted_no_broadcast', + lightning_address_min_sendable_msat: '3000000', + lightning_address_max_sendable_msat: '20000000', + ...overrides + } +} + // Build a Response-like object. `text` defaults to a JSON serialization of // `json` so callers can pass either. function makeRes ({ ok = true, status = 200, json, text, headers } = {}) { @@ -211,11 +234,42 @@ describe('GET endpoint methods', () => { }) it('getInfo() issues GET /get_info', async () => { - const { client, fetchImpl } = makeClient({ fetch: fetchReturning(makeRes({ json: { pubkey: 'abc' } })) }) - expect(await client.getInfo()).toEqual({ pubkey: 'abc' }) + const response = lspInfo({ + supported_assets: [{ + asset_id: 'rgb:asset', + schema: 'Ifa', + ticker: 'UTIF', + name: 'UTEXO Test IFA', + precision: 8 + }] + }) + const { client, fetchImpl } = makeClient({ fetch: fetchReturning(makeRes({ json: response })) }) + expect(await client.getInfo()).toEqual(response) expect(fetchImpl.mock.calls[0][0]).toBe('https://lsp.utexo.io/get_info') }) + it('rejects malformed discovery policy instead of exposing an untyped object', async () => { + const response = lspInfo({ max_channel_asset_amount: 1 }) + const { client } = makeClient({ fetch: fetchReturning(makeRes({ json: response })) }) + await expect(client.getInfo()).rejects.toThrow(/max_channel_asset_amount/) + }) + + it('rejects duplicate supported asset identities', async () => { + const asset = { + asset_id: 'rgb:asset', + schema: 'Nia', + ticker: 'UTST', + name: 'UTEXO Signet Test', + precision: 0 + } + const { client } = makeClient({ + fetch: fetchReturning(makeRes({ + json: lspInfo({ supported_assets: [asset, asset] }) + })) + }) + await expect(client.getInfo()).rejects.toThrow(/supported assets/) + }) + it('merges defaultHeaders (e.g. a Bearer token) into every request', async () => { const { client, fetchImpl } = makeClient({ defaultHeaders: { Authorization: 'Bearer tok' } }) await client.health() @@ -544,7 +598,7 @@ describe('_req error and edge handling', () => { it('honours a per-call timeoutMs override (resolves the override, not the constructor default)', async () => { // The per-call value takes precedence over the constructor default. - const { client, fetchImpl } = makeClient({ fetch: fetchReturning(makeRes({ json: {} })), timeoutMs: 15000 }) + const { client, fetchImpl } = makeClient({ fetch: fetchReturning(makeRes({ json: lspInfo() })), timeoutMs: 15000 }) const sigSpy = jest.spyOn(client, '_timeoutSignal') await client.getInfo({ timeoutMs: 2000 }) expect(fetchImpl.mock.calls[0][1].signal).toBeDefined() @@ -555,7 +609,7 @@ describe('_req error and edge handling', () => { }) it('falls back to the constructor timeout when no per-call override is given', async () => { - const { client } = makeClient({ fetch: fetchReturning(makeRes({ json: {} })), timeoutMs: 5000 }) + const { client } = makeClient({ fetch: fetchReturning(makeRes({ json: lspInfo() })), timeoutMs: 5000 }) const sigSpy = jest.spyOn(client, '_timeoutSignal') await client.getInfo() expect(sigSpy).toHaveBeenCalledWith(5000) diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index f10d2fd..f8d16a8 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -58,6 +58,29 @@ const DECODED_RGB_INVOICE = Object.freeze({ transport_endpoints: Object.freeze(['rpc://127.0.0.1:3000/json-rpc']) }) +function lspInfo (overrides = {}) { + return { + api_version: 1, + pubkey: '02' + 'ab'.repeat(32), + network: 'signet', + host: 'lsp.example', + port: 9735, + supported_assets: [], + min_payment_size_msat: '1000', + max_payment_size_msat: '20000000', + min_channel_balance_sat: '200000', + max_channel_balance_sat: '200000', + min_initial_client_balance_msat: '30000000', + max_initial_client_balance_msat: '30000000', + min_channel_asset_amount: '1', + max_channel_asset_amount: '1', + virtual_channel_mode: 'trusted_no_broadcast', + lightning_address_min_sendable_msat: '3000000', + lightning_address_max_sendable_msat: '20000000', + ...overrides + } +} + // Build a fake RLN node whose methods are jest.fn returning canned // values. Every method the account forwards to is present so we can // assert forwarding + arg pass-through. @@ -1456,11 +1479,12 @@ describe('createLsp', () => { }) it('auto-discovers the peer from lspBaseUrl via GET /get_info', async () => { - // No-arg form: pubkey from /get_info, host from the base URL hostname, - // port from the peerPort default (9735). Real LSP /get_info returns the - // node pubkey (hex 33-byte compressed key); stub getInfo so no network. const getInfoSpy = jest.spyOn(LspClient.prototype, 'getInfo') - .mockResolvedValue({ pubkey: 'ab'.repeat(33), num_channels: 4 }) + .mockResolvedValue(lspInfo({ + pubkey: '02' + 'ab'.repeat(32), + host: 'peer.lsp.example', + port: 19735 + })) try { const account = makeAccount({ _config: { lspBaseUrl: 'https://lsp.example:8443/api', lspBearerToken: 'tok' } @@ -1468,12 +1492,11 @@ describe('createLsp', () => { const lsp = await account.createLsp() expect(lsp).toBeInstanceOf(UtexoLsp) expect(lsp.account).toBe(account) - // peer must be assembled from /get_info + base URL hostname + default port. expect(lsp.peer).toEqual({ baseUrl: 'https://lsp.example:8443/api', - peerPubkey: 'ab'.repeat(33), - peerHost: 'lsp.example', - peerPort: 9735, + peerPubkey: '02' + 'ab'.repeat(32), + peerHost: 'peer.lsp.example', + peerPort: 19735, bearerToken: 'tok' }) expect(getInfoSpy).toHaveBeenCalledTimes(1) @@ -1484,7 +1507,7 @@ describe('createLsp', () => { it('honours an explicit peerPort override in the no-arg form', async () => { const getInfoSpy = jest.spyOn(LspClient.prototype, 'getInfo') - .mockResolvedValue({ pubkey: 'cd'.repeat(33) }) + .mockResolvedValue(lspInfo({ pubkey: '03' + 'cd'.repeat(32) })) try { const account = makeAccount({ _config: { lspBaseUrl: 'https://lsp.example' } }) const lsp = await account.createLsp(undefined, 9999) @@ -1499,7 +1522,7 @@ describe('createLsp', () => { it('throws when /get_info returns no pubkey', async () => { const getInfoSpy = jest.spyOn(LspClient.prototype, 'getInfo') - .mockResolvedValue({ num_channels: 0 }) + .mockResolvedValue(lspInfo({ pubkey: undefined })) try { const account = makeAccount({ _config: { lspBaseUrl: 'https://lsp.example' } }) await expect(account.createLsp()).rejects.toThrow(/returned no pubkey/) From 65727dee80cf3c1bc5e887f022dd79bcde2506d2 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 4 Aug 2026 19:03:43 +0530 Subject: [PATCH 29/34] Expose LSP discovery parser subpath --- lsp-info.d.ts | 6 ++++++ package.json | 4 ++++ scripts/verify-package.mjs | 1 + 3 files changed, 11 insertions(+) create mode 100644 lsp-info.d.ts diff --git a/lsp-info.d.ts b/lsp-info.d.ts new file mode 100644 index 0000000..7aa9f7f --- /dev/null +++ b/lsp-info.d.ts @@ -0,0 +1,6 @@ +export { + parseLspInfo, + type LspAssetSchema, + type LspInfo, + type LspSupportedAsset, +} from './index.js' diff --git a/package.json b/package.json index a01e93f..7ddaec2 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,10 @@ }, "./package": { "default": "./package.json" + }, + "./lsp-info": { + "types": "./lsp-info.d.ts", + "default": "./src/lsp-info.js" } }, "publishConfig": { diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 4cc4734..ef311b5 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -107,6 +107,7 @@ const requiredRootFiles = [ 'index-node.js', 'index.d.ts', 'index.js', + 'lsp-info.d.ts', 'package.json' ] const requiredRuntimeFiles = [ From c00fa737c62133226e094c15fcd190ed2da607ef Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 4 Aug 2026 23:30:37 +0530 Subject: [PATCH 30/34] Expose RGB contract preload API --- README.md | 2 +- index.d.ts | 12 +++++++++++ src/wallet-account-rgb-lightning.js | 17 +++++++++++++++ tests/wallet-account-surface.test.js | 31 ++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85b40ad..a68ba87 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ are async and forward to the active binding. | HODL invoices | `createHodlInvoice({ paymentHash, ... })`, `cancelHodlInvoice(request)`, `claimHodlInvoice(request)` | | Payments | `sendPayment(request)`, `keysend(request)`, `listPayments()`, `getPayment(hash, type)` | | RGB assets | `listAssets(filter?)`, `getAssetBalance(id)`, `getAssetMetadata(id)`, `listTransfers(id)`, `listTransfersByTxid(txid)`, `refreshTransfers(req)`, `failTransfers(req)` | -| RGB invoices/transfers | `createRgbInvoice(request)`, `decodeRgbInvoice(invoice)`, `importRgbTransferConsignment(request)`, `sendRgbAsset(request)`, `getAssetMedia(digest)`, `postAssetMedia(request)` | +| RGB invoices/transfers | `createRgbInvoice(request)`, `decodeRgbInvoice(invoice)`, `importRgbTransferConsignment(request)`, `importRgbContract(request)`, `sendRgbAsset(request)`, `getAssetMedia(digest)`, `postAssetMedia(request)` | | RGB issuance (forwarded) | `issueAssetNia(request)`, `issueAssetUda(request)`, `issueAssetCfa(request)`, `issueAssetIfa(request)`, `inflate(request)` — forward to the binding; `@utexo/wdk-wallet-rgb` is the supported path (see note) | | BTC | `getBalance(skipSync?)`, `getBalanceDetails(skipSync?)`, `sendTransaction({ to, value, ... })`, `sendBtc(nativeRequest)`, `prepareBtcSend(request)`, `commitPreparedBtcSend(request)`, `cancelBtcSendPlan(request)`, `getTransactions(skipSync?)`, `getTransactionsByTxid(txid)`, `listUnspents(skipSync?)`, `createUtxos(request)`, `prepareCreateUtxos(request)`, `commitPreparedCreateUtxos(request)`, `cancelCreateUtxosPlan(request)`, `estimateFee(blocks)` | | WDK-standard | `index`, `path`, `keyPair`, `sign(message)`, `verify(message, signature)`, `transfer(options)`, `quoteTransfer(options)`, `quoteSendTransaction(tx)`, `getTransactionReceipt(hash)`, `toReadOnlyAccount()` | diff --git a/index.d.ts b/index.d.ts index dcb951d..fd04f34 100644 --- a/index.d.ts +++ b/index.d.ts @@ -491,6 +491,17 @@ export interface ImportRgbTransferConsignmentResult { metadata: object } +export interface ImportRgbContractRequest { + contract_base64: string + expected_asset_id: string +} + +export interface ImportRgbContractResult { + asset_id: string + already_imported: boolean + metadata: object +} + export interface BtcSendRequest { amount: number address: string @@ -963,6 +974,7 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning failTransfers(request: object): Promise createRgbInvoice(request: CreateRgbInvoiceRequest | object): Promise importRgbTransferConsignment(request: ImportRgbTransferConsignmentRequest): Promise + importRgbContract(request: ImportRgbContractRequest): Promise sendRgbAsset(request: SendRgbAssetRequest | object): Promise prepareRgbSend(request: SendRgbAssetRequest): Promise commitPreparedRgbSend(request: CommitPreparedSendRequest): Promise diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 99b448e..8acc64d 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -1013,6 +1013,23 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL return this._node.importRgbTransferConsignment(request) } + /** + * Validate and register a standalone RGB contract without creating an + * allocation or transfer. Intended for trusted, network-scoped contract + * preload during wallet bootstrap. + * + * @param {Object} request + * @returns {Promise} + */ + async importRgbContract (request) { + if (typeof this._node.importRgbContract !== 'function') { + throw new Error( + 'The installed RGB Lightning native binding does not expose importRgbContract()' + ) + } + return this._node.importRgbContract(request) + } + /** * Send an RGB asset. Forwarded verbatim to RLN's `sendRgb` * (`JsonSendRgbRequest`): diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index f8d16a8..88a0d1c 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -126,6 +126,11 @@ function makeNode (overrides = {}) { already_imported: false, metadata: { name: 'Asset' } })), + importRgbContract: jest.fn((r) => ({ + asset_id: r.expected_asset_id, + already_imported: false, + metadata: { name: 'Approved Asset' } + })), prepareRgbSend: jest.fn(() => ({ plan_id: 'ab'.repeat(32), batch_transfer_idx: 7, @@ -949,6 +954,32 @@ describe('RGB invoices / transfers / media', () => { })).rejects.toThrow('does not expose importRgbTransferConsignment()') }) + it('importRgbContract forwards to node.importRgbContract', async () => { + const node = makeNode() + const account = makeAccount({ node }) + const req = { + contract_base64: 'Y29udHJhY3Q=', + expected_asset_id: 'rgb:approved' + } + + await expect(account.importRgbContract(req)).resolves.toEqual({ + asset_id: 'rgb:approved', + already_imported: false, + metadata: { name: 'Approved Asset' } + }) + expect(node.importRgbContract).toHaveBeenCalledWith(req) + }) + + it('fails closed when the native binding lacks importRgbContract()', async () => { + const node = makeNode({ importRgbContract: undefined }) + const account = makeAccount({ node }) + + await expect(account.importRgbContract({ + contract_base64: 'Y29udHJhY3Q=', + expected_asset_id: 'rgb:approved' + })).rejects.toThrow('does not expose importRgbContract()') + }) + it('prepares and commits an exact RGB transaction plan', async () => { const node = makeNode() const account = makeAccount({ node }) From 3dcbc56f7235af161f52c851a059203c3591febc Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 14 Aug 2026 17:36:02 +0530 Subject: [PATCH 31/34] Harden RGB import boundary --- CHANGELOG.md | 4 + README.md | 10 ++ index.d.ts | 5 + scripts/smoke-node-package.mjs | 2 +- src/rgb-import-contract.js | 143 ++++++++++++++++++++++++++++ src/wallet-account-rgb-lightning.js | 20 +++- tests/rgb-import-contract.test.js | 76 +++++++++++++++ 7 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/rgb-import-contract.js create mode 100644 tests/rgb-import-contract.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c2a85..b8ab6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Validated standalone RGB contract and transfer-consignment import boundaries. + Requests are exact and bounded, transaction IDs are canonicalized, native + responses are schema-checked, and an import fails closed if the returned + asset differs from `expected_asset_id`. - Stable native Lightning `failure_code` fields on immediate send results and persisted payment records, allowing callers to distinguish route, expiry, duplicate-payment, recipient, retry, and restart-abandonment failures. diff --git a/README.md b/README.md index a68ba87..47545a3 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,16 @@ are async and forward to the active binding. Notes: +- **`importRgbContract()` registers trusted contract metadata only.** Call it + with a network-scoped, independently validated binary contract encoded as + base64 and the exact expected asset id. It creates no allocation and leaves + the asset balance at zero. Repeating the same import is idempotent; a payload + whose derived asset id differs from `expected_asset_id` fails closed. +- **`importRgbTransferConsignment()` is not a substitute for receiving.** It + persists metadata from a transfer the native receive path has already + accepted and requires the exact off-chain transaction id. Normal BTC/RGB + settlement must still use the protocol receive flow. + - **`refreshWalletSnapshot()` is the production balance/history refresh.** It serializes native refreshes, coalesces identical requests, FullSyncs both Vanilla and Colored keychains in `routine` mode, and FullScans both only in diff --git a/index.d.ts b/index.d.ts index fd04f34..a00c35b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -480,8 +480,11 @@ export interface SendRgbAssetRequest { } export interface ImportRgbTransferConsignmentRequest { + /** Raw binary transfer consignment encoded with standard base64. */ consignment_base64: string + /** Exact off-chain RGB transaction id associated with the accepted transfer. */ offchain_txid: string + /** Optional fail-closed assertion for the consignment's derived asset id. */ expected_asset_id?: string } @@ -492,7 +495,9 @@ export interface ImportRgbTransferConsignmentResult { } export interface ImportRgbContractRequest { + /** Trusted, network-scoped binary RGB contract encoded with standard base64. */ contract_base64: string + /** Required fail-closed assertion for the contract's derived asset id. */ expected_asset_id: string } diff --git a/scripts/smoke-node-package.mjs b/scripts/smoke-node-package.mjs index 104929b..538da22 100644 --- a/scripts/smoke-node-package.mjs +++ b/scripts/smoke-node-package.mjs @@ -87,7 +87,7 @@ try { const nativePackageSpec = registryPackage ? `${nativePackage}@${nativeVersion}` : process.env.WDK_RGB_LIGHTNING_NODE_SPEC ?? - 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#iris-wallet' + 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#ed494dabc6d4ea4c7c572dad0a3857b683f7e1f2' writeFileSync( path.join(temporaryRoot, 'package.json'), diff --git a/src/rgb-import-contract.js b/src/rgb-import-contract.js new file mode 100644 index 0000000..e1afcc8 --- /dev/null +++ b/src/rgb-import-contract.js @@ -0,0 +1,143 @@ +const TXID_PATTERN = /^[0-9a-f]{64}$/i +const MAX_RGB_IMPORT_BASE64_CHARACTERS = 16 * 1024 * 1024 +const MAX_ASSET_ID_CHARACTERS = 512 + +function fail (path, expectation) { + throw new TypeError(`${path} must ${expectation}`) +} + +function requireObject (value, path) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + fail(path, 'be an object') + } + return value +} + +function requireExactKeys (value, required, optional, path) { + const allowed = new Set([...required, ...optional]) + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + fail(path, `contain only: ${[...allowed].sort().join(', ')}`) + } + } + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + fail(path, `contain ${key}`) + } + } +} + +function requirePayload (value, path) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_RGB_IMPORT_BASE64_CHARACTERS + ) { + fail( + path, + `be a non-empty base64 payload no longer than ${MAX_RGB_IMPORT_BASE64_CHARACTERS} characters` + ) + } + return value +} + +function requireAssetId (value, path) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_ASSET_ID_CHARACTERS || + value.trim() !== value + ) { + fail(path, `be a non-empty RGB asset id no longer than ${MAX_ASSET_ID_CHARACTERS} characters`) + } + return value +} + +function requireTxid (value, path) { + if (typeof value !== 'string' || !TXID_PATTERN.test(value)) { + fail(path, 'be a 32-byte transaction id') + } + return value.toLowerCase() +} + +function validateImportResult (value, expectedAssetId, path) { + const result = requireObject(value, path) + requireExactKeys( + result, + ['asset_id', 'already_imported', 'metadata'], + [], + path + ) + const assetId = requireAssetId(result.asset_id, `${path}.asset_id`) + if (expectedAssetId !== undefined && assetId !== expectedAssetId) { + fail(`${path}.asset_id`, `equal expected_asset_id (${expectedAssetId})`) + } + if (typeof result.already_imported !== 'boolean') { + fail(`${path}.already_imported`, 'be a boolean') + } + const metadata = requireObject(result.metadata, `${path}.metadata`) + return Object.freeze({ + asset_id: assetId, + already_imported: result.already_imported, + metadata + }) +} + +export function validateImportRgbTransferConsignmentRequest (value) { + const request = requireObject(value, 'RGB transfer consignment import request') + requireExactKeys( + request, + ['consignment_base64', 'offchain_txid'], + ['expected_asset_id'], + 'RGB transfer consignment import request' + ) + const normalized = { + consignment_base64: requirePayload( + request.consignment_base64, + 'RGB transfer consignment import request.consignment_base64' + ), + offchain_txid: requireTxid( + request.offchain_txid, + 'RGB transfer consignment import request.offchain_txid' + ) + } + if (request.expected_asset_id !== undefined) { + normalized.expected_asset_id = requireAssetId( + request.expected_asset_id, + 'RGB transfer consignment import request.expected_asset_id' + ) + } + return Object.freeze(normalized) +} + +export function validateImportRgbContractRequest (value) { + const request = requireObject(value, 'RGB contract import request') + requireExactKeys( + request, + ['contract_base64', 'expected_asset_id'], + [], + 'RGB contract import request' + ) + return Object.freeze({ + contract_base64: requirePayload( + request.contract_base64, + 'RGB contract import request.contract_base64' + ), + expected_asset_id: requireAssetId( + request.expected_asset_id, + 'RGB contract import request.expected_asset_id' + ) + }) +} + +export function validateImportRgbTransferConsignmentResult (value, expectedAssetId) { + return validateImportResult( + value, + expectedAssetId, + 'RGB transfer consignment import result' + ) +} + +export function validateImportRgbContractResult (value, expectedAssetId) { + return validateImportResult(value, expectedAssetId, 'RGB contract import result') +} diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 8acc64d..7592478 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -60,6 +60,12 @@ import { validateCreateUtxosRequest, validatePreparedCreateUtxosResponse } from './rgb-utxo-setup-contract.js' +import { + validateImportRgbContractRequest, + validateImportRgbContractResult, + validateImportRgbTransferConsignmentRequest, + validateImportRgbTransferConsignmentResult +} from './rgb-import-contract.js' import { validateAddressReceipts } from './address-receipt-contract.js' export { PENDING_ADDRESS } @@ -1010,7 +1016,12 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL 'The installed RGB Lightning native binding does not expose importRgbTransferConsignment()' ) } - return this._node.importRgbTransferConsignment(request) + const validatedRequest = validateImportRgbTransferConsignmentRequest(request) + const result = await this._node.importRgbTransferConsignment(validatedRequest) + return validateImportRgbTransferConsignmentResult( + result, + validatedRequest.expected_asset_id + ) } /** @@ -1027,7 +1038,12 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL 'The installed RGB Lightning native binding does not expose importRgbContract()' ) } - return this._node.importRgbContract(request) + const validatedRequest = validateImportRgbContractRequest(request) + const result = await this._node.importRgbContract(validatedRequest) + return validateImportRgbContractResult( + result, + validatedRequest.expected_asset_id + ) } /** diff --git a/tests/rgb-import-contract.test.js b/tests/rgb-import-contract.test.js new file mode 100644 index 0000000..424df8b --- /dev/null +++ b/tests/rgb-import-contract.test.js @@ -0,0 +1,76 @@ +import { + validateImportRgbContractRequest, + validateImportRgbContractResult, + validateImportRgbTransferConsignmentRequest, + validateImportRgbTransferConsignmentResult +} from '../src/rgb-import-contract.js' + +const TXID = 'AB'.repeat(32) +const ASSET_ID = 'rgb:approved' + +describe('RGB import boundary contract', () => { + it('normalizes a transfer request without changing its binary payload', () => { + expect(validateImportRgbTransferConsignmentRequest({ + consignment_base64: 'Y29uc2lnbm1lbnQ=', + offchain_txid: TXID, + expected_asset_id: ASSET_ID + })).toEqual({ + consignment_base64: 'Y29uc2lnbm1lbnQ=', + offchain_txid: TXID.toLowerCase(), + expected_asset_id: ASSET_ID + }) + }) + + it('requires an exact, bounded transfer request shape', () => { + expect(() => validateImportRgbTransferConsignmentRequest({ + consignment_base64: '', + offchain_txid: TXID + })).toThrow('consignment_base64') + expect(() => validateImportRgbTransferConsignmentRequest({ + consignment_base64: 'YQ==', + offchain_txid: 'not-a-txid' + })).toThrow('offchain_txid') + expect(() => validateImportRgbTransferConsignmentRequest({ + consignment_base64: 'YQ==', + offchain_txid: TXID, + typo: true + })).toThrow('contain only') + }) + + it('requires an expected asset id for standalone contract imports', () => { + expect(validateImportRgbContractRequest({ + contract_base64: 'Y29udHJhY3Q=', + expected_asset_id: ASSET_ID + })).toEqual({ + contract_base64: 'Y29udHJhY3Q=', + expected_asset_id: ASSET_ID + }) + expect(() => validateImportRgbContractRequest({ + contract_base64: 'Y29udHJhY3Q=' + })).toThrow('contain expected_asset_id') + }) + + it('rejects native results for a different asset', () => { + const result = { + asset_id: 'rgb:other', + already_imported: false, + metadata: {} + } + expect(() => validateImportRgbContractResult(result, ASSET_ID)) + .toThrow('equal expected_asset_id') + expect(() => validateImportRgbTransferConsignmentResult(result, ASSET_ID)) + .toThrow('equal expected_asset_id') + }) + + it('accepts idempotent, metadata-only native results', () => { + expect(validateImportRgbContractResult({ + asset_id: ASSET_ID, + already_imported: true, + metadata: { ticker: 'USDT' } + }, ASSET_ID)).toEqual({ + asset_id: ASSET_ID, + already_imported: true, + metadata: { ticker: 'USDT' } + }) + }) +}) From b3b9ff0583f5b90ff131d7e13368acc0446d132c Mon Sep 17 00:00:00 2001 From: Jainakin Date: Fri, 14 Aug 2026 20:26:41 +0530 Subject: [PATCH 32/34] Advance native contract import smoke pin --- scripts/smoke-node-package.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-node-package.mjs b/scripts/smoke-node-package.mjs index 538da22..992375f 100644 --- a/scripts/smoke-node-package.mjs +++ b/scripts/smoke-node-package.mjs @@ -87,7 +87,7 @@ try { const nativePackageSpec = registryPackage ? `${nativePackage}@${nativeVersion}` : process.env.WDK_RGB_LIGHTNING_NODE_SPEC ?? - 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#ed494dabc6d4ea4c7c572dad0a3857b683f7e1f2' + 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#dd131addea8ed662c552e897e5fd3e2832f02e8b' writeFileSync( path.join(temporaryRoot, 'package.json'), From ef9845f26cd8f17c894faa538da537094b21a6b2 Mon Sep 17 00:00:00 2001 From: Jainakin Date: Mon, 17 Aug 2026 17:08:39 +0530 Subject: [PATCH 33/34] Pin merged Node native runtime --- scripts/smoke-node-package.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/smoke-node-package.mjs b/scripts/smoke-node-package.mjs index 992375f..4da662b 100644 --- a/scripts/smoke-node-package.mjs +++ b/scripts/smoke-node-package.mjs @@ -87,7 +87,7 @@ try { const nativePackageSpec = registryPackage ? `${nativePackage}@${nativeVersion}` : process.env.WDK_RGB_LIGHTNING_NODE_SPEC ?? - 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#dd131addea8ed662c552e897e5fd3e2832f02e8b' + 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#941568fb94410f43c5f59d2e5c4daf1843c9b245' writeFileSync( path.join(temporaryRoot, 'package.json'), From a3370badc4279fa8dfb20c93eef2e02fa871c2bb Mon Sep 17 00:00:00 2001 From: Jainakin Date: Tue, 1 Sep 2026 10:50:57 +0530 Subject: [PATCH 34/34] Require signed Lightning Address registration --- CHANGELOG.md | 10 ++++ README.md | 16 ++++-- index.d.ts | 17 +++++- package-lock.json | 4 +- package.json | 4 +- scripts/smoke-node-package.mjs | 10 ++-- src/bare-binding.js | 21 ++++++++ src/binding-interface.js | 6 +++ src/lsp-client.js | 7 +-- src/node-binding.js | 21 ++++++++ src/utexo-lsp.js | 74 +++++++++++++++++++++----- src/wallet-account-rgb-lightning.js | 17 ++++++ tests/bare-binding-methods.test.js | 14 +++++ tests/node-binding-methods.test.js | 26 ++++++++++ tests/utexo-lsp.test.js | 77 ++++++++++++++++++++++++++-- tests/wallet-account-surface.test.js | 23 +++++++++ 16 files changed, 315 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ab6ed..9bc836f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Address-attested APay across the Bare and Node bindings, account surface, and + composed LSP flow. `enableLightningAddress()` now resolves the + LSP-provisioned address before submitting exactly one signed hash batch and + fails closed when the generated native method is unavailable. Legacy + unattested registration requires explicit `requireAddressAttestation: false`. - Validated standalone RGB contract and transfer-consignment import boundaries. Requests are exact and bounded, transaction IDs are canonicalized, native responses are schema-checked, and an import fails closed if the returned @@ -62,6 +67,11 @@ while pre-`1.0`. whether the input used UMA form. ### Changed +- Raised native peer floors to `@utexo/rgb-lightning-node-bare + >=0.1.0-beta.20 <0.2.0` and `@utexo/rgb-lightning-node-nodejs + >=0.1.0-beta.16 <0.2.0`, the first published wrappers exposing + address-attested APay through their generated native APIs. The Node package + smoke now consumes the minimum registry peer and verifies that capability. - Raised native peer floors to `@utexo/rgb-lightning-node-bare >=0.1.0-beta.18 <0.2.0` and `@utexo/rgb-lightning-node-nodejs >=0.1.0-beta.14 <0.2.0`. These releases preserve duplicate-channel diff --git a/README.md b/README.md index 47545a3..66b00e0 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ are async and forward to the active binding. | WDK-standard | `index`, `path`, `keyPair`, `sign(message)`, `verify(message, signature)`, `transfer(options)`, `quoteTransfer(options)`, `quoteSendTransaction(tx)`, `getTransactionReceipt(hash)`, `toReadOnlyAccount()` | | Diagnostics | `sendOnionMessage(request)`, `checkIndexerUrl(url)`, `checkProxyEndpoint(endpoint)` | | VSS | `vssStatus()`, `vssBackup()`, `clearVssFence(password)` | -| APay / LSP | `apayNew(hostNodeId)`, `bootstrapLsp({ peerPubkeyAndAddr, hostNodeId? })`, `getLspConfig()`, `createLsp(peer?)` | +| APay / LSP | `apayNewWithAddress(hostNodeId, username, domain)`, `apayNew(hostNodeId)` (legacy), `bootstrapLsp({ peerPubkeyAndAddr, hostNodeId? })`, `getLspConfig()`, `createLsp(peer?)` | Notes: @@ -412,15 +412,23 @@ the wallet's behalf. Against a production LSP this requires `enableVirtualChannelsV0: true` and the LSP's node_id in `virtualPeerPubkeys`. -- `account.apayNew(hostNodeId)` — register with the LSP as an APay recipient - (`hostNodeId` is the LSP node_id, hex). Requires `lspBaseUrl` - (and `lspBearerToken` if the LSP enforces auth). +- `account.apayNewWithAddress(hostNodeId, username, domain)` — register one APay + hash batch carrying the wallet node's signed Lightning Address attestation. + `lsp.enableLightningAddress()` resolves the LSP-provisioned address first and + uses this method by default. +- `account.apayNew(hostNodeId)` — legacy unattested registration. It remains + available for compatibility, but `enableLightningAddress()` uses it only + when explicitly called with `{ requireAddressAttestation: false }`. - `account.bootstrapLsp({ peerPubkeyAndAddr, hostNodeId? })` — connect to the LSP peer, wait until it appears in `listPeers`, then (if `hostNodeId` is given) call `apayNew`. Refuses to register before the peer is visible to avoid RLN's host-response timeout (throws `ApayError` with code `APAY_PEER_NOT_VISIBLE`). +Do not call `apayNew` immediately before `enableLightningAddress`. The native +batch size can fill the LSP hash-pool cap in one request, so a second registration +may be rejected as `invalid_hash_batch`. + ## Security model - **Seed never leaves the host.** The mnemonic is owned by the WDK secret diff --git a/index.d.ts b/index.d.ts index a00c35b..100a7a5 100644 --- a/index.d.ts +++ b/index.d.ts @@ -730,6 +730,7 @@ export interface IRgbLightningBinding { vssDeleteAll(password: string): { deleted_keys: number } vssStatus(): VssStatus apayNew(hostNodeId: string): object + apayNewWithAddress(hostNodeId: string, username: string, domain: string): object shutdown(): void } @@ -747,6 +748,7 @@ export class NodeRgbLightningBinding implements IRgbLightningBinding { vssDeleteAll(password: string): { deleted_keys: number } vssStatus(): VssStatus apayNew(hostNodeId: string): object + apayNewWithAddress(hostNodeId: string, username: string, domain: string): object shutdown(): void static healthcheck(): string static isInitialized(): boolean @@ -768,6 +770,7 @@ export class BareRgbLightningBinding implements IRgbLightningBinding { vssDeleteAll(password: string): { deleted_keys: number } vssStatus(): VssStatus apayNew(hostNodeId: string): object + apayNewWithAddress(hostNodeId: string, username: string, domain: string): object shutdown(): void static healthcheck(): string static isInitialized(): boolean @@ -930,6 +933,8 @@ export class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbLightning // APay / LSP bootstrap /** @throws {ApayError} on LSP failure. */ apayNew(hostNodeId: string): Promise + /** Register a hash batch carrying a native signature for `username@domain`. */ + apayNewWithAddress(hostNodeId: string, username: string, domain: string): Promise bootstrapLsp(opts: { peerPubkeyAndAddr: string hostNodeId?: string @@ -1097,7 +1102,7 @@ export class LspClient { lnurlCallback(username: string, amountMsat: bigint | number | string, opts?: { assetId?: string; assetAmount?: bigint | number | string; timeoutMs?: number }): Promise<{ pr: string; routes?: unknown[] }> /** Full LUD-06 resolution routed through this LSP's baseUrl (discovery + callback). */ resolveAddress(username: string, amountMsat: bigint | number | string, opts?: { assetId?: string; assetAmount?: bigint | number | string; timeoutMs?: number }): Promise<{ pr: string; routes?: unknown[]; status?: string; reason?: string }> - /** Resolve the auto-assigned Lightning Address for a node pubkey (post-apayNew). */ + /** Resolve the LSP-provisioned Lightning Address for a node pubkey before attested APay registration. */ getLightningAddressByPubkey(peerPubkey: string, opts?: { timeoutMs?: number }): Promise<{ username: string; domain: string }> onchainSend(params: { rgbInvoice: string @@ -1283,6 +1288,14 @@ export interface LightningAddressInfo { address: string } +export interface EnableLightningAddressOptions { + /** + * Require native signed address attestation. Defaults to true. Set false + * only for an explicit compatibility downgrade to legacy `apayNew`. + */ + requireAddressAttestation?: boolean +} + export interface ClaimResult { paymentHash: string claimed: boolean @@ -1300,7 +1313,7 @@ export class UtexoLsp { waitForOutboundLiquidity(minMsat: number, opts?: WaitOptions): Promise sendAsset(opts: SendAssetOptions): Promise payAddress(opts: PayAddressOptions): Promise<{ invoice: string; sendResult: object }> - enableLightningAddress(): Promise + enableLightningAddress(opts?: EnableLightningAddressOptions): Promise claimPendingPayments(): Promise } diff --git a/package-lock.json b/package-lock.json index fba1230..f33daa4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,8 @@ "typescript": "5.8.3" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.19 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.15 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.20 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.16 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/package.json b/package.json index 7ddaec2..7bd7b18 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "sodium-universal": "5.0.1" }, "peerDependencies": { - "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.19 <0.2.0", - "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.15 <0.2.0" + "@utexo/rgb-lightning-node-bare": ">=0.1.0-beta.20 <0.2.0", + "@utexo/rgb-lightning-node-nodejs": ">=0.1.0-beta.16 <0.2.0" }, "peerDependenciesMeta": { "@utexo/rgb-lightning-node-bare": { diff --git a/scripts/smoke-node-package.mjs b/scripts/smoke-node-package.mjs index 4da662b..210898f 100644 --- a/scripts/smoke-node-package.mjs +++ b/scripts/smoke-node-package.mjs @@ -84,10 +84,8 @@ try { packageJson.peerDependencies[nativePackage], nativePackage ) - const nativePackageSpec = registryPackage - ? `${nativePackage}@${nativeVersion}` - : process.env.WDK_RGB_LIGHTNING_NODE_SPEC ?? - 'github:UTEXO-Protocol/rgb-lightning-node-nodejs#941568fb94410f43c5f59d2e5c4daf1843c9b245' + const nativePackageSpec = process.env.WDK_RGB_LIGHTNING_NODE_SPEC ?? + `${nativePackage}@${nativeVersion}` writeFileSync( path.join(temporaryRoot, 'package.json'), @@ -155,6 +153,7 @@ try { NodeRgbLightningBinding, WalletAccountReadOnlyRgbLightning } from '${packageJson.name}' + import { SdkNode as NativeSdkNode } from '${nativePackage}' const metadata = ( await import('${packageJson.name}/package', { @@ -177,6 +176,9 @@ try { if (typeof NodeRgbLightningBinding.healthcheck !== 'function') { throw new Error('The native healthcheck surface is missing') } + if (typeof NativeSdkNode.prototype.apayNewWithAddress !== 'function') { + throw new Error('The native address-attested APay method is missing') + } NodeRgbLightningBinding.healthcheck() ` diff --git a/src/bare-binding.js b/src/bare-binding.js index 3fc52d0..8a96033 100644 --- a/src/bare-binding.js +++ b/src/bare-binding.js @@ -339,6 +339,27 @@ export class BareRgbLightningBinding { return node.apayNew(hostNodeId) } + /** + * Register an APay hash batch with a signed Lightning Address attestation. + * + * @param {string} hostNodeId - LSP node ID. + * @param {string} username - LSP-provisioned Lightning Address username. + * @param {string} domain - LSP-provisioned Lightning Address domain. + * @returns {object} - Native `AsyncOrderNewResponse`. + * @throws {Error} - If the installed native wrapper predates this generated + * method or native APay registration fails. + */ + apayNewWithAddress (hostNodeId, username, domain) { + const node = this.ensureNode() + if (typeof node.apayNewWithAddress !== 'function') { + throw new Error( + 'Address-attested APay requires @utexo/rgb-lightning-node-bare ' + + 'with apayNewWithAddress support' + ) + } + return node.apayNewWithAddress(hostNodeId, username, domain) + } + /** * Stop the node and destroy the signer. The operation is idempotent. * diff --git a/src/binding-interface.js b/src/binding-interface.js index 3f85407..6c8d1fd 100644 --- a/src/binding-interface.js +++ b/src/binding-interface.js @@ -109,6 +109,12 @@ * accepts payments addressed to those hashes on the wallet's behalf * while the wallet is offline. Argument is the LSP's node_id (hex). * Returns the native AsyncOrderNewResponse unchanged. + * @property {(hostNodeId: string, username: string, domain: string) => object} apayNewWithAddress - + * Register an APay hash batch carrying the wallet node's signed Lightning + * Address attestation. Production Lightning Address registration uses this + * method so the LSP can bind the batch to `username@domain` without trusting + * caller-supplied identity data. Returns the native AsyncOrderNewResponse + * unchanged. * @property {() => void} shutdown - Idempotently release the node handle and * destroy the signer. */ diff --git a/src/lsp-client.js b/src/lsp-client.js index d4c011a..d352c82 100644 --- a/src/lsp-client.js +++ b/src/lsp-client.js @@ -278,9 +278,10 @@ export class LspClient { /** * Resolve the auto-assigned Lightning Address (`{ username, domain }`) - * the LSP minted for a node pubkey — i.e. the offline-receive address - * created as a side effect of `apayNew` / `async_order/new`. Give the - * resulting `username@domain` to senders. + * the LSP provisioned for a node pubkey. Provisioning can complete shortly + * after peer connection, before the wallet submits its address-attested + * APay batch, so callers should tolerate a temporary not-found response. + * Give the resulting `username@domain` to senders. * * Mirrors `@utexo/rgb-sdk-rn`'s * `UtexoLSPClient.getLightningAddressByPubkey`. diff --git a/src/node-binding.js b/src/node-binding.js index 6ed63be..56c9ee6 100644 --- a/src/node-binding.js +++ b/src/node-binding.js @@ -313,6 +313,27 @@ export class NodeRgbLightningBinding { return node.apayNew(hostNodeId) } + /** + * Register an APay hash batch with a signed Lightning Address attestation. + * + * @param {string} hostNodeId - LSP node ID. + * @param {string} username - LSP-provisioned Lightning Address username. + * @param {string} domain - LSP-provisioned Lightning Address domain. + * @returns {object} - Native `AsyncOrderNewResponse`. + * @throws {Error} - If the installed native wrapper predates this generated + * method or native APay registration fails. + */ + apayNewWithAddress (hostNodeId, username, domain) { + const node = this.ensureNode() + if (typeof node.apayNewWithAddress !== 'function') { + throw new Error( + 'Address-attested APay requires @utexo/rgb-lightning-node-nodejs ' + + 'with apayNewWithAddress support' + ) + } + return node.apayNewWithAddress(hostNodeId, username, domain) + } + /** * Stop the node and destroy the signer. The operation is idempotent. * diff --git a/src/utexo-lsp.js b/src/utexo-lsp.js index c8b4a00..48d10ab 100644 --- a/src/utexo-lsp.js +++ b/src/utexo-lsp.js @@ -111,6 +111,8 @@ export function normalizeReceiveStatus (raw) { const DEFAULT_CHANNEL_TIMEOUT_MS = 120_000 const DEFAULT_SETTLEMENT_TIMEOUT_MS = 60_000 const DEFAULT_POLL_INTERVAL_MS = 2_000 +const LIGHTNING_ADDRESS_LOOKUP_ATTEMPTS = 8 +const LIGHTNING_ADDRESS_LOOKUP_DELAY_MS = 2_000 // ── UtexoLsp ───────────────────────────────────────────────────────────────── @@ -120,8 +122,8 @@ export class UtexoLsp { * * @param {object} account - A `WalletAccountRgbLightning` or compatible * exposing connectPeer, sync, listChannels, createLightningInvoice, - * getInvoiceStatus, sendPayment, getNodeInfo, apayNew, listPayments, - * claimHodlInvoice. + * getInvoiceStatus, sendPayment, getNodeInfo, apayNewWithAddress, + * apayNew, listPayments, claimHodlInvoice. * @param {object} peer - LSP peer details: `{ baseUrl, peerPubkey, peerHost, * peerPort, bearerToken?, timeoutMs?, allowHttp? }`. * @throws {TypeError} - If the account or peer base URL is missing or @@ -407,29 +409,46 @@ export class UtexoLsp { // ── 8. Async / offline receive (APay) ───────────────────────────────────────── /** - * Register the async-payment hash pool with this LSP, then read back - * the auto-assigned Lightning Address for this wallet's pubkey. Call - * once after first unlock to enable offline receive. + * Register the async-payment hash pool with this LSP and return the + * auto-assigned Lightning Address for this wallet's pubkey. Call once after + * first unlock to enable offline receive. * + * The LSP provisions the address before registration. The production path + * resolves that address first and registers exactly one signed batch through + * `apayNewWithAddress`. Calling legacy `apayNew` first can consume the hash + * pool capacity and leaves the address ownership unattested. + * + * @param {object} [opts] - Registration policy. + * @param {boolean} [opts.requireAddressAttestation=true] - Require the + * generated native address-attestation method. Set to `false` only for an + * explicit legacy compatibility downgrade. * @returns {Promise<{ username:string, domain:string, address:string }>} - Auto-assigned * Lightning Address components and full address. * @throws {LspError} - If LSP information or address lookup fails. * @throws {Error} - If the wallet is locked, the LSP response is malformed, * or APay registration fails. */ - async enableLightningAddress () { - const nodeInfo = await this.account.getNodeInfo() - const pubkey = String(nodeInfo?.pubkey ?? '') - if (!pubkey) throw new Error('UtexoLsp.enableLightningAddress: wallet not unlocked (no pubkey)') - + async enableLightningAddress ({ requireAddressAttestation = true } = {}) { + const addr = await this._ownLightningAddress('UtexoLsp.enableLightningAddress') const lspInfo = await this.http.getInfo() const lspPubkey = lspInfo?.pubkey if (typeof lspPubkey !== 'string' || lspPubkey.length === 0) { throw new Error('UtexoLsp.enableLightningAddress: LSP /get_info returned no pubkey') } - await this.account.apayNew(lspPubkey) - const addr = await this.http.getLightningAddressByPubkey(pubkey) + if (requireAddressAttestation) { + if (typeof this.account.apayNewWithAddress !== 'function') { + throw new Error( + 'UtexoLsp.enableLightningAddress: address-attested APay is unavailable; ' + + 'install compatible native wrappers or explicitly set ' + + 'requireAddressAttestation to false for legacy registration' + ) + } + await this.account.apayNewWithAddress(lspPubkey, addr.username, addr.domain) + } else { + await this.account.apayNew(lspPubkey) + } + return { username: addr.username, domain: addr.domain, address: `${addr.username}@${addr.domain}` } } @@ -517,6 +536,37 @@ export class UtexoLsp { return obj[camel] ?? obj[snake] } + async _ownLightningAddress (context) { + const nodeInfo = await this.account.getNodeInfo() + const pubkey = String(nodeInfo?.pubkey ?? '') + if (!pubkey) throw new Error(`${context}: wallet not unlocked (no pubkey)`) + + let lastError + for (let attempt = 0; attempt < LIGHTNING_ADDRESS_LOOKUP_ATTEMPTS; attempt += 1) { + try { + const address = await this.http.getLightningAddressByPubkey(pubkey) + if ( + typeof address?.username === 'string' && address.username.length > 0 && + typeof address?.domain === 'string' && address.domain.length > 0 + ) { + return address + } + lastError = new Error('LSP returned an incomplete Lightning Address') + } catch (error) { + lastError = error + } + + if (attempt + 1 < LIGHTNING_ADDRESS_LOOKUP_ATTEMPTS) { + await this._sleep(LIGHTNING_ADDRESS_LOOKUP_DELAY_MS) + } + } + + throw new Error( + `${context}: LSP did not provision a Lightning Address for ${pubkey}. ` + + `Last error: ${String(lastError)}` + ) + } + _checkAbort (signal) { if (signal?.aborted) throw new Error('UtexoLsp: operation aborted') } diff --git a/src/wallet-account-rgb-lightning.js b/src/wallet-account-rgb-lightning.js index 7592478..d61ced8 100644 --- a/src/wallet-account-rgb-lightning.js +++ b/src/wallet-account-rgb-lightning.js @@ -377,6 +377,23 @@ export default class WalletAccountRgbLightning extends WalletAccountReadOnlyRgbL } } + /** + * Register an APay hash batch and bind it to an LSP-provisioned Lightning + * Address using the wallet node's native signature. + * + * @param {string} hostNodeId - LSP node ID. + * @param {string} username - Lightning Address username assigned by the LSP. + * @param {string} domain - Lightning Address domain assigned by the LSP. + * @returns {Promise} Native `AsyncOrderNewResponse`. + */ + async apayNewWithAddress (hostNodeId, username, domain) { + try { + return this._binding.apayNewWithAddress(hostNodeId, username, domain) + } catch (e) { + throw wrapError(e, ApayError) + } + } + /** * One-shot LSP bootstrap for consumers that want to connect the LSP peer * and optionally register for APay after unlock. Keeping this separate diff --git a/tests/bare-binding-methods.test.js b/tests/bare-binding-methods.test.js index 378d013..680ddd6 100644 --- a/tests/bare-binding-methods.test.js +++ b/tests/bare-binding-methods.test.js @@ -20,6 +20,7 @@ function fakeNode () { vssBackup: jest.fn(() => ({ version: 7 })), vssDeleteAll: jest.fn(() => ({ deleted_keys: 12 })), apayNew: jest.fn(() => ({ order_id: 'order-1' })), + apayNewWithAddress: jest.fn(() => ({ order_id: 'order-2' })), detachExternalSigner: jest.fn(), shutdown: jest.fn() } @@ -297,8 +298,10 @@ describe('BareRgbLightningBinding', () => { lastBackupVersion: 7 }) expect(binding.apayNew('02host')).toEqual({ order_id: 'order-1' }) + expect(binding.apayNewWithAddress('02host', 'alice', 'lsp.example')).toEqual({ order_id: 'order-2' }) expect(node.vssClearFence).toHaveBeenCalledWith({ password: 'pw' }) expect(node.apayNew).toHaveBeenCalledWith('02host') + expect(node.apayNewWithAddress).toHaveBeenCalledWith('02host', 'alice', 'lsp.example') node.vssBackup .mockReturnValueOnce({ version: 'unknown' }) @@ -316,6 +319,17 @@ describe('BareRgbLightningBinding', () => { }) }) + it('fails closed when the installed Bare wrapper lacks address attestation', () => { + const binding = makeBinding() + const node = fakeNode() + delete node.apayNewWithAddress + binding._node = node + + expect(() => binding.apayNewWithAddress('02host', 'alice', 'lsp.example')) + .toThrow('Address-attested APay requires @utexo/rgb-lightning-node-bare') + expect(node.apayNew).not.toHaveBeenCalled() + }) + it('cleans up the signer and retained seeds when node shutdown fails', () => { const binding = makeBinding() const node = fakeNode() diff --git a/tests/node-binding-methods.test.js b/tests/node-binding-methods.test.js index e6e0587..3fd5e01 100644 --- a/tests/node-binding-methods.test.js +++ b/tests/node-binding-methods.test.js @@ -54,6 +54,7 @@ function fakeNode () { vssBackup: jest.fn(() => ({ version: 7 })), vssDeleteAll: jest.fn(() => ({ deleted_keys: 12 })), apayNew: jest.fn(() => realAsyncOrderNewResponse()), + apayNewWithAddress: jest.fn(() => realAsyncOrderNewResponse()), detachExternalSigner: jest.fn(), shutdown: jest.fn() } @@ -553,6 +554,31 @@ describe('apayNew', () => { }) }) +describe('apayNewWithAddress', () => { + it('forwards all attestation fields and preserves the native response', () => { + const binding = makeBinding() + const node = fakeNode() + const response = realAsyncOrderNewResponse() + node.apayNewWithAddress.mockReturnValue(response) + binding._node = node + + expect(binding.apayNewWithAddress('02hostid', 'alice', 'lsp.example')).toBe(response) + expect(node.apayNewWithAddress).toHaveBeenCalledWith('02hostid', 'alice', 'lsp.example') + expect(node.apayNew).not.toHaveBeenCalled() + }) + + it('fails closed when the installed Node wrapper lacks address attestation', () => { + const binding = makeBinding() + const node = fakeNode() + delete node.apayNewWithAddress + binding._node = node + + expect(() => binding.apayNewWithAddress('02hostid', 'alice', 'lsp.example')) + .toThrow('Address-attested APay requires @utexo/rgb-lightning-node-nodejs') + expect(node.apayNew).not.toHaveBeenCalled() + }) +}) + describe('shutdown', () => { it('shuts down node + signer, nulls them and resets _sdkInitDone', () => { const b = makeBinding() diff --git a/tests/utexo-lsp.test.js b/tests/utexo-lsp.test.js index 2b71c11..a1408d3 100644 --- a/tests/utexo-lsp.test.js +++ b/tests/utexo-lsp.test.js @@ -40,6 +40,7 @@ function makeAccount (overrides = {}) { sendPayment: jest.fn(async () => ({ payment_hash: 'ph' })), getNodeInfo: jest.fn(async () => ({ pubkey: 'mynodepubkey' })), apayNew: jest.fn(async () => ({ ok: true })), + apayNewWithAddress: jest.fn(async () => ({ ok: true })), listPayments: jest.fn(async () => []), claimHodlInvoice: jest.fn(async () => ({ ok: true })), ...overrides @@ -718,13 +719,20 @@ describe('payAddress', () => { // ── enableLightningAddress ───────────────────────────────────────────────── describe('enableLightningAddress', () => { - it('registers the apay pool and reads back the assigned Lightning Address', async () => { + it('resolves the assigned address before registering one attested APay batch', async () => { const account = makeAccount({ getNodeInfo: jest.fn(async () => ({ pubkey: 'wallet-pk' })) }) const lsp = makeLsp(account) const out = await lsp.enableLightningAddress() expect(lsp.http.getInfo).toHaveBeenCalled() - expect(account.apayNew).toHaveBeenCalledWith('lsppubkey') expect(lsp.http.getLightningAddressByPubkey).toHaveBeenCalledWith('wallet-pk') + expect(account.apayNewWithAddress).toHaveBeenCalledWith( + 'lsppubkey', + 'alice', + 'lsp.example.io' + ) + expect(account.apayNew).not.toHaveBeenCalled() + expect(lsp.http.getLightningAddressByPubkey.mock.invocationCallOrder[0]) + .toBeLessThan(account.apayNewWithAddress.mock.invocationCallOrder[0]) expect(out).toEqual({ username: 'alice', domain: 'lsp.example.io', address: 'alice@lsp.example.io' }) }) @@ -732,7 +740,7 @@ describe('enableLightningAddress', () => { const account = makeAccount({ getNodeInfo: jest.fn(async () => ({})) }) const lsp = makeLsp(account) await expect(lsp.enableLightningAddress()).rejects.toThrow('wallet not unlocked') - expect(account.apayNew).not.toHaveBeenCalled() + expect(account.apayNewWithAddress).not.toHaveBeenCalled() }) it('throws when the LSP /get_info returns no pubkey', async () => { @@ -740,6 +748,69 @@ describe('enableLightningAddress', () => { const lsp = makeLsp(account) lsp.http.getInfo = jest.fn(async () => ({})) await expect(lsp.enableLightningAddress()).rejects.toThrow('returned no pubkey') + expect(account.apayNewWithAddress).not.toHaveBeenCalled() + }) + + it('fails closed when address attestation is unavailable', async () => { + const account = makeAccount({ + getNodeInfo: jest.fn(async () => ({ pubkey: 'wallet-pk' })), + apayNewWithAddress: undefined + }) + const lsp = makeLsp(account) + + await expect(lsp.enableLightningAddress()).rejects.toThrow('address-attested APay is unavailable') + expect(account.apayNew).not.toHaveBeenCalled() + }) + + it('uses legacy registration only after an explicit policy downgrade', async () => { + const account = makeAccount({ + getNodeInfo: jest.fn(async () => ({ pubkey: 'wallet-pk' })), + apayNewWithAddress: undefined + }) + const lsp = makeLsp(account) + + await expect(lsp.enableLightningAddress({ requireAddressAttestation: false })) + .resolves.toEqual({ + username: 'alice', + domain: 'lsp.example.io', + address: 'alice@lsp.example.io' + }) + expect(account.apayNew).toHaveBeenCalledWith('lsppubkey') + }) + + it('retries while the LSP is still provisioning the address account', async () => { + const account = makeAccount({ getNodeInfo: jest.fn(async () => ({ pubkey: 'wallet-pk' })) }) + const lsp = makeLsp(account) + lsp._sleep = jest.fn(async () => {}) + lsp.http.getLightningAddressByPubkey + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce({ username: '', domain: '' }) + .mockResolvedValueOnce({ username: 'alice', domain: 'lsp.example.io' }) + + await expect(lsp.enableLightningAddress()).resolves.toEqual({ + username: 'alice', + domain: 'lsp.example.io', + address: 'alice@lsp.example.io' + }) + expect(lsp.http.getLightningAddressByPubkey).toHaveBeenCalledTimes(3) + expect(lsp._sleep).toHaveBeenCalledTimes(2) + expect(account.apayNewWithAddress).toHaveBeenCalledTimes(1) + }) + + it('does not register a batch when address provisioning never completes', async () => { + const account = makeAccount({ getNodeInfo: jest.fn(async () => ({ pubkey: 'wallet-pk' })) }) + const lsp = makeLsp(account) + lsp._sleep = jest.fn(async () => {}) + lsp.http.getLightningAddressByPubkey = jest.fn(async () => { + throw new Error('not found') + }) + + await expect(lsp.enableLightningAddress()).rejects.toThrow( + 'LSP did not provision a Lightning Address for wallet-pk' + ) + expect(lsp.http.getLightningAddressByPubkey).toHaveBeenCalledTimes(8) + expect(lsp._sleep).toHaveBeenCalledTimes(7) + expect(account.apayNewWithAddress).not.toHaveBeenCalled() expect(account.apayNew).not.toHaveBeenCalled() }) }) diff --git a/tests/wallet-account-surface.test.js b/tests/wallet-account-surface.test.js index 88a0d1c..43b0208 100644 --- a/tests/wallet-account-surface.test.js +++ b/tests/wallet-account-surface.test.js @@ -441,6 +441,29 @@ describe('apayNew', () => { expect(err).toBeInstanceOf(ApayError) expect(err.message).toBe('lsp unreachable') }) + + it('forwards signed Lightning Address registration without downgrading', async () => { + const response = { order_id: 'order-attested' } + const apayNew = jest.fn() + const apayNewWithAddress = jest.fn(() => response) + const account = makeAccount({ apayNew, apayNewWithAddress }) + + await expect(account.apayNewWithAddress('host', 'alice', 'lsp.example')).resolves.toBe(response) + expect(apayNewWithAddress).toHaveBeenCalledWith('host', 'alice', 'lsp.example') + expect(apayNew).not.toHaveBeenCalled() + }) + + it('wraps address-attestation failures in ApayError', async () => { + const account = makeAccount({ + apayNewWithAddress: () => { throw new Error('attestation rejected') } + }) + const error = await account + .apayNewWithAddress('host', 'alice', 'lsp.example') + .catch((cause) => cause) + + expect(error).toBeInstanceOf(ApayError) + expect(error.message).toBe('attestation rejected') + }) }) describe('node info / network / sync', () => {