Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6e4af2f
Add coherent wallet snapshot refresh
Jainakin Jul 21, 2026
7afaecc
Harden wallet snapshot coherence
Jainakin Jul 21, 2026
5ce1f6c
fix: activate RGB account before address discovery
Jainakin Jul 22, 2026
c5441b2
fix: expose safe snapshot contract diagnostics
Jainakin Jul 22, 2026
fcbbfa1
fix: normalize legacy snapshot network casing
Jainakin Jul 22, 2026
7e94f44
fix: preserve signer through manager disposal
Jainakin Jul 27, 2026
c97f810
Recover stale VSS fence during opt-in unlock
Jainakin Jul 28, 2026
028aa68
Document external-signer VSS identity
Jainakin Jul 28, 2026
0359fc7
feat: expose Lightning fee controls and evidence
Jainakin Jul 28, 2026
fee0e13
Merge remote-tracking branch 'origin/main' into iris-wallet
Jainakin Jul 28, 2026
65c85f8
feat: expose deterministic on-chain send plans
Jainakin Jul 28, 2026
61c518a
feat: harden wallet send and receive contracts
Jainakin Jul 28, 2026
fad7242
fix: require complete Lightning invoice metadata
Jainakin Jul 28, 2026
99954bf
fix: expose stable RGB invoice decode metadata
Jainakin Jul 28, 2026
b036469
fix: require recoverable Lightning bindings
Jainakin Jul 28, 2026
2502bee
fix: persist VLS signer state across restarts
Jainakin Jul 28, 2026
dbec00c
fix: require RGB witness output data
Jainakin Jul 29, 2026
d7c409f
fix: reconcile RGB transfers before snapshots
Jainakin Jul 29, 2026
97afa4b
feat: add explicit RGB wallet UTXO setup
Jainakin Jul 29, 2026
9fbe2a9
fix: validate RGB unspent allocation state
Jainakin Jul 29, 2026
fe1509c
feat: add coherent snapshots and native operation control
Jainakin Jul 29, 2026
20ce17b
fix: enforce native operation lifecycle contract
Jainakin Jul 30, 2026
d98af10
fix: serialize address discovery with native unlock
Jainakin Jul 30, 2026
e5ff92a
fix: propagate Lightning failures and teardown
Jainakin Jul 31, 2026
d8a1660
Retry transient partial wallet synchronization
Jainakin Jul 31, 2026
c41498a
fix: validate durable wallet snapshots
Jainakin Aug 1, 2026
83d0a72
Fix mutually exclusive chain backend config
Jainakin Aug 2, 2026
1ae1183
Expose RGB transfer consignment import in WDK
Jainakin Aug 4, 2026
96e3996
Consume typed LSP discovery policy
Jainakin Aug 4, 2026
65727de
Expose LSP discovery parser subpath
Jainakin Aug 4, 2026
c00fa73
Expose RGB contract preload API
Jainakin Aug 4, 2026
3dcbc56
Harden RGB import boundary
Jainakin Aug 14, 2026
b3b9ff0
Advance native contract import smoke pin
Jainakin Aug 14, 2026
ef9845f
Pin merged Node native runtime
Jainakin Aug 17, 2026
a3370ba
Require signed Lightning Address registration
Jainakin Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,94 @@ 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
- 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
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.
- 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
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 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.
- 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.
- 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`,
`normalizeLightningAddress`, `UMA_PREFIX`, and `UMA_MAX_USERNAME_LENGTH`;
`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.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
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
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
metadata through the C-FFI decode response.

## [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,
Expand All @@ -39,6 +114,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
Expand Down
96 changes: 74 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -159,6 +158,8 @@ 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. |
| `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). |

Expand All @@ -171,23 +172,50 @@ 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)` |
| 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)`, `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)`, `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)` |
| 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:

- **`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
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.
- **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
(`{ amountMsat?, expirySec, assetId?, assetAmount?, paymentHash?,
Expand All @@ -196,15 +224,20 @@ 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.
- **`getAddress()` never returns a fabricated spend address.** Before unlock
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.
Expand Down Expand Up @@ -341,10 +374,18 @@ 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`.

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
Expand All @@ -355,6 +396,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`.
Expand All @@ -367,23 +412,30 @@ 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
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
Expand Down
3 changes: 3 additions & 0 deletions index-bare.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export {
VssError,
VssNotConfiguredError,
ApayError,
WalletSyncError,
WalletSnapshotError,
NotImplementedError
} from './src/errors.js'

Expand All @@ -36,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,
Expand Down
Loading