diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..de9a46c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.patch whitespace=-blank-at-eol,-space-before-tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..392a21e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + push: + branches: [main, iris-wallet] + +permissions: + contents: read + +env: + RLN_VERSION: v0.11.0-beta.3 + RUST_TOOLCHAIN: '1.88.0' + +jobs: + contract: + runs-on: macos-14 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - name: Clone pinned rgb-lightning-node source + run: | + set -euo pipefail + RLN_DIR="$(cd "$GITHUB_WORKSPACE/../.." && pwd)/rgb-lightning-node" + git clone --recurse-submodules --shallow-submodules --depth 1 \ + --branch "$RLN_VERSION" \ + https://github.com/UTEXO-Protocol/rgb-lightning-node.git "$RLN_DIR" + git -C "$RLN_DIR" apply --check \ + "$GITHUB_WORKSPACE/patches/c-ffi-utexo-patches-$RLN_VERSION.patch" + git -C "$RLN_DIR" apply \ + "$GITHUB_WORKSPACE/patches/c-ffi-utexo-patches-$RLN_VERSION.patch" + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt + # The pinned rust-lightning dependency emits one release-only warning. + # Keep it visible without converting third-party warnings into errors. + rustflags: '' + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: Install JavaScript dependencies + run: npm ci --ignore-scripts + + - name: Verify native patch integrity and contract tests + run: | + git -C ../../rgb-lightning-node diff --check + cargo test --locked --manifest-path ../../rgb-lightning-node/bindings/c-ffi/Cargo.toml wallet_snapshot + cargo test --locked --manifest-path ../../rgb-lightning-node/Cargo.toml wallet_sync_mode + + - name: Build the host Bare addon and run the canary + run: | + case "$(uname -m)" in + arm64) HOST_TARGET=darwin-arm64 ;; + x86_64) HOST_TARGET=darwin-x64 ;; + *) echo "Unsupported host architecture: $(uname -m)"; exit 1 ;; + esac + npm run check:types + bash scripts/build-cffi.sh darwin + bash scripts/build-prebuilds.sh "$HOST_TARGET" + npm test + + - name: Install pinned Android NDK + id: setup_ndk + run: | + echo "ndk-path=$(bash scripts/install-android-ndk.sh)" >> "$GITHUB_OUTPUT" + + - name: Build and verify every supported Android addon + env: + ANDROID_NDK_HOME: ${{ steps.setup_ndk.outputs.ndk-path }} + RLN_BARE_SOURCE_DIR: ${{ github.workspace }}/../../rgb-lightning-node + run: node scripts/install-native-artifacts.js --platform android diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b53ad51..739f5a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,17 +67,17 @@ jobs: os: android target: aarch64-linux-android platform: android-arm64 - ndk_linker: aarch64-linux-android24-clang + ndk_linker: aarch64-linux-android29-clang - runner: macos-latest os: android target: armv7-linux-androideabi platform: android-arm - ndk_linker: armv7a-linux-androideabi24-clang + ndk_linker: armv7a-linux-androideabi29-clang - runner: macos-latest os: android target: x86_64-linux-android platform: android-x64 - ndk_linker: x86_64-linux-android24-clang + ndk_linker: x86_64-linux-android29-clang runs-on: ${{ matrix.runner }} steps: - name: Checkout repository @@ -136,12 +136,11 @@ jobs: if: matrix.os == 'android' run: cargo install --force --locked bindgen-cli --version 0.72.1 - - name: Setup Android NDK + - name: Install pinned Android NDK if: matrix.os == 'android' - uses: nttld/setup-ndk@v1 id: setup_ndk - with: - ndk-version: r27c + run: | + echo "ndk-path=$(bash scripts/install-android-ndk.sh)" >> "$GITHUB_OUTPUT" - name: Pin signer-external to RLN-compatible commit env: @@ -209,7 +208,7 @@ jobs: export RANLIB_${TARGET_UNDERSCORE}="$TOOLCHAIN/bin/llvm-ranlib" # bindgen needs the Android sysroot for non-arm64 targets. - export BINDGEN_EXTRA_CLANG_ARGS="--sysroot=$TOOLCHAIN/sysroot -target ${TARGET}24" + export BINDGEN_EXTRA_CLANG_ARGS="--sysroot=$TOOLCHAIN/sysroot -target ${TARGET}29" cargo rustc --release --target ${{ matrix.target }} --crate-type staticlib "$TOOLCHAIN/bin/llvm-strip" --strip-debug "target/${{ matrix.target }}/release/librlncffi.a" || true @@ -264,12 +263,11 @@ jobs: name: librlncffi-${{ matrix.platform }} path: lib/${{ matrix.platform }} - - name: Setup Android NDK + - name: Install pinned Android NDK if: startsWith(matrix.platform, 'android-') - uses: nttld/setup-ndk@v1 id: setup_ndk - with: - ndk-version: r27c + run: | + echo "ndk-path=$(bash scripts/install-android-ndk.sh)" >> "$GITHUB_OUTPUT" - name: Build prebuild (Apple) if: ${{ !startsWith(matrix.platform, 'android-') }} @@ -301,7 +299,7 @@ jobs: -Dcmake-npm_DIR="$PWD/node_modules/cmake-npm" \ -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" \ -DANDROID_ABI=${{ matrix.android_abi }} \ - -DANDROID_PLATFORM=android-24 \ + -DANDROID_PLATFORM=android-29 \ -DANDROID_ALLOW_UNDEFINED_SYMBOLS=TRUE cmake --build build-tmp BARE=$(find build-tmp -name "*.bare" -type f | head -1) diff --git a/.gitignore b/.gitignore index 429ae19..0d4e638 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build artefacts (large; distributed via GitHub Releases like rgb-lib-bare) lib/ prebuilds/ +.utexo-native-overlay.json # CMake build dirs build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index aea7d81..412de8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +- Persist a local VSS writer identity so abrupt process restarts reclaim their + own fence without enabling automatic cross-installation fence clearing. +- Publish that identity with an Android-safe exclusive-create protocol instead + of filesystem hard links, with bounded concurrent-reader retry and + fail-closed corruption handling. + +## 0.1.0-beta.19 + +- Expose the disk-backed native VLS signer required for channels to survive + mobile process restarts. +- Bind native artifacts to the exact overlay commit, patch, toolchain, targets, + and content hashes so stale binaries cannot satisfy a newer package contract. +- Add a production-shaped RGB payment regression for a persistent external + signer accepting an inbound trusted virtual channel. + All notable changes to `@utexo/rgb-lightning-node-bare` are documented here. @@ -10,6 +27,37 @@ while pre-`1.0`. ## [Unreleased] ### Added +- Reproducible Android overlay artifacts for `arm64-v8a`, `armeabi-v7a`, + and `x86_64`, built from the same pinned native patch as iOS with exact + Rust, NDK, API-level, cargo-ndk, and bindgen inputs. +- Platform-scoped native preparation for local and EAS builds, incremental + cross-platform artifact provenance, and ELF-aware exported-symbol checks. +- Authoritative `listAddressReceipts(address)` settlement evidence backed by + the configured Electrum or Esplora indexer, including exact received + satoshis, transaction IDs, block heights, and confirmation counts. +- Deterministic BTC and RGB on-chain send plans. `prepareBtcSend()` and + `prepareRgbSend()` reserve the exact unsigned plan inside the native wallet + and return only its opaque transaction identity, fee, input/output totals, + virtual size, and RGB batch identity. `commitPreparedBtcSend()` and + `commitPreparedRgbSend()` idempotently validate and submit that exact + native plan without exposing PSBT material to JavaScript. +- Explicit RGB wallet UTXO setup plans. `prepareCreateUtxos()` reserves an + exact native transaction and returns only review-safe fee, input, output, + virtual-size, target-count, and output-size data. + `commitPreparedCreateUtxos()` signs and broadcasts that exact plan, while + `cancelCreateUtxosPlan()` releases only a matching setup reservation. +- Preserve `pending_blinded` in every `listUnspents()` item so callers can + distinguish a genuinely free RGB allocation slot from a receive-reserved + colorable UTXO. +- Idempotent BTC and RGB plan cancellation plus bounded pending-plan + inspection, allowing a wallet to release abandoned send reservations + without touching channel or UTXO-management operations. +- `SdkNode.syncWallet()` and `SdkNode.walletSnapshot()` with the same pinned + native overlay as NodeJS: dual-keychain + FullSync/FullScan modes, bounded activity, coherent tip evidence, and + decimal-string amounts. +- Strict public TypeScript declarations and pull-request CI that builds and + executes the host Bare addon against the pinned native contract. - `SdkNode.assetLinkCreate(request)` for the RLN v0.11 parent/child RGB asset-link contract. - `SdkNode.verifyMessage(message, signature)` with canonical Lightning @@ -18,8 +66,12 @@ while pre-`1.0`. `listTransfersByTxid()` wrappers required by WDK's read-only account. - A release smoke test that loads the built Darwin addon and exercises node creation, external-signer initialization, and locked-state verification. +- An explicit `RLN_BARE_JS_ONLY_INSTALL=1` mode for non-native CI tooling; + native app paths continue to require symbol-verified artifacts. ### Changed +- Android Bare addons have debug sections stripped with the pinned NDK + toolchain before hashing and packaging. - Updated the transaction and transfer query bindings for the consolidated RLN v0.11 C-FFI filter signatures while retaining the existing JavaScript convenience methods. @@ -27,8 +79,33 @@ while pre-`1.0`. from upstream when no overlay exists. - CI and local package tests use the Bare runtime explicitly and reproducible `npm ci` installs. +- Unsupported non-macOS Apple source builds fail with a direct platform error. ### Fixed +- Prepared RGB UTXO setup atomically isolates allocation outputs on a fresh + colored address and advances the receive address again before returning the + plan. Existing and future witness invoices can no longer quarantine setup + outputs as `pending_witness`. +- Explicit node shutdown and signer destruction now release their native + handles immediately, including persistent signer database locks, instead of + waiting for nondeterministic garbage collection. +- Reopening a trusted virtual channel no longer fails after the previous + channel was safely abandoned. Active and abandon-pending sessions still + block duplicate opens; only the terminal abandoned state is reusable. +- `decodeRgbInvoice()` now returns a stable tagged assignment object instead + of an implementation-defined Rust `Debug` string. The exact blind/witness + recipient type and nullable expiration remain preserved. +- `decodeLnInvoice()` now preserves `min_final_cltv_expiry_delta` across the + C-FFI JSON boundary. A native contract test guards the complete mobile + response shape so the React Native runtime cannot silently lose CLTV data. +- C-FFI network information now emits canonical lowercase network names, + matching the public TypeScript contract and wallet snapshot contract v1. +- Git-commit consumers now build the checksum-pinned C-FFI overlay for the + declared iOS targets (or import explicitly supplied, symbol-verified CI + artifacts) instead of silently linking older release binaries. Registry + packages without overlay metadata retain the release-asset installer. The + CMake packages needed by this production install path are runtime build + dependencies rather than dev-only dependencies. - Replaced the nonexistent `cmake-bare-rebuild` package script with the repository's supported prebuild script. - Release version commits now include `package-lock.json`. diff --git a/CMakeLists.txt b/CMakeLists.txt index af0c5c0..c1ba53f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,7 +54,6 @@ if(target MATCHES "ios") "-framework Security" "-framework SystemConfiguration" "-framework CoreFoundation" - "-lc++" "-lz" "-lsqlite3" "-lresolv" @@ -66,7 +65,6 @@ elseif(target MATCHES "darwin") "-framework Security" "-framework SystemConfiguration" "-framework CoreFoundation" - "-lc++" "-lz" "-lsqlite3" "-lresolv" diff --git a/README.md b/README.md index e1894ff..d91ec19 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Static linking is mandatory on iOS and yields a single self-contained ## Requirements -- Node.js >= 18 (for `cmake-bare` and the postinstall script) +- Node.js >= 20 (for `cmake-bare` and the postinstall script) - [Bare] runtime (to actually load and run the addon) ## Installation @@ -171,19 +171,27 @@ creating any node. |-------|---------| | Lifecycle | `create`, `init`, `unlock`, `shutdown` | | External signer | `initWithNativeExternalSigner`, `attachNativeExternalSigner`, `unlockWithNativeExternalSigner`, `initWithExternalSigner`, `unlockWithAttachedExternalSigner`, `detachExternalSigner` | -| Info / sync | `nodeInfo`, `networkInfo`, `sync`, `address`, `rotateAddress` | +| Info / sync | `nodeInfo`, `networkInfo`, `sync` (legacy), `syncWallet`, `walletSnapshot`, `address` / `getAddress`, `rotateAddress` | | Peers | `connectPeer`, `disconnectPeer`, `listPeers` | | Channels | `openChannel`, `closeChannel`, `listChannels`, `getChannelId` | | Invoices | `lnInvoice`, `decodeLnInvoice`, `invoiceStatus`, `rgbInvoice`, `decodeRgbInvoice`, `cancelHodlInvoice`, `claimHodlInvoice` | | Payments | `sendPayment`, `keysend`, `listPayments`, `getPayment` | | Swaps | `makerInit`, `makerExecute`, `taker`, `listSwaps`, `getSwap` | | RGB issuance | `issueAssetNia`, `issueAssetUda`, `issueAssetCfa`, `issueAssetIfa` | -| RGB assets | `listAssets`, `assetBalance`, `assetLinkCreate`, `assetMetadata`, `sendRgb`, `inflate`, `listTransfers`, `listTransfersByTxid`, `refreshTransfers`, `failTransfers`, `getAssetMedia`, `postAssetMedia` | -| BTC | `btcBalance`, `sendBtc`, `listTransactions`, `listTransactionsByTxid`, `listUnspents`, `createUtxos`, `estimateFee` | +| RGB assets | `listAssets`, `assetBalance`, `assetLinkCreate`, `assetMetadata`, `sendRgb`, `prepareRgbSend`, `commitPreparedRgbSend`, `cancelRgbSendPlan`, `listPendingRgbSendPlans`, `importRgbTransferConsignment`, `importRgbContract`, `inflate`, `listTransfers`, `listTransfersByTxid`, `refreshTransfers`, `failTransfers`, `getAssetMedia`, `postAssetMedia` | +| BTC | `btcBalance`, `sendBtc`, `prepareBtcSend`, `commitPreparedBtcSend`, `cancelBtcSendPlan`, `listTransactions`, `listTransactionsByTxid`, `listUnspents`, `createUtxos`, `prepareCreateUtxos`, `commitPreparedCreateUtxos`, `cancelCreateUtxosPlan`, `estimateFee` | | VSS | `vssClearFence`, `vssBackup` | | APay | `apayNew` | | Signing / onion / diagnostics | `signMessage`, `verifyMessage`, `sendOnionMessage`, `checkIndexerUrl`, `checkProxyEndpoint` | +`syncWallet({ mode })` is the production synchronization contract. `routine` +updates every revealed Vanilla and Colored script with `FullSync`; `recovery` +discovers both keychains with `FullScan`. It reports each keychain separately +instead of hiding a partial failure. `walletSnapshot(request)` then reads a +versioned, bounded snapshot without another implicit sync. Every monetary +amount is base-10 text, and Lightning claimable balances remain distinct from +inbound/outbound routing capacities. + The C-FFI symbols backing these are declared in [`rln.h`](./rln.h) and wrapped in [`binding.cc`](./binding.cc); see [`index.js`](./index.js) for the authoritative JS method list. @@ -191,13 +199,16 @@ the authoritative JS method list. ## Seed handling RLN never sees the BIP-39 mnemonic. The host (WDK) derives a 32-byte -BIP-32 entropy and passes it as `seedHex` to `NativeExternalSigner.create`. +BIP-32 entropy and passes it as `seedHex` to +`NativeExternalSigner.createWithStorage`. `initWithNativeExternalSigner` writes only public identifying data (xpubs, node id, master fingerprint) to the key-source file on disk. The same mnemonic re-derives the same `seedHex` on every launch, so the LDK node -identity stays stable across restarts. The VLS signer state lives entirely -in process memory; all channel-state cryptography happens in-process via -`signer-external` / `vls-protocol-signer`. The JS signer handle can be +identity stays stable across restarts. VLS channel-validation state is kept +in the caller-provided private storage directory; production wallets must +retain that directory for the lifetime of their channels. The seed remains +host-owned and is never written there. All channel-state cryptography happens +in-process via `signer-external` / `vls-protocol-signer`. The JS signer handle can be dropped (`destroy()` or GC) once RLN has cloned its `Arc` ref via attach/init/unlock. @@ -221,6 +232,60 @@ links dynamically at runtime (one `.node` per host), while `cmake-bare` links statically at build time, producing one self-contained `.bare` file usable inside any Bare worklet. +## Git commit installs with a native overlay + +Git commits can expose C-FFI behavior that has not been promoted to a package +release yet. Such commits declare `utexoNativeOverlay` in `package.json` with +an exact upstream tag and commit, patch path and SHA-256, Rust toolchain, iOS +deployment target, Android NDK/API/tool versions, and output target list. +During `postinstall` the package: + +1. verifies the metadata and patch checksum; +2. verifies any existing static libraries and Bare addons contain the required + wallet snapshot symbols; +3. optionally imports artifacts from the explicitly trusted + `RLN_BARE_ARTIFACTS_DIR`; or +4. clones the exact upstream commit, applies only the checksum-pinned patch, + installs the pinned Rust targets and Android build tools, builds the + platform-scoped outputs, strips Android debug sections, and verifies their + symbols before succeeding. + +`RLN_BARE_SOURCE_DIR` may point to an exact local checkout for development. It +must be at the configured commit and either pristine or have the complete +configured patch already applied. Both overrides are build inputs controlled +by the caller; neither bypasses commit, patch, file, or symbol validation. +Registry packages without `utexoNativeOverlay` continue to download artifacts +from their matching GitHub release. + +Target preparation is platform scoped so an Android build does not require or +replace iOS artifacts, and vice versa. On a normal macOS install the Apple +targets are prepared. EAS selects the target group from `EAS_BUILD_PLATFORM`; +local or custom build pipelines can select it explicitly: + +```sh +node scripts/install-native-artifacts.js --platform ios +node scripts/install-native-artifacts.js --platform android +``` + +`RLN_BARE_TARGETS` accepts an explicit comma-separated subset of configured +targets for artifact CI. Provenance is incremental: preparing a second platform +adds its hashes without discarding already verified hashes for the first. + +JavaScript-only CI jobs that will not link or load the native addon may opt out +explicitly: + +```sh +RLN_BARE_JS_ONLY_INSTALL=1 npm ci +``` + +The opt-out creates no native artifacts. A later app link or runtime step must +still run the consumer's artifact and symbol checks, and therefore fails closed +if a compatible addon was not installed. Source-building declared Apple +targets requires macOS; non-macOS hosts receive a direct error instead of +attempting an impossible cross-build. Android builds require the exact NDK +revision declared by the overlay and produce `arm64-v8a`, `armeabi-v7a`, and +`x86_64` addons from the same patched source and symbol contract as iOS. + ## Build and release (maintainers) Releases are cut by the **Build and Release (Bare)** GitHub Actions diff --git a/binding.cc b/binding.cc index fdc9066..4957e27 100644 --- a/binding.cc +++ b/binding.cc @@ -42,6 +42,12 @@ static js_value_t *cstring_to_js(js_env_t *env, const char *str) { return result; } +static js_value_t *make_undefined(js_env_t *env) { + js_value_t *undefined; + js_get_undefined(env, &undefined); + return undefined; +} + static js_value_t *handle_result_string(js_env_t *env, struct CResultString res) { if (res.result == Ok) { js_value_t *val = cstring_to_js(env, res.inner); @@ -76,14 +82,38 @@ static uint32_t js_to_uint32(js_env_t *env, js_value_t *val) { struct SdkNodeRef { struct COpaqueStruct opaque; bool freed; + bool shutdown_attempted; + bool teardown_registered; }; -static void sdk_node_destructor(js_env_t *env, void *data, void *hint) { - SdkNodeRef *ref = (SdkNodeRef *)data; +static void shutdown_and_free_sdk_node(SdkNodeRef *ref) { if (!ref->freed) { - rln_sdk_node_shutdown(&ref->opaque); + if (!ref->shutdown_attempted) { + ref->shutdown_attempted = true; + struct CResultString shutdown_result = rln_sdk_node_shutdown(&ref->opaque); + if (shutdown_result.inner != NULL) { + rln_free_string(shutdown_result.inner); + } + } free_sdk_node(ref->opaque); + ref->opaque.ptr = NULL; + ref->freed = true; } +} + +static void sdk_node_teardown(void *data) { + SdkNodeRef *ref = (SdkNodeRef *)data; + ref->teardown_registered = false; + shutdown_and_free_sdk_node(ref); +} + +static void sdk_node_destructor(js_env_t *env, void *data, void *hint) { + SdkNodeRef *ref = (SdkNodeRef *)data; + if (ref->teardown_registered) { + js_remove_teardown_callback(env, sdk_node_teardown, ref); + ref->teardown_registered = false; + } + shutdown_and_free_sdk_node(ref); free(ref); } @@ -91,19 +121,54 @@ static js_value_t *wrap_sdk_node(js_env_t *env, struct COpaqueStruct opaque) { SdkNodeRef *ref = (SdkNodeRef *)malloc(sizeof(SdkNodeRef)); ref->opaque = opaque; ref->freed = false; + ref->shutdown_attempted = false; + ref->teardown_registered = false; + + int err = js_add_teardown_callback(env, sdk_node_teardown, ref); + if (err != 0) { + shutdown_and_free_sdk_node(ref); + free(ref); + js_throw_error(env, NULL, "Unable to register rgb-lightning-node teardown"); + return NULL; + } + ref->teardown_registered = true; js_value_t *external; - js_create_external(env, ref, sdk_node_destructor, NULL, &external); + err = js_create_external(env, ref, sdk_node_destructor, NULL, &external); + if (err != 0) { + js_remove_teardown_callback(env, sdk_node_teardown, ref); + ref->teardown_registered = false; + shutdown_and_free_sdk_node(ref); + free(ref); + js_throw_error(env, NULL, "Unable to create rgb-lightning-node handle"); + return NULL; + } return external; } -static const struct COpaqueStruct *unwrap_sdk_node(js_env_t *env, js_value_t *val) { - void *data; - js_get_value_external(env, val, &data); +static const struct COpaqueStruct *require_sdk_node(js_env_t *env, + js_value_t *val) { + void *data = NULL; + if (js_get_value_external(env, val, &data) != 0 || data == NULL) { + js_throw_error(env, "ERR_RLN_NODE_CLOSED", + "RGB Lightning node handle is unavailable"); + return NULL; + } SdkNodeRef *ref = (SdkNodeRef *)data; + if (ref->freed || ref->shutdown_attempted || ref->opaque.ptr == NULL) { + js_throw_error(env, "ERR_RLN_NODE_CLOSED", + "RGB Lightning node is already closed"); + return NULL; + } return &ref->opaque; } +static SdkNodeRef *unwrap_sdk_node_ref(js_env_t *env, js_value_t *val) { + void *data = NULL; + if (js_get_value_external(env, val, &data) != 0 || data == NULL) return NULL; + return (SdkNodeRef *)data; +} + static js_value_t *handle_result_node(js_env_t *env, struct CResult res) { if (res.result == Ok) { return wrap_sdk_node(env, res.inner); @@ -152,13 +217,29 @@ static js_value_t *wrap_signer(js_env_t *env, struct COpaqueStruct opaque) { return external; } -static const struct COpaqueStruct *unwrap_signer(js_env_t *env, js_value_t *val) { - void *data; - js_get_value_external(env, val, &data); +static const struct COpaqueStruct *require_signer(js_env_t *env, + js_value_t *val) { + void *data = NULL; + if (js_get_value_external(env, val, &data) != 0 || data == NULL) { + js_throw_error(env, "ERR_RLN_SIGNER_CLOSED", + "RGB Lightning signer handle is unavailable"); + return NULL; + } SignerRef *ref = (SignerRef *)data; + if (ref->freed || ref->opaque.ptr == NULL) { + js_throw_error(env, "ERR_RLN_SIGNER_CLOSED", + "RGB Lightning signer is already closed"); + return NULL; + } return &ref->opaque; } +static SignerRef *unwrap_signer_ref(js_env_t *env, js_value_t *val) { + void *data; + js_get_value_external(env, val, &data); + return (SignerRef *)data; +} + static js_value_t *handle_result_signer(js_env_t *env, struct CResult res) { if (res.result == Ok) { return wrap_signer(env, res.inner); @@ -193,7 +274,8 @@ static int get_args(js_env_t *env, js_callback_info_t *info, static js_value_t *fn_##NAME(js_env_t *env, js_callback_info_t *info) { \ js_value_t *args[1]; \ get_args(env, info, args, 1); \ - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); \ + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); \ + if (node == NULL) return make_undefined(env); \ return handle_result_string(env, RLN_FN(node)); \ } @@ -201,7 +283,8 @@ static int get_args(js_env_t *env, js_callback_info_t *info, static js_value_t *fn_##NAME(js_env_t *env, js_callback_info_t *info) { \ js_value_t *args[2]; \ get_args(env, info, args, 2); \ - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); \ + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); \ + if (node == NULL) return make_undefined(env); \ char *s = js_to_cstring(env, args[1]); \ struct CResultString res = RLN_FN(node, s); \ free(s); \ @@ -214,7 +297,8 @@ static int get_args(js_env_t *env, js_callback_info_t *info, static js_value_t *fn_##NAME(js_env_t *env, js_callback_info_t *info) { \ js_value_t *args[2]; \ get_args(env, info, args, 2); \ - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); \ + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); \ + if (node == NULL) return make_undefined(env); \ bool b = js_to_bool(env, args[1]); \ return handle_result_string(env, RLN_FN(node, b)); \ } @@ -260,7 +344,8 @@ static js_value_t *fn_sdk_node_new(js_env_t *env, js_callback_info_t *info) { static js_value_t *fn_sdk_node_init(js_env_t *env, js_callback_info_t *info) { js_value_t *args[3]; get_args(env, info, args, 3); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *password = js_to_cstring(env, args[1]); char *mnemonic = js_to_cstring(env, args[2]); struct CResultString res = rln_sdk_node_init(node, password, mnemonic); @@ -270,11 +355,50 @@ static js_value_t *fn_sdk_node_init(js_env_t *env, js_callback_info_t *info) { } FN_NODE_JSON(sdk_node_unlock, rln_sdk_node_unlock) -FN_NODE(sdk_node_shutdown, rln_sdk_node_shutdown) +static js_value_t *fn_sdk_node_shutdown(js_env_t *env, + js_callback_info_t *info) { + js_value_t *args[1]; + get_args(env, info, args, 1); + SdkNodeRef *ref = unwrap_sdk_node_ref(env, args[0]); + if (ref == NULL || ref->freed || ref->opaque.ptr == NULL) { + js_throw_error(env, "ERR_RLN_NODE_CLOSED", + "RGB Lightning node is already closed"); + return make_undefined(env); + } + if (ref->shutdown_attempted) return make_undefined(env); + + // Mark the attempt before entering native code. A failed or panicking + // shutdown can leave partially released resources and must not be retried + // implicitly by destroy() or the environment teardown callback. + ref->shutdown_attempted = true; + return handle_result_string(env, rln_sdk_node_shutdown(&ref->opaque)); +} FN_NODE_JSON(sdk_node_vss_clear_fence, rln_sdk_node_vss_clear_fence) FN_NODE(sdk_node_vss_backup, rln_sdk_node_vss_backup) +FN_NODE_JSON(sdk_node_vss_delete_all, rln_sdk_node_vss_delete_all) FN_NODE_STR(sdk_node_apay_new, rln_sdk_node_apay_new) +static js_value_t *fn_sdk_node_destroy(js_env_t *env, js_callback_info_t *info) { + js_value_t *args[1]; + get_args(env, info, args, 1); + SdkNodeRef *ref = unwrap_sdk_node_ref(env, args[0]); + if (ref == NULL) { + js_throw_error(env, "ERR_RLN_NODE_CLOSED", + "RGB Lightning node handle is unavailable"); + return make_undefined(env); + } + if (!ref->freed) { + shutdown_and_free_sdk_node(ref); + } + if (ref->teardown_registered) { + js_remove_teardown_callback(env, sdk_node_teardown, ref); + ref->teardown_registered = false; + } + js_value_t *undefined; + js_get_undefined(env, &undefined); + return undefined; +} + // ============================================================================ // External-signer surface // ============================================================================ @@ -291,13 +415,49 @@ static js_value_t *fn_native_external_signer_new(js_env_t *env, js_callback_info return handle_result_signer(env, res); } +static js_value_t *fn_native_external_signer_new_with_storage(js_env_t *env, + js_callback_info_t *info) { + js_value_t *args[4]; + get_args(env, info, args, 4); + char *seed_hex = js_to_cstring(env, args[0]); + char *network = js_to_cstring(env, args[1]); + bool permissive_policy = js_to_bool(env, args[2]); + char *storage_dir_path = js_to_cstring(env, args[3]); + struct CResult res = rln_native_external_signer_new_with_storage( + seed_hex, + network, + permissive_policy, + storage_dir_path + ); + free(seed_hex); + free(network); + free(storage_dir_path); + return handle_result_signer(env, res); +} + static js_value_t *fn_native_external_signer_bootstrap(js_env_t *env, js_callback_info_t *info) { js_value_t *args[1]; get_args(env, info, args, 1); - const struct COpaqueStruct *signer = unwrap_signer(env, args[0]); + const struct COpaqueStruct *signer = require_signer(env, args[0]); + if (signer == NULL) return make_undefined(env); return handle_result_string(env, rln_native_external_signer_bootstrap(signer)); } +static js_value_t *fn_native_external_signer_destroy(js_env_t *env, + js_callback_info_t *info) { + js_value_t *args[1]; + get_args(env, info, args, 1); + SignerRef *ref = unwrap_signer_ref(env, args[0]); + if (!ref->freed) { + free_native_external_signer(ref->opaque); + ref->opaque.ptr = NULL; + ref->freed = true; + } + js_value_t *undefined; + js_get_undefined(env, &undefined); + return undefined; +} + // `node` + `signer` form (3 of these): init / attach / unlock-with-native // share the same shape — wrap them through a single helper macro. @@ -305,8 +465,10 @@ static js_value_t *fn_native_external_signer_bootstrap(js_env_t *env, js_callbac static js_value_t *fn_##NAME(js_env_t *env, js_callback_info_t *info) { \ js_value_t *args[2]; \ get_args(env, info, args, 2); \ - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); \ - const struct COpaqueStruct *signer = unwrap_signer(env, args[1]); \ + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); \ + if (node == NULL) return make_undefined(env); \ + const struct COpaqueStruct *signer = require_signer(env, args[1]); \ + if (signer == NULL) return make_undefined(env); \ return handle_result_string(env, RLN_FN(node, signer)); \ } @@ -319,8 +481,10 @@ static js_value_t *fn_sdk_node_unlock_with_native_external_signer(js_env_t *env, js_callback_info_t *info) { js_value_t *args[3]; get_args(env, info, args, 3); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); - const struct COpaqueStruct *signer = unwrap_signer(env, args[1]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); + const struct COpaqueStruct *signer = require_signer(env, args[1]); + if (signer == NULL) return make_undefined(env); char *request_json = js_to_cstring(env, args[2]); struct CResultString res = rln_sdk_node_unlock_with_native_external_signer(node, signer, request_json); @@ -328,6 +492,25 @@ static js_value_t *fn_sdk_node_unlock_with_native_external_signer(js_env_t *env, return handle_result_string(env, res); } +static js_value_t *fn_sdk_node_start_unlock_with_native_external_signer( + js_env_t *env, js_callback_info_t *info) { + js_value_t *args[3]; + get_args(env, info, args, 3); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); + const struct COpaqueStruct *signer = require_signer(env, args[1]); + if (signer == NULL) return make_undefined(env); + char *request_json = js_to_cstring(env, args[2]); + struct CResultString res = + rln_sdk_node_start_unlock_with_native_external_signer(node, signer, request_json); + free(request_json); + return handle_result_string(env, res); +} + +FN_NODE_STR(sdk_node_native_operation_status, rln_sdk_node_native_operation_status) +FN_NODE_STR(sdk_node_adopt_native_operation, rln_sdk_node_adopt_native_operation) +FN_NODE_STR(sdk_node_cancel_native_operation, rln_sdk_node_cancel_native_operation) + // `node` + `bootstrap_json` / `unlock_request_json` — host-implemented // signer path. Reuse the FN_NODE_JSON shape. FN_NODE_JSON(sdk_node_init_with_external_signer, rln_sdk_node_init_with_external_signer) @@ -342,6 +525,8 @@ FN_NODE_JSON(sdk_node_unlock_with_attached_external_signer, FN_NODE(node_info, rln_node_info) FN_NODE(network_info, rln_network_info) FN_NODE(sync, rln_sync) +FN_NODE_JSON(sync_wallet, rln_sync_wallet) +FN_NODE_JSON(wallet_snapshot, rln_wallet_snapshot) FN_NODE(address, rln_address) FN_NODE(rotate_address, rln_rotate_address) @@ -387,7 +572,8 @@ FN_NODE(list_payments, rln_list_payments) static js_value_t *fn_get_payment(js_env_t *env, js_callback_info_t *info) { js_value_t *args[3]; get_args(env, info, args, 3); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *hash = js_to_cstring(env, args[1]); char *type = js_to_cstring(env, args[2]); struct CResultString res = rln_get_payment(node, hash, type); @@ -408,7 +594,8 @@ FN_NODE(list_swaps, rln_list_swaps) static js_value_t *fn_get_swap(js_env_t *env, js_callback_info_t *info) { js_value_t *args[3]; get_args(env, info, args, 3); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *hash = js_to_cstring(env, args[1]); bool taker_flag = js_to_bool(env, args[2]); struct CResultString res = rln_get_swap(node, hash, taker_flag); @@ -433,7 +620,8 @@ FN_NODE_STR(asset_metadata, rln_asset_metadata) static js_value_t *fn_list_transfers(js_env_t *env, js_callback_info_t *info) { js_value_t *args[2]; get_args(env, info, args, 2); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *asset_id = js_to_cstring(env, args[1]); struct CResultString res = rln_list_transfers(node, asset_id, NULL); free(asset_id); @@ -444,7 +632,8 @@ static js_value_t *fn_list_transfers_by_txid(js_env_t *env, js_callback_info_t *info) { js_value_t *args[2]; get_args(env, info, args, 2); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *txid = js_to_cstring(env, args[1]); struct CResultString res = rln_list_transfers(node, NULL, txid); free(txid); @@ -454,6 +643,12 @@ FN_NODE_JSON(refresh_transfers, rln_refresh_transfers) FN_NODE_JSON(fail_transfers, rln_fail_transfers) FN_NODE_JSON(send_rgb, rln_send_rgb) +FN_NODE_JSON(import_rgb_transfer_consignment, rln_import_rgb_transfer_consignment) +FN_NODE_JSON(import_rgb_contract, rln_import_rgb_contract) +FN_NODE_JSON(prepare_rgb_send, rln_prepare_rgb_send) +FN_NODE_JSON(commit_prepared_rgb_send, rln_commit_prepared_rgb_send) +FN_NODE_JSON(cancel_rgb_send_plan, rln_cancel_rgb_send_plan) +FN_NODE(list_pending_rgb_send_plans, rln_list_pending_rgb_send_plans) FN_NODE_JSON(inflate, rln_inflate) // Asset media @@ -464,13 +659,22 @@ FN_NODE_JSON(post_asset_media, rln_post_asset_media) // BTC ops // ============================================================================ -FN_NODE_BOOL(btc_balance, rln_btc_balance) -FN_NODE_JSON(send_btc, rln_send_btc) + FN_NODE_BOOL(btc_balance, rln_btc_balance) + FN_NODE_JSON(send_btc, rln_send_btc) + FN_NODE_JSON(prepare_btc_send, rln_prepare_btc_send) +FN_NODE_JSON(commit_prepared_btc_send, rln_commit_prepared_btc_send) +FN_NODE_JSON(cancel_btc_send_plan, rln_cancel_btc_send_plan) +FN_NODE_JSON(prepare_create_utxos, rln_prepare_create_utxos) +FN_NODE_JSON(commit_prepared_create_utxos, rln_commit_prepared_create_utxos) +FN_NODE_JSON(cancel_create_utxos_plan, rln_cancel_create_utxos_plan) +FN_NODE(list_pending_vanilla_transactions, rln_list_pending_vanilla_transactions) + FN_NODE_STR(list_address_receipts, rln_list_address_receipts) static js_value_t *fn_list_transactions(js_env_t *env, js_callback_info_t *info) { js_value_t *args[2]; get_args(env, info, args, 2); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); bool skip_sync = js_to_bool(env, args[1]); return handle_result_string(env, rln_list_transactions(node, skip_sync, NULL)); @@ -479,7 +683,8 @@ static js_value_t *fn_list_transactions(js_env_t *env, static js_value_t *fn_list_transactions_by_txid(js_env_t *env, js_callback_info_t *info) { js_value_t *args[3]; get_args(env, info, args, 3); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *txid = js_to_cstring(env, args[1]); bool skip_sync = js_to_bool(env, args[2]); struct CResultString res = rln_list_transactions(node, skip_sync, txid); @@ -493,7 +698,8 @@ FN_NODE_JSON(create_utxos, rln_create_utxos) static js_value_t *fn_estimate_fee(js_env_t *env, js_callback_info_t *info) { js_value_t *args[2]; get_args(env, info, args, 2); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); uint32_t blocks_u32 = js_to_uint32(env, args[1]); uint16_t blocks = (uint16_t)(blocks_u32 & 0xFFFF); return handle_result_string(env, rln_estimate_fee(node, blocks)); @@ -509,7 +715,8 @@ FN_NODE_STR(sign_message, rln_sign_message) static js_value_t *fn_verify_message(js_env_t *env, js_callback_info_t *info) { js_value_t *args[3]; get_args(env, info, args, 3); - const struct COpaqueStruct *node = unwrap_sdk_node(env, args[0]); + const struct COpaqueStruct *node = require_sdk_node(env, args[0]); + if (node == NULL) return make_undefined(env); char *message = js_to_cstring(env, args[1]); char *signature = js_to_cstring(env, args[2]); struct CResultString res = rln_verify_message(node, message, signature); @@ -547,19 +754,28 @@ rgb_lightning_node_bare_exports(js_env_t *env, js_value_t *exports) { EXPORT("sdkNodeInit", sdk_node_init); EXPORT("sdkNodeUnlock", sdk_node_unlock); EXPORT("sdkNodeShutdown", sdk_node_shutdown); + EXPORT("sdkNodeDestroy", sdk_node_destroy); EXPORT("sdkNodeVssClearFence", sdk_node_vss_clear_fence); EXPORT("sdkNodeVssBackup", sdk_node_vss_backup); + EXPORT("sdkNodeVssDeleteAll", sdk_node_vss_delete_all); EXPORT("sdkNodeApayNew", sdk_node_apay_new); // External signer (native — recommended) EXPORT("nativeExternalSignerNew", native_external_signer_new); + EXPORT("nativeExternalSignerNewWithStorage", native_external_signer_new_with_storage); EXPORT("nativeExternalSignerBootstrap", native_external_signer_bootstrap); + EXPORT("nativeExternalSignerDestroy", native_external_signer_destroy); EXPORT("sdkNodeInitWithNativeExternalSigner", sdk_node_init_with_native_external_signer); EXPORT("sdkNodeAttachNativeExternalSigner", sdk_node_attach_native_external_signer); EXPORT("sdkNodeUnlockWithNativeExternalSigner", sdk_node_unlock_with_native_external_signer); + EXPORT("sdkNodeStartUnlockWithNativeExternalSigner", + sdk_node_start_unlock_with_native_external_signer); + EXPORT("sdkNodeNativeOperationStatus", sdk_node_native_operation_status); + EXPORT("sdkNodeAdoptNativeOperation", sdk_node_adopt_native_operation); + EXPORT("sdkNodeCancelNativeOperation", sdk_node_cancel_native_operation); // External signer (host-implemented — bootstrap dict only; // foreign-signer callback transport not yet exposed) @@ -572,6 +788,8 @@ rgb_lightning_node_bare_exports(js_env_t *env, js_value_t *exports) { EXPORT("nodeInfo", node_info); EXPORT("networkInfo", network_info); EXPORT("sync", sync); + EXPORT("syncWallet", sync_wallet); + EXPORT("walletSnapshot", wallet_snapshot); EXPORT("address", address); EXPORT("rotateAddress", rotate_address); @@ -622,6 +840,12 @@ rgb_lightning_node_bare_exports(js_env_t *env, js_value_t *exports) { EXPORT("refreshTransfers", refresh_transfers); EXPORT("failTransfers", fail_transfers); EXPORT("sendRgb", send_rgb); + EXPORT("importRgbTransferConsignment", import_rgb_transfer_consignment); + EXPORT("importRgbContract", import_rgb_contract); + EXPORT("prepareRgbSend", prepare_rgb_send); + EXPORT("commitPreparedRgbSend", commit_prepared_rgb_send); + EXPORT("cancelRgbSendPlan", cancel_rgb_send_plan); + EXPORT("listPendingRgbSendPlans", list_pending_rgb_send_plans); EXPORT("inflate", inflate); EXPORT("getAssetMedia", get_asset_media); EXPORT("postAssetMedia", post_asset_media); @@ -629,6 +853,14 @@ rgb_lightning_node_bare_exports(js_env_t *env, js_value_t *exports) { // BTC ops EXPORT("btcBalance", btc_balance); EXPORT("sendBtc", send_btc); + EXPORT("prepareBtcSend", prepare_btc_send); + EXPORT("commitPreparedBtcSend", commit_prepared_btc_send); + EXPORT("cancelBtcSendPlan", cancel_btc_send_plan); + EXPORT("prepareCreateUtxos", prepare_create_utxos); + EXPORT("commitPreparedCreateUtxos", commit_prepared_create_utxos); + EXPORT("cancelCreateUtxosPlan", cancel_create_utxos_plan); + EXPORT("listPendingVanillaTransactions", list_pending_vanilla_transactions); + EXPORT("listAddressReceipts", list_address_receipts); EXPORT("listTransactions", list_transactions); EXPORT("listTransactionsByTxid", list_transactions_by_txid); EXPORT("listUnspents", list_unspents); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..826d94e --- /dev/null +++ b/index.d.ts @@ -0,0 +1,520 @@ +// TypeScript surface for @utexo/rgb-lightning-node-bare. +// +// The Rust N-API layer exchanges JSON strings internally. The public +// JavaScript facade in index.js owns that marshalling, so package consumers +// pass plain objects and receive parsed JSON values. + +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[] +export interface JsonObject { [key: string]: JsonValue } +export type JsonRequest = Record + +/** 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 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 +} + +export interface StartNativeOperationResponse extends NativeOperationStatus { + adopted_existing: boolean +} + +export interface WalletSyncRequest { + mode: WalletSyncMode +} + +export type WalletSyncKeychainResult = + | { status: 'succeeded'; checkpoint: WalletSnapshotNetwork } + | { status: 'failed'; error_code: string } + +export interface WalletSyncResponse { + contract_version: 2 + mode: WalletSyncMode + vanilla: WalletSyncKeychainResult + colored: WalletSyncKeychainResult +} + +export interface WalletSnapshotRequest { + asset_ids?: string[] + max_assets?: number + max_channels?: number + max_activity_items?: number + include_activity?: boolean +} + +export interface WalletSnapshotNetwork { + network: 'mainnet' | 'testnet' | 'regtest' | 'signet' + height: number + block_hash: string +} + +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 + /** Aggregate LDK amount claimable on channel close; this is not routing capacity. */ + 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 + /** LDK amount claimable from this channel monitor; this is not outbound capacity. */ + 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' + 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 +} + +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 DecodedLnInvoice { + 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 type LightningPaymentStatus = + | 'Pending' + | 'Claimable' + | 'Claiming' + | 'Succeeded' + | 'Cancelled' + | 'Failed' + +export interface SendPaymentResponse { + payment_id: string + payment_hash: string | null + payment_secret: string | null + status: LightningPaymentStatus + failure_code: string | null +} + +export interface LightningPayment { + amt_msat: number | null + asset_amount: number | null + asset_id: string | null + payment_hash: string + payment_type: 'Outbound' | 'InboundAutoClaim' | 'InboundHodl' + status: LightningPaymentStatus + created_at: number + updated_at: number + payee_pubkey: string + preimage: string | null + description_hash: string | null + fee_paid_msat: number | null + failure_code: string | null +} + +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 ImportRgbTransferConsignmentRequest { + consignment_base64: string + offchain_txid: string + expected_asset_id?: string +} + +export interface ImportRgbTransferConsignmentResponse { + asset_id: string + already_imported: boolean + metadata: JsonObject +} + +export interface ImportRgbContractRequest { + contract_base64: string + expected_asset_id: string +} + +export interface ImportRgbContractResponse { + asset_id: string + already_imported: boolean + metadata: JsonObject +} + +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: WalletSnapshotRgbAssignment | null + assignments: WalletSnapshotRgbAssignment[] + 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 WalletSnapshotRgbAssignment { + kind: 'Fungible' | 'NonFungible' | 'InflationRight' | 'Any' + amount?: DecimalString +} + +export interface WalletSnapshotAssetTransfers { + asset_id: string + transfers: WalletSnapshotTransfer[] +} + +export interface WalletSnapshotResponse { + contract_version: 2 + native_source: 'rgb-lightning-node-v0.11.0-beta.3+utexo-wallet-v3' + capture_sequence: DecimalString + capture_attempts: 2 | 3 + stable_capture_count: 2 + 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 BtcSendRequest { + amount: number + address: string + fee_rate: number + skip_sync: boolean +} + +export interface PreparedSendResponse { + plan_id: string + fee_sat: DecimalString + total_input_sat: DecimalString + total_output_sat: DecimalString + size_vbytes: DecimalString +} + +export interface PreparedRgbSendResponse extends PreparedSendResponse { + batch_transfer_idx: number +} + +export interface CreateUtxosRequest { + up_to: boolean + num?: number + size?: number + fee_rate: number + skip_sync: boolean +} + +export interface PreparedCreateUtxosResponse extends PreparedSendResponse { + target_count: number + output_size_sat: number +} + +export interface CommitPreparedSendRequest { + plan_id: string +} + +export interface SendBtcResponse { + txid: string +} + +export interface CancelBtcSendPlanResponse { + cancelled: boolean +} + +export interface PendingVanillaTransaction { + txid: string + operation_type: 'CreateUtxos' | 'Drain' | 'SendBtc' +} + +export interface PendingRgbSendPlan { + plan_id: string + batch_transfer_idx: 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: RgbAllocation[] + pending_blinded: number +} + +export interface AddressReceipt { + txid: string + amount_sat: DecimalString + confirmations: number + block_height: number | null +} + +export class NativeExternalSigner { + static create( + seedHex: string, + network: 'mainnet' | 'testnet' | 'testnet4' | 'regtest' | 'signet', + permissiveSignerPolicy?: boolean + ): NativeExternalSigner + + static createWithStorage( + seedHex: string, + network: 'mainnet' | 'testnet' | 'testnet4' | 'regtest' | 'signet', + storageDirPath: string, + permissiveSignerPolicy?: boolean + ): NativeExternalSigner + + bootstrap(): JsonObject + destroy(): void +} + +export class SdkNode { + static create(request: JsonRequest): SdkNode + + // Legacy seed-owning lifecycle (external signer is preferred for WDK). + init(password: string, mnemonic?: string): string + unlock(request: JsonRequest): void + + // External-signer lifecycle + initWithNativeExternalSigner(signer: NativeExternalSigner): void + attachNativeExternalSigner(signer: NativeExternalSigner): void + unlockWithNativeExternalSigner(signer: NativeExternalSigner, request: JsonRequest): void + startUnlockWithNativeExternalSigner( + signer: NativeExternalSigner, + request: JsonRequest + ): StartNativeOperationResponse + nativeOperationStatus(operationId: string): NativeOperationStatus + adoptNativeOperation(operationId: string): NativeOperationStatus + cancelNativeOperation(operationId: string): NativeOperationStatus + initWithExternalSigner(bootstrap: JsonRequest): void + detachExternalSigner(): void + unlockWithAttachedExternalSigner(request: JsonRequest): void + shutdown(): void + + // VSS / APay + vssClearFence(request: JsonRequest): void + vssBackup(): JsonObject + vssDeleteAll(request: { password: string }): { deleted_keys: number } + apayNew(hostNodeId: string): JsonObject + + // Node info / network / sync + nodeInfo(): JsonObject + networkInfo(): JsonObject + sync(): JsonValue + syncWallet(request: WalletSyncRequest): WalletSyncResponse + walletSnapshot(request?: WalletSnapshotRequest): WalletSnapshotResponse + getAddress(): JsonObject + address(): JsonObject + rotateAddress(): JsonObject + + // Peers / channels + connectPeer(peerPubkeyAndAddr: string): JsonValue + disconnectPeer(request: JsonRequest): JsonValue + listPeers(): JsonValue + openChannel(request: JsonRequest): JsonValue + closeChannel(request: JsonRequest): JsonValue + listChannels(): JsonValue + getChannelId(temporaryChannelIdHex: string): JsonValue + + // BTC / UTXOs + btcBalance(skipSync?: boolean): JsonObject + listUnspents(skipSync?: boolean): RgbUnspent[] + listTransactions(skipSync?: boolean): JsonValue + listTransactionsByTxid(txid: string, skipSync?: boolean): JsonValue + sendBtc(request: BtcSendRequest): SendBtcResponse + prepareBtcSend(request: BtcSendRequest): PreparedSendResponse + commitPreparedBtcSend(request: CommitPreparedSendRequest): SendBtcResponse + cancelBtcSendPlan(request: { plan_id: string }): CancelBtcSendPlanResponse + prepareCreateUtxos(request: CreateUtxosRequest): PreparedCreateUtxosResponse + commitPreparedCreateUtxos(request: CommitPreparedSendRequest): SendBtcResponse + cancelCreateUtxosPlan(request: { plan_id: string }): CancelBtcSendPlanResponse + listPendingVanillaTransactions(): PendingVanillaTransaction[] + listAddressReceipts(address: string): AddressReceipt[] + createUtxos(request: JsonRequest): JsonValue + estimateFee(blocks: number): JsonObject + + // Lightning invoices / payments + lnInvoice(request: JsonRequest): JsonObject + decodeLnInvoice(invoice: string): DecodedLnInvoice + invoiceStatus(invoice: string): JsonObject + cancelHodlInvoice(request: JsonRequest): JsonValue + claimHodlInvoice(request: JsonRequest): JsonValue + sendPayment(request: JsonRequest): SendPaymentResponse + keysend(request: JsonRequest): JsonValue + listPayments(): LightningPayment[] + getPayment(paymentHashHex: string, paymentType: string): LightningPayment + + // Atomic swaps + makerInit(request: JsonRequest): JsonValue + makerExecute(request: JsonRequest): JsonValue + taker(request: JsonRequest): JsonValue + listSwaps(): JsonValue + getSwap(paymentHash: string, taker: boolean): JsonValue + + // RGB issuance / assets + issueAssetNia(request: JsonRequest): JsonValue + issueAssetUda(request: JsonRequest): JsonValue + issueAssetCfa(request: JsonRequest): JsonValue + issueAssetIfa(request: JsonRequest): JsonValue + listAssets(filterAssetSchemas?: string[]): JsonValue + assetBalance(assetId: string): JsonObject + assetMetadata(assetId: string): JsonObject + + // RGB invoices / transfers + rgbInvoice(request: JsonRequest): JsonObject + decodeRgbInvoice(invoice: string): DecodedRgbInvoice + sendRgb(request: JsonRequest): JsonValue + importRgbTransferConsignment(request: ImportRgbTransferConsignmentRequest): ImportRgbTransferConsignmentResponse + importRgbContract(request: ImportRgbContractRequest): ImportRgbContractResponse + prepareRgbSend(request: JsonRequest): PreparedRgbSendResponse + commitPreparedRgbSend(request: CommitPreparedSendRequest): JsonValue + cancelRgbSendPlan(request: { plan_id: string }): CancelBtcSendPlanResponse + listPendingRgbSendPlans(): PendingRgbSendPlan[] + refreshTransfers(request: JsonRequest): { ok: true } + failTransfers(request: JsonRequest): JsonValue + inflate(request: JsonRequest): JsonValue + listTransfers(assetId: string): JsonValue + listTransfersByTxid(txid: string): JsonValue + + // RGB asset media + getAssetMedia(digest: string): JsonValue + postAssetMedia(request: JsonRequest): JsonValue + + // Signing / onion / diagnostics + signMessage(message: string): JsonObject + verifyMessage(message: string, signature: string): { valid: boolean } + sendOnionMessage(request: JsonRequest): JsonValue + checkIndexerUrl(indexerUrl: string): JsonObject + checkProxyEndpoint(proxyEndpoint: string): JsonValue +} + +export function uniffiHealthcheck(): string +export function uniffiIsInitialized(): boolean +export function sdkInitialize(request?: JsonRequest): void +export function sdkShutdown(): void diff --git a/index.js b/index.js index f3a7afe..3bcce9d 100644 --- a/index.js +++ b/index.js @@ -69,8 +69,17 @@ class SdkNode { shutdown () { if (this._closed) return - binding.sdkNodeShutdown(this._handle) - this._closed = true + let failure + try { + binding.sdkNodeShutdown(this._handle) + } catch (error) { + failure = error + } finally { + binding.sdkNodeDestroy(this._handle) + this._handle = null + this._closed = true + } + if (failure) throw failure } /** @@ -103,6 +112,10 @@ class SdkNode { return JSON.parse(binding.sdkNodeVssBackup(this._handle)) } + vssDeleteAll (request) { + return JSON.parse(binding.sdkNodeVssDeleteAll(this._handle, JSON.stringify(request))) + } + /** * APay receiver-side registration with an LSP. Pass the LSP's * node_id (hex). Returns the parsed AsyncOrderNewResponse — @@ -158,6 +171,26 @@ class SdkNode { ) } + startUnlockWithNativeExternalSigner (signer, request) { + return JSON.parse(binding.sdkNodeStartUnlockWithNativeExternalSigner( + this._handle, + signer._handle, + JSON.stringify(request) + )) + } + + nativeOperationStatus (operationId) { + return JSON.parse(binding.sdkNodeNativeOperationStatus(this._handle, operationId)) + } + + adoptNativeOperation (operationId) { + return JSON.parse(binding.sdkNodeAdoptNativeOperation(this._handle, operationId)) + } + + cancelNativeOperation (operationId) { + return JSON.parse(binding.sdkNodeCancelNativeOperation(this._handle, operationId)) + } + /** * Initialise with a raw bootstrap dictionary. Used when the signer is * implemented by the host outside this binding (the foreign-signer @@ -190,7 +223,14 @@ class SdkNode { nodeInfo () { return JSON.parse(binding.nodeInfo(this._handle)) } networkInfo () { return JSON.parse(binding.networkInfo(this._handle)) } sync () { return JSON.parse(binding.sync(this._handle)) } + syncWallet (request) { + return JSON.parse(binding.syncWallet(this._handle, JSON.stringify(request))) + } + walletSnapshot (request = {}) { + return JSON.parse(binding.walletSnapshot(this._handle, JSON.stringify(request))) + } address () { return JSON.parse(binding.address(this._handle)) } + getAddress () { return this.address() } rotateAddress () { return JSON.parse(binding.rotateAddress(this._handle)) } // -------- Channels -------- @@ -323,6 +363,28 @@ class SdkNode { sendRgb (request) { return JSON.parse(binding.sendRgb(this._handle, JSON.stringify(request))) } + + importRgbTransferConsignment (request) { + return JSON.parse(binding.importRgbTransferConsignment(this._handle, JSON.stringify(request))) + } + + importRgbContract (request) { + return JSON.parse(binding.importRgbContract(this._handle, JSON.stringify(request))) + } + + prepareRgbSend (request) { + return JSON.parse(binding.prepareRgbSend(this._handle, JSON.stringify(request))) + } + + commitPreparedRgbSend (request) { + return JSON.parse(binding.commitPreparedRgbSend(this._handle, JSON.stringify(request))) + } + cancelRgbSendPlan (request) { + return JSON.parse(binding.cancelRgbSendPlan(this._handle, JSON.stringify(request))) + } + listPendingRgbSendPlans () { + return JSON.parse(binding.listPendingRgbSendPlans(this._handle)) + } inflate (request) { return JSON.parse(binding.inflate(this._handle, JSON.stringify(request))) } @@ -342,6 +404,39 @@ class SdkNode { sendBtc (request) { return JSON.parse(binding.sendBtc(this._handle, JSON.stringify(request))) } + + prepareBtcSend (request) { + return JSON.parse(binding.prepareBtcSend(this._handle, JSON.stringify(request))) + } + + commitPreparedBtcSend (request) { + return JSON.parse(binding.commitPreparedBtcSend(this._handle, JSON.stringify(request))) + } + + cancelBtcSendPlan (request) { + return JSON.parse(binding.cancelBtcSendPlan(this._handle, JSON.stringify(request))) + } + + prepareCreateUtxos (request) { + return JSON.parse(binding.prepareCreateUtxos(this._handle, JSON.stringify(request))) + } + + commitPreparedCreateUtxos (request) { + return JSON.parse(binding.commitPreparedCreateUtxos(this._handle, JSON.stringify(request))) + } + + cancelCreateUtxosPlan (request) { + return JSON.parse(binding.cancelCreateUtxosPlan(this._handle, JSON.stringify(request))) + } + + listPendingVanillaTransactions () { + return JSON.parse(binding.listPendingVanillaTransactions(this._handle)) + } + + listAddressReceipts (address) { + return JSON.parse(binding.listAddressReceipts(this._handle, address)) + } + listTransactions (skipSync = false) { return JSON.parse(binding.listTransactions(this._handle, !!skipSync)) } @@ -381,13 +476,13 @@ class SdkNode { exports.SdkNode = SdkNode /** - * Host-provided in-memory VLS signer. + * Host-provided VLS signer. * * The seed never reaches RLN's persistence layer — the host (e.g. the * WDK secret manager) supplies a stable 32-byte BIP-32 seed at unlock - * time, the VLS signer state lives entirely in process memory, and - * everything cryptographic happens in-process via `signer-external` / - * `vls-protocol-signer`. + * time. Production channel wallets must use `createWithStorage` so VLS + * commitment state survives process restarts. `create` is intentionally + * retained for stateless tooling and tests that do not preserve channels. * * Lifecycle: * 1. `NativeExternalSigner.create(seedHex, network)` @@ -422,6 +517,34 @@ class NativeExternalSigner { ) } + /** + * Construct a disk-backed signer whose channel validation state survives + * process restarts. The storage directory is signer-private state and must + * be stable for the wallet identity. + * + * @param {string} seedHex - 64-char hex string (32-byte BIP-32 entropy) + * @param {string} network - "mainnet" | "testnet" | "testnet4" | "signet" | "regtest" + * @param {string} storageDirPath - Stable, private signer-state directory + * @param {boolean} [permissivePolicy=false] - VLS policy filter + * @returns {NativeExternalSigner} + */ + static createWithStorage (seedHex, network, storageDirPath, permissivePolicy = false) { + if (typeof seedHex !== 'string' || seedHex.length !== 64) { + throw new Error('NativeExternalSigner.createWithStorage: seedHex must be a 64-char hex string') + } + if (typeof storageDirPath !== 'string' || storageDirPath.length === 0) { + throw new Error('NativeExternalSigner.createWithStorage: storageDirPath is required') + } + return new NativeExternalSigner( + binding.nativeExternalSignerNewWithStorage( + seedHex, + network, + !!permissivePolicy, + storageDirPath + ) + ) + } + /** * Returns the bootstrap dictionary (node_id, account xpubs, master * fingerprint, protocol_version, api_level) — identifies the signer @@ -432,11 +555,11 @@ class NativeExternalSigner { return JSON.parse(binding.nativeExternalSignerBootstrap(this._handle)) } - // Eager drop. Optional — the GC destructor handles it otherwise. + // Eager drop. The GC destructor remains as an idempotent fallback. destroy () { - // The native destructor runs on GC; nothing explicit to do here yet, - // but we keep this method so consumers can express intent and we - // can switch to an explicit-free C-FFI later without an API churn. + if (this._destroyed) return + binding.nativeExternalSignerDestroy(this._handle) + this._handle = null this._destroyed = true } } diff --git a/package-lock.json b/package-lock.json index b292883..246b357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,28 @@ { "name": "@utexo/rgb-lightning-node-bare", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@utexo/rgb-lightning-node-bare", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.19", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "bare-fs": "^4.7.1", "bare-os": "^3.9.0", "bare-path": "^3.0.0", + "cmake-bare": "^1.7.6", + "cmake-npm": "^1.1.0", "require-addon": "^1.2.0" }, "devDependencies": { "bare-runtime": "1.30.3", - "cmake-bare": "^1.7.6", - "cmake-npm": "^1.1.0" + "typescript": "^6.0.3" + }, + "engines": { + "node": ">=20" } }, "node_modules/b4a": { @@ -664,7 +668,6 @@ "version": "1.7.7", "resolved": "https://registry.npmjs.org/cmake-bare/-/cmake-bare-1.7.7.tgz", "integrity": "sha512-H+PFcrkdFkeJwLm9ElSbatPamFkszpTz0yZtgwcqUIgOkwn7Gsqb7+WgsFWtniHgxwjc7nQTF5/1LFrPEvEBYA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "cmake-npm": "^1.1.0" @@ -674,7 +677,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmake-npm/-/cmake-npm-1.1.1.tgz", "integrity": "sha512-kXz1vY9uyFXcKIzapkJ8+mEU2biH9aO8IKr9slnVO/PQpswPlp91YtkYTJUcc8CW/grE2p/8uH/qhgoMOf4Vug==", - "dev": true, "license": "Apache-2.0" }, "node_modules/compact-encoding": { @@ -757,6 +759,20 @@ "dependencies": { "b4a": "^1.6.4" } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index dfc89c5..179c0e4 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,72 @@ { "name": "@utexo/rgb-lightning-node-bare", - "version": "0.1.0-beta.16", + "version": "0.1.0-beta.19", "description": "Bare native addon wrapping rgb-lightning-node C FFI for use in bare worklets", "main": "index.js", + "types": "index.d.ts", "addon": true, + "engines": { + "node": ">=20" + }, "license": "Apache-2.0", "author": "UTEXO", "repository": { "type": "git", "url": "https://github.com/UTEXO-Protocol/rgb-lightning-node-bare.git" }, + "utexoNativeOverlay": { + "repository": "https://github.com/UTEXO-Protocol/rgb-lightning-node.git", + "ref": "v0.11.0-beta.3", + "commit": "f30a5393268de67c6bb5a1c525bc790c5b11afa2", + "patch": "patches/c-ffi-utexo-patches-v0.11.0-beta.3.patch", + "patchSha256": "a765ad577bb0e0a88cd15136074357babffd61e2c3dffde624017a2a3cc8983d", + "rustToolchain": "1.88.0", + "iosDeploymentTarget": "16.0", + "androidNdkVersion": "27.1.12297006", + "androidApiLevel": 29, + "cargoNdkVersion": "4.1.2", + "bindgenCliVersion": "0.72.1", + "targets": [ + "ios-arm64", + "ios-arm64-simulator", + "ios-x64-simulator", + "android-arm64", + "android-arm", + "android-x64" + ] + }, "files": [ "binding.js", "binding.cc", + "index.d.ts", "rln.h", "CMakeLists.txt", "index.js", + "patches/", "prebuilds/", "lib/", "scripts/" ], "scripts": { - "postinstall": "bash scripts/download-libs.sh", + "postinstall": "node scripts/install-native-artifacts.js", + "prepare-native": "node scripts/install-native-artifacts.js", "build": "bash scripts/build-prebuilds.sh", "build-cffi": "bash scripts/build-cffi.sh", "build-prebuilds": "bash scripts/build-prebuilds.sh", - "test": "bare test.js" + "check:types": "tsc --noEmit --strict --skipLibCheck index.d.ts", + "test:installer": "node --test scripts/*.test.js", + "test": "npm run test:installer && node ./node_modules/bare-runtime/bin/bare ./test.js" }, "dependencies": { "bare-fs": "^4.7.1", "bare-os": "^3.9.0", "bare-path": "^3.0.0", + "cmake-bare": "^1.7.6", + "cmake-npm": "^1.1.0", "require-addon": "^1.2.0" }, "devDependencies": { "bare-runtime": "1.30.3", - "cmake-bare": "^1.7.6", - "cmake-npm": "^1.1.0" + "typescript": "^6.0.3" } } diff --git a/patches/README.md b/patches/README.md index 79e6722..1108200 100644 --- a/patches/README.md +++ b/patches/README.md @@ -10,8 +10,28 @@ crate, so applying the patch once benefits both bindings. | File | Targets upstream tag | Adds | |---|---|---| +| `c-ffi-utexo-patches-v0.11.0-beta.3.patch` | [`v0.11.0-beta.3`](https://github.com/UTEXO-Protocol/rgb-lightning-node/releases/tag/v0.11.0-beta.3) | Linked-asset support plus versioned dual-keychain sync, rotation-safe address discovery, bounded decimal-safe wallet snapshots, validated RGB contract and transfer imports, durable RGB Lightning payment identity, typed expiry/failure metadata, explicit Lightning fee caps, deterministic BTC/RGB send plans, isolated RGB UTXO setup, trusted-LSP virtual-channel classification, transactional orphaned-session recovery, reconnect-safe deterministic shutdown, and restart-safe VSS writer fencing | +| `c-ffi-utexo-patches-v0.9.0-beta.3.patch` | [`v0.9.0-beta.3`](https://github.com/UTEXO-Protocol/rgb-lightning-node/releases/tag/v0.9.0-beta.3) | Versioned dual-keychain sync and bounded, decimal-safe wallet snapshots | | `c-ffi-utexo-patches-v0.5.0-beta.1.patch` | [`v0.5.0-beta.1`](https://github.com/UTEXO-Protocol/rgb-lightning-node/releases/tag/v0.5.0-beta.1) | `rln_sdk_node_apay_new`, `rln_sdk_node_vss_clear_fence` C wrappers + supporting JSON request types | +The wallet snapshot overlays are byte-identical to their corresponding copies +in the NodeJS binding repository. Routine synchronization FullSyncs both +Vanilla and Colored keychains, recovery synchronization FullScans both +keychains, and all snapshot monetary values cross the JavaScript boundary as +decimal strings. + +Payment persistence stores RGB contract, asset amount, and carrier millisatoshis +with the same native payment record as status. Snapshot contract v3 exposes that +identity, expiry, and terminal reason after restart. Address rotation reveals the +new script before returning, and an inbound channel is classified as virtual only +when SCID privacy is requested by an explicitly configured trusted peer. +The VSS writer identity is persisted only with local node state, so an ordinary +process restart can reclaim its existing fence while a separately provisioned +installation using the same mnemonic remains fenced out. +Shutdown aborts and joins peer reconnect/listener tasks before the final peer +disconnect, preventing a reconnect from leaving the remote LSP with a half-open +socket while the local runtime is being destroyed. + ## Why a patch rather than a fork? The UniFFI surface in upstream already exposes `apay_new` (`src/uniffi_api/mod.rs:1396`) and `vss_clear_fence` (`src/uniffi_api/mod.rs:335`). We only add the **`extern "C"` wrappers** in `bindings/c-ffi/`, which call the existing UniFFI methods via the same `block_on_sdk` pattern as every other wrapper. diff --git a/patches/c-ffi-utexo-patches-v0.11.0-beta.3.patch b/patches/c-ffi-utexo-patches-v0.11.0-beta.3.patch new file mode 100644 index 0000000..2a0d024 --- /dev/null +++ b/patches/c-ffi-utexo-patches-v0.11.0-beta.3.patch @@ -0,0 +1,10192 @@ +diff --git a/Cargo.lock b/Cargo.lock +index a08dec6..ccccd9a 100644 +--- a/Cargo.lock ++++ b/Cargo.lock +@@ -4271,7 +4271,7 @@ dependencies = [ + [[package]] + name = "rgb-lib" + version = "0.3.0-beta.6" +-source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" ++source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?rev=95332c41fd715939ac6e078ad859d474b1f6fa9b#95332c41fd715939ac6e078ad859d474b1f6fa9b" + dependencies = [ + "amplify", + "base64 0.22.1", +@@ -4316,7 +4316,7 @@ dependencies = [ + [[package]] + name = "rgb-lib-migration" + version = "0.3.0-beta.4" +-source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" ++source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?rev=95332c41fd715939ac6e078ad859d474b1f6fa9b#95332c41fd715939ac6e078ad859d474b1f6fa9b" + dependencies = [ + "sea-orm-migration", + "tokio", +diff --git a/Cargo.toml b/Cargo.toml +index 0ccba6e..21877e5 100644 +--- a/Cargo.toml ++++ b/Cargo.toml +@@ -66,7 +66,7 @@ magic-crypt = "4.0.1" + rand = "0.8.5" + regex = { version = "1.11", default-features = false } + reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } +-rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.32", features = [ ++rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", rev = "95332c41fd715939ac6e078ad859d474b1f6fa9b", features = [ + "electrum", + "esplora", + ] } +diff --git a/bindings/c-ffi/Cargo.lock b/bindings/c-ffi/Cargo.lock +index 0ad56b6..24857b3 100644 +--- a/bindings/c-ffi/Cargo.lock ++++ b/bindings/c-ffi/Cargo.lock +@@ -4210,7 +4210,7 @@ dependencies = [ + [[package]] + name = "rgb-lib" + version = "0.3.0-beta.6" +-source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" ++source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?rev=95332c41fd715939ac6e078ad859d474b1f6fa9b#95332c41fd715939ac6e078ad859d474b1f6fa9b" + dependencies = [ + "amplify", + "base64 0.22.1", +@@ -4255,7 +4255,7 @@ dependencies = [ + [[package]] + name = "rgb-lib-migration" + version = "0.3.0-beta.4" +-source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?tag=v0.3.0-beta.32#9ce4e5f6a14450ae7d924b0c4b40185519fa5fc3" ++source = "git+https://github.com/UTEXO-Protocol/rgb-lib.git?rev=95332c41fd715939ac6e078ad859d474b1f6fa9b#95332c41fd715939ac6e078ad859d474b1f6fa9b" + dependencies = [ + "sea-orm-migration", + "tokio", +@@ -4466,6 +4466,7 @@ dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.19", ++ "uuid", + ] + + [[package]] +diff --git a/bindings/c-ffi/Cargo.toml b/bindings/c-ffi/Cargo.toml +index de835da..e6ad18f 100644 +--- a/bindings/c-ffi/Cargo.toml ++++ b/bindings/c-ffi/Cargo.toml +@@ -24,6 +24,7 @@ serde = { version = "1.0", features = ["derive"] } + serde_json = "1.0" + thiserror = "2.0" + hex = { package = "hex-conservative", version = "0.3.0" } ++uuid = { version = "1.11.0", default-features = false, features = ["v4"] } + + [build-dependencies] + cbindgen = "0.29.0" +diff --git a/bindings/c-ffi/rln.h b/bindings/c-ffi/rln.h +index 7e44bcb..20197b7 100644 +--- a/bindings/c-ffi/rln.h ++++ b/bindings/c-ffi/rln.h +@@ -26,8 +26,9 @@ typedef struct CResult { + } CResult; + + /** +- * Drop a `NativeExternalSigner` handle. Safe to call immediately after +- * attach / init / unlock succeeds: RLN holds its own `Arc` clone. ++ * Shut down and drop a `NativeExternalSigner` handle. Call only after node ++ * shutdown has completed; shutdown invalidates every outstanding `Arc` clone ++ * so the seed-bearing backend and persistent VLS store are released. + */ + void free_native_external_signer(struct COpaqueStruct obj); + +@@ -44,9 +45,18 @@ struct CResultString rln_asset_metadata(const struct COpaqueStruct *node, const + + struct CResultString rln_btc_balance(const struct COpaqueStruct *node, bool skip_sync); + ++struct CResultString rln_cancel_btc_send_plan(const struct COpaqueStruct *node, ++ const char *request_json); ++ ++struct CResultString rln_cancel_create_utxos_plan(const struct COpaqueStruct *node, ++ const char *request_json); ++ + struct CResultString rln_cancel_hodl_invoice(const struct COpaqueStruct *node, + const char *request_json); + ++struct CResultString rln_cancel_rgb_send_plan(const struct COpaqueStruct *node, ++ const char *request_json); ++ + struct CResultString rln_check_indexer_url(const struct COpaqueStruct *node, + const char *indexer_url); + +@@ -58,6 +68,15 @@ struct CResultString rln_claim_hodl_invoice(const struct COpaqueStruct *node, + + struct CResultString rln_close_channel(const struct COpaqueStruct *node, const char *request_json); + ++struct CResultString rln_commit_prepared_btc_send(const struct COpaqueStruct *node, ++ const char *request_json); ++ ++struct CResultString rln_commit_prepared_create_utxos(const struct COpaqueStruct *node, ++ const char *request_json); ++ ++struct CResultString rln_commit_prepared_rgb_send(const struct COpaqueStruct *node, ++ const char *request_json); ++ + struct CResultString rln_connect_peer(const struct COpaqueStruct *node, + const char *peer_pubkey_and_addr); + +@@ -92,6 +111,12 @@ struct CResultString rln_get_swap(const struct COpaqueStruct *node, + const char *payment_hash, + bool taker_flag); + ++struct CResultString rln_import_rgb_contract(const struct COpaqueStruct *node, ++ const char *request_json); ++ ++struct CResultString rln_import_rgb_transfer_consignment(const struct COpaqueStruct *node, ++ const char *request_json); ++ + struct CResultString rln_inflate(const struct COpaqueStruct *node, const char *request_json); + + struct CResultString rln_invoice_status(const struct COpaqueStruct *node, const char *invoice); +@@ -110,6 +135,9 @@ struct CResultString rln_issue_asset_uda(const struct COpaqueStruct *node, + + struct CResultString rln_keysend(const struct COpaqueStruct *node, const char *request_json); + ++struct CResultString rln_list_address_receipts(const struct COpaqueStruct *node, ++ const char *address); ++ + struct CResultString rln_list_assets(const struct COpaqueStruct *node, + const char *filter_asset_schemas_json); + +@@ -119,6 +147,10 @@ struct CResultString rln_list_payments(const struct COpaqueStruct *node); + + struct CResultString rln_list_peers(const struct COpaqueStruct *node); + ++struct CResultString rln_list_pending_rgb_send_plans(const struct COpaqueStruct *node); ++ ++struct CResultString rln_list_pending_vanilla_transactions(const struct COpaqueStruct *node); ++ + struct CResultString rln_list_swaps(const struct COpaqueStruct *node); + + struct CResultString rln_list_transactions(const struct COpaqueStruct *node, +@@ -143,6 +175,11 @@ struct CResult rln_native_external_signer_new(const char *seed_hex, + const char *network, + bool permissive_policy); + ++struct CResult rln_native_external_signer_new_with_storage(const char *seed_hex, ++ const char *network, ++ bool permissive_policy, ++ const char *storage_dir_path); ++ + struct CResultString rln_network_info(const struct COpaqueStruct *node); + + struct CResultString rln_node_info(const struct COpaqueStruct *node); +@@ -152,6 +189,15 @@ struct CResultString rln_open_channel(const struct COpaqueStruct *node, const ch + struct CResultString rln_post_asset_media(const struct COpaqueStruct *node, + const char *request_json); + ++struct CResultString rln_prepare_btc_send(const struct COpaqueStruct *node, ++ const char *request_json); ++ ++struct CResultString rln_prepare_create_utxos(const struct COpaqueStruct *node, ++ const char *request_json); ++ ++struct CResultString rln_prepare_rgb_send(const struct COpaqueStruct *node, ++ const char *request_json); ++ + struct CResultString rln_refresh_transfers(const struct COpaqueStruct *node, + const char *request_json); + +@@ -161,6 +207,9 @@ struct CResultString rln_rotate_address(const struct COpaqueStruct *node); + + struct CResultString rln_sdk_initialize(const char *request_json); + ++struct CResultString rln_sdk_node_adopt_native_operation(const struct COpaqueStruct *node, ++ const char *operation_id); ++ + /** + * APay receiver-side registration with an LSP. Argument is the LSP's + * node_id as a hex string (compressed secp256k1). Returns JSON of +@@ -174,6 +223,9 @@ struct CResultString rln_sdk_node_apay_new(const struct COpaqueStruct *node, + struct CResultString rln_sdk_node_attach_native_external_signer(const struct COpaqueStruct *node, + const struct COpaqueStruct *signer); + ++struct CResultString rln_sdk_node_cancel_native_operation(const struct COpaqueStruct *node, ++ const char *operation_id); ++ + struct CResultString rln_sdk_node_detach_external_signer(const struct COpaqueStruct *node); + + struct CResultString rln_sdk_node_init(const struct COpaqueStruct *node, +@@ -186,10 +238,17 @@ struct CResultString rln_sdk_node_init_with_external_signer(const struct COpaque + struct CResultString rln_sdk_node_init_with_native_external_signer(const struct COpaqueStruct *node, + const struct COpaqueStruct *signer); + ++struct CResultString rln_sdk_node_native_operation_status(const struct COpaqueStruct *node, ++ const char *operation_id); ++ + struct CResult rln_sdk_node_new(const char *request_json); + + struct CResultString rln_sdk_node_shutdown(const struct COpaqueStruct *node); + ++struct CResultString rln_sdk_node_start_unlock_with_native_external_signer(const struct COpaqueStruct *node, ++ const struct COpaqueStruct *signer, ++ const char *request_json); ++ + struct CResultString rln_sdk_node_unlock(const struct COpaqueStruct *node, + const char *request_json); + +@@ -223,6 +282,14 @@ struct CResultString rln_sdk_node_vss_backup(const struct COpaqueStruct *node); + struct CResultString rln_sdk_node_vss_clear_fence(const struct COpaqueStruct *node, + const char *request_json); + ++/** ++ * Permanently delete every object in the authenticated VSS store. ++ * Request JSON: `{"password":"..."}`. The node must be locked. ++ * Returns `{"deleted_keys": u64}` after a verified empty re-list. ++ */ ++struct CResultString rln_sdk_node_vss_delete_all(const struct COpaqueStruct *node, ++ const char *request_json); ++ + struct CResultString rln_sdk_shutdown(void); + + struct CResultString rln_send_btc(const struct COpaqueStruct *node, const char *request_json); +@@ -238,6 +305,8 @@ struct CResultString rln_sign_message(const struct COpaqueStruct *node, const ch + + struct CResultString rln_sync(const struct COpaqueStruct *node); + ++struct CResultString rln_sync_wallet(const struct COpaqueStruct *node, const char *request_json); ++ + struct CResultString rln_taker(const struct COpaqueStruct *node, const char *request_json); + + struct CResultString rln_uniffi_healthcheck(void); +@@ -247,3 +316,6 @@ struct CResultString rln_uniffi_is_initialized(void); + struct CResultString rln_verify_message(const struct COpaqueStruct *node, + const char *message, + const char *signature); ++ ++struct CResultString rln_wallet_snapshot(const struct COpaqueStruct *node, ++ const char *request_json); +diff --git a/bindings/c-ffi/rln.hpp b/bindings/c-ffi/rln.hpp +index 7b06f67..4079885 100644 +--- a/bindings/c-ffi/rln.hpp ++++ b/bindings/c-ffi/rln.hpp +@@ -29,8 +29,9 @@ struct CResult { + + extern "C" { + +-/// Drop a `NativeExternalSigner` handle. Safe to call immediately after +-/// attach / init / unlock succeeds: RLN holds its own `Arc` clone. ++/// Shut down and drop a `NativeExternalSigner` handle. Call only after node ++/// shutdown has completed; shutdown invalidates every outstanding `Arc` clone ++/// so the seed-bearing backend and persistent VLS store are released. + void free_native_external_signer(COpaqueStruct obj); + + void free_sdk_node(COpaqueStruct obj); +@@ -45,8 +46,14 @@ CResultString rln_asset_metadata(const COpaqueStruct *node, const char *asset_id + + CResultString rln_btc_balance(const COpaqueStruct *node, bool skip_sync); + ++CResultString rln_cancel_btc_send_plan(const COpaqueStruct *node, const char *request_json); ++ ++CResultString rln_cancel_create_utxos_plan(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_cancel_hodl_invoice(const COpaqueStruct *node, const char *request_json); + ++CResultString rln_cancel_rgb_send_plan(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_check_indexer_url(const COpaqueStruct *node, const char *indexer_url); + + CResultString rln_check_proxy_endpoint(const COpaqueStruct *node, const char *proxy_endpoint); +@@ -55,6 +62,12 @@ CResultString rln_claim_hodl_invoice(const COpaqueStruct *node, const char *requ + + CResultString rln_close_channel(const COpaqueStruct *node, const char *request_json); + ++CResultString rln_commit_prepared_btc_send(const COpaqueStruct *node, const char *request_json); ++ ++CResultString rln_commit_prepared_create_utxos(const COpaqueStruct *node, const char *request_json); ++ ++CResultString rln_commit_prepared_rgb_send(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_connect_peer(const COpaqueStruct *node, const char *peer_pubkey_and_addr); + + CResultString rln_create_utxos(const COpaqueStruct *node, const char *request_json); +@@ -82,6 +95,11 @@ CResultString rln_get_payment(const COpaqueStruct *node, + + CResultString rln_get_swap(const COpaqueStruct *node, const char *payment_hash, bool taker_flag); + ++CResultString rln_import_rgb_contract(const COpaqueStruct *node, const char *request_json); ++ ++CResultString rln_import_rgb_transfer_consignment(const COpaqueStruct *node, ++ const char *request_json); ++ + CResultString rln_inflate(const COpaqueStruct *node, const char *request_json); + + CResultString rln_invoice_status(const COpaqueStruct *node, const char *invoice); +@@ -96,6 +114,8 @@ CResultString rln_issue_asset_uda(const COpaqueStruct *node, const char *request + + CResultString rln_keysend(const COpaqueStruct *node, const char *request_json); + ++CResultString rln_list_address_receipts(const COpaqueStruct *node, const char *address); ++ + CResultString rln_list_assets(const COpaqueStruct *node, const char *filter_asset_schemas_json); + + CResultString rln_list_channels(const COpaqueStruct *node); +@@ -104,6 +124,10 @@ CResultString rln_list_payments(const COpaqueStruct *node); + + CResultString rln_list_peers(const COpaqueStruct *node); + ++CResultString rln_list_pending_rgb_send_plans(const COpaqueStruct *node); ++ ++CResultString rln_list_pending_vanilla_transactions(const COpaqueStruct *node); ++ + CResultString rln_list_swaps(const COpaqueStruct *node); + + CResultString rln_list_transactions(const COpaqueStruct *node, +@@ -128,6 +152,11 @@ CResult rln_native_external_signer_new(const char *seed_hex, + const char *network, + bool permissive_policy); + ++CResult rln_native_external_signer_new_with_storage(const char *seed_hex, ++ const char *network, ++ bool permissive_policy, ++ const char *storage_dir_path); ++ + CResultString rln_network_info(const COpaqueStruct *node); + + CResultString rln_node_info(const COpaqueStruct *node); +@@ -136,6 +165,12 @@ CResultString rln_open_channel(const COpaqueStruct *node, const char *request_js + + CResultString rln_post_asset_media(const COpaqueStruct *node, const char *request_json); + ++CResultString rln_prepare_btc_send(const COpaqueStruct *node, const char *request_json); ++ ++CResultString rln_prepare_create_utxos(const COpaqueStruct *node, const char *request_json); ++ ++CResultString rln_prepare_rgb_send(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_refresh_transfers(const COpaqueStruct *node, const char *request_json); + + CResultString rln_rgb_invoice(const COpaqueStruct *node, const char *request_json); +@@ -144,6 +179,9 @@ CResultString rln_rotate_address(const COpaqueStruct *node); + + CResultString rln_sdk_initialize(const char *request_json); + ++CResultString rln_sdk_node_adopt_native_operation(const COpaqueStruct *node, ++ const char *operation_id); ++ + /// APay receiver-side registration with an LSP. Argument is the LSP's + /// node_id as a hex string (compressed secp256k1). Returns JSON of + /// `AsyncOrderNewResponse` (request_id, host_node_id, protocol_version, +@@ -154,6 +192,9 @@ CResultString rln_sdk_node_apay_new(const COpaqueStruct *node, const char *host_ + CResultString rln_sdk_node_attach_native_external_signer(const COpaqueStruct *node, + const COpaqueStruct *signer); + ++CResultString rln_sdk_node_cancel_native_operation(const COpaqueStruct *node, ++ const char *operation_id); ++ + CResultString rln_sdk_node_detach_external_signer(const COpaqueStruct *node); + + CResultString rln_sdk_node_init(const COpaqueStruct *node, +@@ -166,10 +207,17 @@ CResultString rln_sdk_node_init_with_external_signer(const COpaqueStruct *node, + CResultString rln_sdk_node_init_with_native_external_signer(const COpaqueStruct *node, + const COpaqueStruct *signer); + ++CResultString rln_sdk_node_native_operation_status(const COpaqueStruct *node, ++ const char *operation_id); ++ + CResult rln_sdk_node_new(const char *request_json); + + CResultString rln_sdk_node_shutdown(const COpaqueStruct *node); + ++CResultString rln_sdk_node_start_unlock_with_native_external_signer(const COpaqueStruct *node, ++ const COpaqueStruct *signer, ++ const char *request_json); ++ + CResultString rln_sdk_node_unlock(const COpaqueStruct *node, const char *request_json); + + CResultString rln_sdk_node_unlock_with_attached_external_signer(const COpaqueStruct *node, +@@ -197,6 +245,11 @@ CResultString rln_sdk_node_vss_backup(const COpaqueStruct *node); + /// when you're certain the previous owner is gone. + CResultString rln_sdk_node_vss_clear_fence(const COpaqueStruct *node, const char *request_json); + ++/// Permanently delete every object in the authenticated VSS store. ++/// Request JSON: `{"password":"..."}`. The node must be locked. ++/// Returns `{"deleted_keys": u64}` after a verified empty re-list. ++CResultString rln_sdk_node_vss_delete_all(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_sdk_shutdown(); + + CResultString rln_send_btc(const COpaqueStruct *node, const char *request_json); +@@ -211,6 +264,8 @@ CResultString rln_sign_message(const COpaqueStruct *node, const char *message); + + CResultString rln_sync(const COpaqueStruct *node); + ++CResultString rln_sync_wallet(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_taker(const COpaqueStruct *node, const char *request_json); + + CResultString rln_uniffi_healthcheck(); +@@ -221,4 +276,6 @@ CResultString rln_verify_message(const COpaqueStruct *node, + const char *message, + const char *signature); + ++CResultString rln_wallet_snapshot(const COpaqueStruct *node, const char *request_json); ++ + } // extern "C" +diff --git a/bindings/c-ffi/src/api.rs b/bindings/c-ffi/src/api.rs +index 548b574..df83c23 100644 +--- a/bindings/c-ffi/src/api.rs ++++ b/bindings/c-ffi/src/api.rs +@@ -3,9 +3,12 @@ + //! zero or more JSON request strings, do the conversion to UniFFI types, + //! dispatch to [`SdkNode`], and serialize the response back to JSON. + ++use std::collections::HashSet; + use std::ffi::c_char; + use std::str::FromStr; ++use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; ++use std::time::{SystemTime, UNIX_EPOCH}; + + use hex::DisplayHex; + use hex::FromHex; +@@ -13,9 +16,17 @@ use rgb_lightning_node as rln; + use rgb_lightning_node::{NativeExternalSigner, SdkNode}; + + use crate::json_types::*; +-use crate::utils::{convert_optional_string, ptr_to_string, require_handle, require_signer, Error}; ++use crate::native_operations; ++use crate::utils::{ ++ convert_optional_string, opaque_identity, ptr_to_string, require_handle, require_signer, Error, ++}; + use crate::COpaqueStruct; + ++const MAX_WALLET_SNAPSHOT_ASSETS: usize = 128; ++const MAX_WALLET_SNAPSHOT_CHANNELS: usize = 512; ++const MAX_WALLET_SNAPSHOT_ACTIVITY_ITEMS: usize = 5_000; ++static WALLET_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(0); ++ + // --------------------------------------------------------------------------- + // String-parse helpers + // --------------------------------------------------------------------------- +@@ -59,6 +70,34 @@ fn json(t: T) -> Result { + Ok(serde_json::to_string(&t)?) + } + ++fn unix_time_ms() -> Result { ++ let millis = SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .map_err(|_| Error::StringParse("system clock is before Unix epoch".to_string()))? ++ .as_millis(); ++ u64::try_from(millis) ++ .map_err(|_| Error::StringParse("system clock exceeds u64 milliseconds".to_string())) ++} ++ ++fn next_wallet_snapshot_sequence() -> Result { ++ WALLET_SNAPSHOT_SEQUENCE ++ .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { ++ current.checked_add(1) ++ }) ++ .map(|previous| previous + 1) ++ .map_err(|_| Error::StringParse("wallet snapshot sequence exhausted".to_string())) ++} ++ ++fn validate_snapshot_limit(name: &str, requested: u16, maximum: usize) -> Result { ++ let requested = usize::from(requested); ++ if requested == 0 || requested > maximum { ++ return Err(Error::StringParse(format!( ++ "{name} must be between 1 and {maximum}" ++ ))); ++ } ++ Ok(requested) ++} ++ + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- +@@ -122,6 +161,16 @@ pub(crate) fn sdk_node_vss_backup(node: &COpaqueStruct) -> Result + json(serde_json::json!({ "version": version })) + } + ++pub(crate) fn sdk_node_vss_delete_all( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let request: JsonVssClearFenceRequest = parse_req(request_json)?; ++ let deleted_keys = node.vss_delete_all(request.into())?; ++ json(serde_json::json!({ "deleted_keys": deleted_keys })) ++} ++ + // APay receiver-side: register this node with an LSP as an async-order + // recipient. PR #51 upstream — returns AsyncOrderNewResponse (request_id, + // host_node_id, protocol_version, order_id, status, accepted_through_index, +@@ -288,7 +337,7 @@ pub(crate) fn decode_rgb_invoice( + ) -> Result { + let node = require_handle(node)?; + let resp = node.decode_rgb_invoice(ptr_to_string(invoice))?; +- json(JsonDecodeRgbInvoiceResponse::from(resp)) ++ json(JsonDecodeRgbInvoiceResponse::try_from(resp)?) + } + + pub(crate) fn get_payment( +@@ -338,6 +387,67 @@ pub(crate) fn send_rgb(node: &COpaqueStruct, request_json: *const c_char) -> Res + json(JsonSendRgbResponse::from(resp)) + } + ++pub(crate) fn import_rgb_transfer_consignment( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonImportRgbTransferConsignmentRequest = parse_req(request_json)?; ++ let resp = node.importrgbtransferconsignment(req.try_into()?)?; ++ json(JsonImportRgbTransferConsignmentResponse::from(resp)) ++} ++ ++pub(crate) fn prepare_rgb_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonSendRgbRequest = parse_req(request_json)?; ++ let resp = node.prepare_rgb_send(req.try_into()?)?; ++ json(JsonPreparedRgbSendResponse::from(resp)) ++} ++ ++pub(crate) fn commit_prepared_rgb_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCommitPreparedSendRequest = parse_req(request_json)?; ++ let resp = node.commit_prepared_rgb_send(req.try_into()?)?; ++ json(JsonSendRgbResponse::from(resp)) ++} ++ ++pub(crate) fn cancel_rgb_send_plan( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCancelBtcSendPlanRequest = parse_req(request_json)?; ++ let resp = node.cancel_rgb_send_plan(req.try_into()?)?; ++ json(JsonCancelBtcSendPlanResponse::from(resp)) ++} ++ ++pub(crate) fn list_pending_rgb_send_plans(node: &COpaqueStruct) -> Result { ++ let node = require_handle(node)?; ++ let plans = node.list_pending_rgb_send_plans()?; ++ json( ++ plans ++ .into_iter() ++ .map(JsonPendingRgbSendPlan::from) ++ .collect::>(), ++ ) ++} ++ ++pub(crate) fn import_rgb_contract( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonImportRgbContractRequest = parse_req(request_json)?; ++ let resp = node.importrgbcontract(req.try_into()?)?; ++ json(JsonImportRgbContractResponse::from(resp)) ++} ++ + pub(crate) fn refresh_transfers( + node: &COpaqueStruct, + request_json: *const c_char, +@@ -580,6 +690,61 @@ pub(crate) fn send_btc(node: &COpaqueStruct, request_json: *const c_char) -> Res + json(JsonSendBtcResponse::from(resp)) + } + ++pub(crate) fn prepare_btc_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonSendBtcRequest = parse_req(request_json)?; ++ let resp = node.prepare_btc_send(req.into())?; ++ json(JsonPreparedSendResponse::from(resp)) ++} ++ ++pub(crate) fn commit_prepared_btc_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCommitPreparedSendRequest = parse_req(request_json)?; ++ let resp = node.commit_prepared_btc_send(req.try_into()?)?; ++ json(JsonSendBtcResponse::from(resp)) ++} ++ ++pub(crate) fn cancel_btc_send_plan( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCancelBtcSendPlanRequest = parse_req(request_json)?; ++ let resp = node.cancel_btc_send_plan(req.try_into()?)?; ++ json(JsonCancelBtcSendPlanResponse::from(resp)) ++} ++ ++pub(crate) fn list_pending_vanilla_transactions(node: &COpaqueStruct) -> Result { ++ let node = require_handle(node)?; ++ let transactions = node.list_pending_vanilla_transactions()?; ++ json( ++ transactions ++ .into_iter() ++ .map(JsonPendingVanillaTransaction::from) ++ .collect::>(), ++ ) ++} ++ ++pub(crate) fn list_address_receipts( ++ node: &COpaqueStruct, ++ address: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let receipts = node.list_address_receipts(ptr_to_string(address))?; ++ json( ++ receipts ++ .into_iter() ++ .map(JsonAddressReceipt::from) ++ .collect::>(), ++ ) ++} ++ + pub(crate) fn create_utxos( + node: &COpaqueStruct, + request_json: *const c_char, +@@ -590,6 +755,36 @@ pub(crate) fn create_utxos( + ok_void() + } + ++pub(crate) fn prepare_create_utxos( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCreateUtxosRequest = parse_req(request_json)?; ++ let resp = node.prepare_create_utxos(req.into())?; ++ json(JsonPreparedCreateUtxosResponse::from(resp)) ++} ++ ++pub(crate) fn commit_prepared_create_utxos( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCommitPreparedSendRequest = parse_req(request_json)?; ++ let resp = node.commit_prepared_create_utxos(req.try_into()?)?; ++ json(JsonSendBtcResponse::from(resp)) ++} ++ ++pub(crate) fn cancel_create_utxos_plan( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let req: JsonCancelBtcSendPlanRequest = parse_req(request_json)?; ++ let resp = node.cancel_create_utxos_plan(req.try_into()?)?; ++ json(JsonCancelBtcSendPlanResponse::from(resp)) ++} ++ + pub(crate) fn list_transactions( + node: &COpaqueStruct, + skip_sync: bool, +@@ -611,6 +806,222 @@ pub(crate) fn sync(node: &COpaqueStruct) -> Result { + ok_void() + } + ++pub(crate) fn sync_wallet( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let request: JsonSyncWalletRequest = parse_req(request_json)?; ++ let response = node.sync_wallet(request.mode.into())?; ++ json(JsonWalletSyncResponse::from(response)) ++} ++ ++#[derive(serde::Serialize)] ++struct WalletSnapshotCapture { ++ network_before: JsonNetworkInfo, ++ network_after: JsonNetworkInfo, ++ node: JsonWalletSnapshotNode, ++ btc: JsonExactBtcBalanceInfo, ++ assets: Vec, ++ channels: Vec, ++ transactions: Option>, ++ payments: Option>, ++ transfers: Option>, ++} ++ ++fn capture_wallet_snapshot_state( ++ node: &mut SdkNode, ++ request: &JsonWalletSnapshotRequest, ++ max_assets: usize, ++ max_channels: usize, ++ max_activity_items: usize, ++) -> Result { ++ let network_before = JsonNetworkInfo::from(node.network_info()?); ++ let node_info = node.node_info()?; ++ let btc = node.btc_balance(true)?; ++ let listed_assets = node.list_assets(vec!["Nia".to_string(), "Ifa".to_string()])?; ++ let mut assets = Vec::::new(); ++ assets.extend( ++ listed_assets ++ .nia ++ .unwrap_or_default() ++ .into_iter() ++ .map(Into::into), ++ ); ++ assets.extend( ++ listed_assets ++ .ifa ++ .unwrap_or_default() ++ .into_iter() ++ .map(Into::into), ++ ); ++ assets.sort_by(|left, right| left.asset_id.cmp(&right.asset_id)); ++ if assets.len() > max_assets { ++ return Err(Error::StringParse(format!( ++ "native RGB inventory contains {} entries, exceeding max_assets {max_assets}", ++ assets.len() ++ ))); ++ } ++ let known_asset_ids = assets ++ .iter() ++ .map(|asset| asset.asset_id.to_string()) ++ .collect::>(); ++ let channels = node.list_channels()?; ++ if channels.len() > max_channels { ++ return Err(Error::StringParse(format!( ++ "native channel inventory contains {} entries, exceeding max_channels {max_channels}", ++ channels.len() ++ ))); ++ } ++ ++ let (transactions, payments, transfers) = if request.include_activity { ++ let transactions = node.list_transactions(true, None)?; ++ let payments = node.list_payments()?; ++ if transactions.len() > max_activity_items || payments.len() > max_activity_items { ++ return Err(Error::StringParse(format!( ++ "native activity exceeds max_activity_items {max_activity_items}" ++ ))); ++ } ++ ++ let mut transfer_count = 0usize; ++ let mut transfers = Vec::new(); ++ for asset_id in request ++ .asset_ids ++ .iter() ++ .filter(|asset_id| known_asset_ids.contains(*asset_id)) ++ { ++ let rows = node.list_transfers(Some(parse_contract_id(asset_id)?), None)?; ++ transfer_count = transfer_count ++ .checked_add(rows.len()) ++ .ok_or_else(|| Error::StringParse("native transfer count overflow".to_string()))?; ++ if transfer_count > max_activity_items { ++ return Err(Error::StringParse(format!( ++ "native transfers exceed max_activity_items {max_activity_items}" ++ ))); ++ } ++ transfers.push(JsonWalletSnapshotAssetTransfers { ++ asset_id: asset_id.clone(), ++ transfers: rows.into_iter().map(Into::into).collect(), ++ }); ++ } ++ ++ ( ++ Some(transactions.into_iter().map(Into::into).collect()), ++ Some(payments.into_iter().map(Into::into).collect()), ++ Some(transfers), ++ ) ++ } else { ++ (None, None, None) ++ }; ++ ++ Ok(WalletSnapshotCapture { ++ network_before, ++ network_after: node.network_info()?.into(), ++ node: node_info.into(), ++ btc: btc.into(), ++ assets: assets.into_iter().map(Into::into).collect(), ++ channels: channels.into_iter().map(Into::into).collect(), ++ transactions, ++ payments, ++ transfers, ++ }) ++} ++ ++fn snapshot_capture_is_coherent(capture: &WalletSnapshotCapture) -> bool { ++ capture.network_before.network == capture.network_after.network ++ && capture.network_before.height == capture.network_after.height ++ && capture.network_before.block_hash == capture.network_after.block_hash ++} ++ ++pub(crate) fn wallet_snapshot( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let request: JsonWalletSnapshotRequest = parse_req(request_json)?; ++ let max_assets = ++ validate_snapshot_limit("max_assets", request.max_assets, MAX_WALLET_SNAPSHOT_ASSETS)?; ++ let max_channels = validate_snapshot_limit( ++ "max_channels", ++ request.max_channels, ++ MAX_WALLET_SNAPSHOT_CHANNELS, ++ )?; ++ let max_activity_items = validate_snapshot_limit( ++ "max_activity_items", ++ request.max_activity_items, ++ MAX_WALLET_SNAPSHOT_ACTIVITY_ITEMS, ++ )?; ++ if request.asset_ids.len() > max_assets { ++ return Err(Error::StringParse(format!( ++ "asset_ids contains {} entries, exceeding max_assets {max_assets}", ++ request.asset_ids.len() ++ ))); ++ } ++ let unique_asset_ids = request.asset_ids.iter().collect::>(); ++ if unique_asset_ids.len() != request.asset_ids.len() { ++ return Err(Error::StringParse( ++ "asset_ids must not contain duplicates".to_string(), ++ )); ++ } ++ for asset_id in &request.asset_ids { ++ parse_contract_id(asset_id)?; ++ } ++ ++ let started_at_ms = unix_time_ms()?; ++ let capture_sequence = next_wallet_snapshot_sequence()?; ++ let mut previous = capture_wallet_snapshot_state( ++ node, ++ &request, ++ max_assets, ++ max_channels, ++ max_activity_items, ++ )?; ++ let mut capture_attempts = 1_u8; ++ let mut accepted = None; ++ for _ in 0..2 { ++ let current = capture_wallet_snapshot_state( ++ node, ++ &request, ++ max_assets, ++ max_channels, ++ max_activity_items, ++ )?; ++ capture_attempts += 1; ++ if snapshot_capture_is_coherent(&previous) ++ && snapshot_capture_is_coherent(¤t) ++ && serde_json::to_vec(&previous)? == serde_json::to_vec(¤t)? ++ { ++ accepted = Some(current); ++ break; ++ } ++ previous = current; ++ } ++ let accepted = accepted.ok_or_else(|| { ++ Error::StringParse( ++ "wallet financial state did not stabilize within three native captures".to_string(), ++ ) ++ })?; ++ let completed_at_ms = unix_time_ms()?; ++ json(JsonWalletSnapshotResponse { ++ contract_version: 3, ++ native_source: "rgb-lightning-node-v0.11.0-beta.3+utexo-wallet-v3", ++ capture_sequence: capture_sequence.to_string(), ++ capture_attempts, ++ stable_capture_count: 2, ++ started_at_ms: started_at_ms.to_string(), ++ completed_at_ms: completed_at_ms.to_string(), ++ network_before: accepted.network_before, ++ network_after: accepted.network_after, ++ node: accepted.node, ++ btc: accepted.btc, ++ assets: accepted.assets, ++ channels: accepted.channels, ++ transactions: accepted.transactions, ++ payments: accepted.payments, ++ transfers: accepted.transfers, ++ }) ++} ++ + // --------------------------------------------------------------------------- + // Swaps / onion + // --------------------------------------------------------------------------- +@@ -716,6 +1127,23 @@ pub(crate) fn native_external_signer_new( + )?) + } + ++pub(crate) fn native_external_signer_new_with_storage( ++ seed_hex: *const c_char, ++ network: *const c_char, ++ permissive_policy: bool, ++ storage_dir_path: *const c_char, ++) -> Result, Error> { ++ let seed_hex = ptr_to_string(seed_hex); ++ let network = ptr_to_string(network); ++ let storage_dir_path = ptr_to_string(storage_dir_path); ++ Ok(NativeExternalSigner::new_with_storage( ++ seed_hex, ++ network, ++ Some(permissive_policy), ++ storage_dir_path, ++ )?) ++} ++ + pub(crate) fn native_external_signer_bootstrap(signer: &COpaqueStruct) -> Result { + let signer = require_signer(signer)?; + let bootstrap = signer.bootstrap()?; +@@ -764,6 +1192,39 @@ pub(crate) fn sdk_node_unlock_with_native_external_signer( + ok_void() + } + ++pub(crate) fn sdk_node_start_unlock_with_native_external_signer( ++ node: &COpaqueStruct, ++ signer: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node_identity = opaque_identity(node)?; ++ let node = require_handle(node)?.clone(); ++ let signer = Arc::clone(require_signer(signer)?); ++ let request: JsonSdkExternalUnlockRequest = parse_req(request_json)?; ++ native_operations::start_unlock(node_identity, node, signer, request) ++} ++ ++pub(crate) fn sdk_node_native_operation_status( ++ node: &COpaqueStruct, ++ operation_id: *const c_char, ++) -> Result { ++ native_operations::status(opaque_identity(node)?, &ptr_to_string(operation_id)) ++} ++ ++pub(crate) fn sdk_node_adopt_native_operation( ++ node: &COpaqueStruct, ++ operation_id: *const c_char, ++) -> Result { ++ native_operations::adopt(opaque_identity(node)?, &ptr_to_string(operation_id)) ++} ++ ++pub(crate) fn sdk_node_cancel_native_operation( ++ node: &COpaqueStruct, ++ operation_id: *const c_char, ++) -> Result { ++ native_operations::cancel(opaque_identity(node)?, &ptr_to_string(operation_id)) ++} ++ + pub(crate) fn sdk_node_init_with_external_signer( + node: &COpaqueStruct, + bootstrap_json: *const c_char, +@@ -798,3 +1259,23 @@ pub(crate) fn sdk_node_unlock_with_attached_external_signer( + )?; + ok_void() + } ++ ++#[cfg(test)] ++mod wallet_snapshot_api_tests { ++ use super::*; ++ ++ #[test] ++ fn snapshot_limits_accept_only_positive_bounded_values() { ++ assert_eq!(validate_snapshot_limit("items", 1, 8).unwrap(), 1); ++ assert_eq!(validate_snapshot_limit("items", 8, 8).unwrap(), 8); ++ assert!(validate_snapshot_limit("items", 0, 8).is_err()); ++ assert!(validate_snapshot_limit("items", 9, 8).is_err()); ++ } ++ ++ #[test] ++ fn snapshot_sequence_is_monotonic() { ++ let first = next_wallet_snapshot_sequence().expect("first sequence"); ++ let second = next_wallet_snapshot_sequence().expect("second sequence"); ++ assert!(second > first); ++ } ++} +diff --git a/bindings/c-ffi/src/json_types.rs b/bindings/c-ffi/src/json_types.rs +index aba9a35..2e40366 100644 +--- a/bindings/c-ffi/src/json_types.rs ++++ b/bindings/c-ffi/src/json_types.rs +@@ -19,22 +19,27 @@ use rgb_lightning_node::{ + BtcBalanceInfo, CancelHodlInvoiceRequest, Channel, ChannelId, ChannelStatus, + CheckIndexerUrlResponse, ClaimHodlInvoiceRequest, ClaimHodlInvoiceResponse, ContractId, + DecodeLnInvoiceResponse, DecodeRgbInvoiceResponse, EmbeddedMedia, EstimateFeeResponse, +- HtlcStatus, IfaIssuanceType, InflateRequest, InflateResponse, InvoiceStatus, +- ListAssetsResponse, LnInvoiceRequest, LnInvoiceResponse, Media, MediaAttachment, NetworkInfo, +- NodeInfo, Payment, PaymentHash, PaymentType, Peer, ProofOfReserves, PublicKey, RecipientId, +- RgbAllocation, RgbOutpoint, RgbRecipient, SdkAssetLinkRequest, SdkCloseChannelRequest, ++ HtlcStatus, IfaIssuanceType, ImportRgbContractRequest, ImportRgbContractResponse, ++ ImportRgbTransferConsignmentRequest, ImportRgbTransferConsignmentResponse, InflateRequest, ++ InflateResponse, InvoiceStatus, ListAssetsResponse, LnInvoiceRequest, LnInvoiceResponse, Media, ++ MediaAttachment, NetworkInfo, NodeInfo, Payment, PaymentHash, PaymentType, Peer, ++ ProofOfReserves, PublicKey, RecipientId, RgbAllocation, RgbAssignmentInfo, RgbOutpoint, ++ RgbRecipient, SdkAddressReceipt, SdkAssetLinkRequest, SdkCancelBtcSendPlanRequest, ++ SdkCancelBtcSendPlanResponse, SdkCloseChannelRequest, SdkCommitPreparedSendRequest, + SdkCreateUtxosRequest, SdkDisconnectPeerRequest, SdkExternalSignerBootstrap, + SdkFailTransfersRequest, SdkFailTransfersResponse, SdkInitRequest, SdkIssueAssetCfaRequest, + SdkIssueAssetIfaRequest, SdkIssueAssetNiaRequest, SdkIssueAssetUdaRequest, SdkKeysendRequest, + SdkKeysendResponse, SdkMakerExecuteRequest, SdkMakerInitRequest, SdkMakerInitResponse, +- SdkOpenChannelRequest, SdkOpenChannelResponse, SdkPostAssetMediaRequest, +- SdkPostAssetMediaResponse, SdkRefreshTransfersRequest, SdkRgbInvoiceRequest, +- SdkRgbInvoiceResponse, SdkSendBtcRequest, SdkSendBtcResponse, SdkSendOnionMessageRequest, +- SdkSendPaymentRequest, SdkSendPaymentResponse, SdkTakerRequest, SdkUnlockRequest, +- SdkVssClearFenceRequest, SendRgbRequest, SendRgbResponse, SignMessageResponse, Swap, SwapList, +- SwapStatus, Token, TokenLight, Transaction, TransactionType, Transfer, +- TransferTransportEndpoint, TransportEndpoint, Txid, Unspent, Utxo, VerifyMessageResponse, +- WitnessData, ++ SdkOpenChannelRequest, SdkOpenChannelResponse, SdkPendingRgbSendPlan, ++ SdkPendingVanillaTransaction, SdkPostAssetMediaRequest, SdkPostAssetMediaResponse, ++ SdkPreparedCreateUtxosResponse, SdkPreparedRgbSendResponse, SdkPreparedSendResponse, ++ SdkRefreshTransfersRequest, SdkRgbInvoiceRequest, SdkRgbInvoiceResponse, SdkSendBtcRequest, ++ SdkSendBtcResponse, SdkSendOnionMessageRequest, SdkSendPaymentRequest, ++ SdkSendPaymentResponse, SdkTakerRequest, SdkUnlockRequest, SdkVssClearFenceRequest, ++ SendRgbRequest, SendRgbResponse, SignMessageResponse, Swap, SwapList, SwapStatus, Token, ++ TokenLight, Transaction, TransactionType, Transfer, TransferTransportEndpoint, ++ TransportEndpoint, Txid, Unspent, Utxo, VerifyMessageResponse, WalletSyncKeychainResult, ++ WalletSyncMode, WalletSyncResult, WitnessData, + }; + use serde::{Deserialize, Serialize}; + +@@ -52,6 +57,10 @@ fn parse_contract_id(s: &str) -> Result { + ContractId::from_str(s).map_err(|e| Error::StringParse(format!("invalid contract id: {e}"))) + } + ++fn parse_txid(s: &str) -> Result { ++ Txid::from_str(s).map_err(|e| Error::StringParse(format!("invalid transaction id: {e}"))) ++} ++ + fn parse_32_hex(s: &str) -> Result<[u8; 32], Error> { + let bytes = + Vec::::from_hex(s).map_err(|e| Error::HexConversion(format!("not hex: {e}")))?; +@@ -229,7 +238,7 @@ impl From for SdkVssClearFenceRequest { + } + + // External-signer mode has no password: the seed never reaches RLN. +-#[derive(Debug, Deserialize)] ++#[derive(Clone, Debug, Deserialize, Serialize)] + pub(crate) struct JsonSdkExternalUnlockRequest { + #[serde(default)] + pub bitcoind_rpc_username: Option, +@@ -474,6 +483,8 @@ pub(crate) struct JsonSendPaymentRequest { + pub asset_id: Option, + #[serde(default)] + pub asset_amount: Option, ++ #[serde(default)] ++ pub max_total_routing_fee_msat: Option, + } + + impl TryFrom for SdkSendPaymentRequest { +@@ -484,6 +495,7 @@ impl TryFrom for SdkSendPaymentRequest { + amt_msat: j.amt_msat, + asset_id: j.asset_id.map(|s| parse_contract_id(&s)).transpose()?, + asset_amount: j.asset_amount, ++ max_total_routing_fee_msat: j.max_total_routing_fee_msat, + }) + } + } +@@ -494,6 +506,7 @@ pub(crate) struct JsonSendPaymentResponse { + pub payment_hash: Option, + pub payment_secret: Option, + pub status: HtlcStatusStr, ++ pub failure_code: Option, + } + + impl From for JsonSendPaymentResponse { +@@ -503,6 +516,7 @@ impl From for JsonSendPaymentResponse { + payment_hash: r.payment_hash.as_ref().map(fmt_payment_hash), + payment_secret: r.payment_secret, + status: r.status.into(), ++ failure_code: r.failure_code, + } + } + } +@@ -601,15 +615,19 @@ pub(crate) struct JsonPayment { + pub amt_msat: Option, + pub asset_amount: Option, + pub asset_id: Option, ++ pub carrier_msat: Option, + pub payment_hash: String, + pub payment_type: PaymentTypeStr, + pub status: HtlcStatusStr, + pub created_at: u64, + pub updated_at: u64, ++ pub expires_at: Option, + pub payee_pubkey: String, + pub preimage: Option, + pub description: Option, + pub description_hash: Option, ++ pub fee_paid_msat: Option, ++ pub failure_code: Option, + } + + impl From for JsonPayment { +@@ -618,15 +636,19 @@ impl From for JsonPayment { + amt_msat: p.amt_msat, + asset_amount: p.asset_amount, + asset_id: p.asset_id.as_ref().map(fmt_contract_id), ++ carrier_msat: p.carrier_msat, + payment_hash: fmt_payment_hash(&p.payment_hash), + payment_type: p.payment_type.into(), + status: p.status.into(), + created_at: p.created_at, + updated_at: p.updated_at, ++ expires_at: p.expires_at, + payee_pubkey: fmt_pubkey(&p.payee_pubkey), + preimage: p.preimage, + description: p.description, + description_hash: p.description_hash, ++ fee_paid_msat: p.fee_paid_msat, ++ failure_code: p.failure_code, + } + } + } +@@ -772,6 +794,7 @@ pub(crate) struct JsonDecodeLnInvoiceResponse { + pub payment_hash: String, + pub payment_secret: String, + pub payee_pubkey: Option, ++ pub min_final_cltv_expiry_delta: u64, + pub network: String, + } + +@@ -786,11 +809,56 @@ impl From for JsonDecodeLnInvoiceResponse { + payment_hash: fmt_payment_hash(&r.payment_hash), + payment_secret: r.payment_secret, + payee_pubkey: r.payee_pubkey.as_ref().map(fmt_pubkey), ++ min_final_cltv_expiry_delta: r.min_final_cltv_expiry_delta, + network: r.network, + } + } + } + ++#[derive(Debug, Serialize)] ++#[serde(tag = "type", content = "value")] ++pub(crate) enum JsonDecodedRgbAssignment { ++ Fungible(u64), ++ NonFungible, ++ InflationRight(u64), ++ Any, ++} ++ ++fn parse_decoded_rgb_assignment(value: &str) -> Result { ++ if value == "Any" { ++ return Ok(JsonDecodedRgbAssignment::Any); ++ } ++ if value == "NonFungible" { ++ return Ok(JsonDecodedRgbAssignment::NonFungible); ++ } ++ ++ for (prefix, constructor) in [ ++ ( ++ "Fungible(", ++ JsonDecodedRgbAssignment::Fungible as fn(u64) -> JsonDecodedRgbAssignment, ++ ), ++ ( ++ "InflationRight(", ++ JsonDecodedRgbAssignment::InflationRight as fn(u64) -> JsonDecodedRgbAssignment, ++ ), ++ ] { ++ if let Some(amount) = value ++ .strip_prefix(prefix) ++ .and_then(|remaining| remaining.strip_suffix(')')) ++ { ++ return amount.parse::().map(constructor).map_err(|error| { ++ Error::StringParse(format!( ++ "invalid decoded RGB assignment amount in {value}: {error}" ++ )) ++ }); ++ } ++ } ++ ++ Err(Error::StringParse(format!( ++ "invalid decoded RGB assignment: {value}" ++ ))) ++} ++ + #[derive(Debug, Serialize)] + pub(crate) struct JsonDecodeRgbInvoiceResponse { + pub recipient_id: String, +@@ -798,25 +866,27 @@ pub(crate) struct JsonDecodeRgbInvoiceResponse { + pub recipient_type: String, + pub asset_schema: Option, + pub asset_id: Option, +- pub assignment: String, ++ pub assignment: JsonDecodedRgbAssignment, + pub network: String, + pub expiration_timestamp: Option, + pub transport_endpoints: Vec, + } + +-impl From for JsonDecodeRgbInvoiceResponse { +- fn from(r: DecodeRgbInvoiceResponse) -> Self { +- JsonDecodeRgbInvoiceResponse { ++impl TryFrom for JsonDecodeRgbInvoiceResponse { ++ type Error = Error; ++ ++ fn try_from(r: DecodeRgbInvoiceResponse) -> Result { ++ Ok(JsonDecodeRgbInvoiceResponse { + recipient_id: r.recipient_id, + proxy_recipient_id: r.proxy_recipient_id, + recipient_type: r.recipient_type, + asset_schema: r.asset_schema, + asset_id: r.asset_id.as_ref().map(fmt_contract_id), +- assignment: r.assignment, ++ assignment: parse_decoded_rgb_assignment(&r.assignment)?, + network: r.network, + expiration_timestamp: r.expiration_timestamp, + transport_endpoints: r.transport_endpoints, +- } ++ }) + } + } + +@@ -985,6 +1055,80 @@ impl From for JsonSendRgbResponse { + } + } + ++#[derive(Debug, Deserialize)] ++pub(crate) struct JsonImportRgbTransferConsignmentRequest { ++ pub consignment_base64: String, ++ pub offchain_txid: String, ++ #[serde(default)] ++ pub expected_asset_id: Option, ++} ++ ++impl TryFrom for ImportRgbTransferConsignmentRequest { ++ type Error = Error; ++ ++ fn try_from(j: JsonImportRgbTransferConsignmentRequest) -> Result { ++ Ok(ImportRgbTransferConsignmentRequest { ++ consignment_base64: j.consignment_base64, ++ offchain_txid: j.offchain_txid, ++ expected_asset_id: j ++ .expected_asset_id ++ .map(|id| parse_contract_id(&id)) ++ .transpose()?, ++ }) ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonImportRgbTransferConsignmentResponse { ++ pub asset_id: String, ++ pub already_imported: bool, ++ pub metadata: JsonAssetMetadataInfo, ++} ++ ++impl From for JsonImportRgbTransferConsignmentResponse { ++ fn from(r: ImportRgbTransferConsignmentResponse) -> Self { ++ JsonImportRgbTransferConsignmentResponse { ++ asset_id: r.asset_id.to_string(), ++ already_imported: r.already_imported, ++ metadata: JsonAssetMetadataInfo::from(r.metadata), ++ } ++ } ++} ++ ++#[derive(Debug, Deserialize)] ++pub(crate) struct JsonImportRgbContractRequest { ++ pub contract_base64: String, ++ pub expected_asset_id: String, ++} ++ ++impl TryFrom for ImportRgbContractRequest { ++ type Error = Error; ++ ++ fn try_from(j: JsonImportRgbContractRequest) -> Result { ++ Ok(ImportRgbContractRequest { ++ contract_base64: j.contract_base64, ++ expected_asset_id: parse_contract_id(&j.expected_asset_id)?, ++ }) ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonImportRgbContractResponse { ++ pub asset_id: String, ++ pub already_imported: bool, ++ pub metadata: JsonAssetMetadataInfo, ++} ++ ++impl From for JsonImportRgbContractResponse { ++ fn from(r: ImportRgbContractResponse) -> Self { ++ Self { ++ asset_id: fmt_contract_id(&r.asset_id), ++ already_imported: r.already_imported, ++ metadata: r.metadata.into(), ++ } ++ } ++} ++ + #[derive(Debug, Deserialize)] + pub(crate) struct JsonRefreshTransfersRequest { + pub skip_sync: bool, +@@ -1636,13 +1780,15 @@ impl From for JsonNodeInfo { + pub(crate) struct JsonNetworkInfo { + pub network: String, + pub height: u32, ++ pub block_hash: String, + } + + impl From for JsonNetworkInfo { + fn from(n: NetworkInfo) -> Self { + JsonNetworkInfo { +- network: n.network, ++ network: n.network.to_ascii_lowercase(), + height: n.height, ++ block_hash: n.block_hash, + } + } + } +@@ -1690,6 +1836,758 @@ impl From for JsonBtcBalanceInfo { + } + } + ++// --------------------------------------------------------------------------- ++// Versioned wallet synchronization + exact wallet snapshot ++// --------------------------------------------------------------------------- ++ ++#[derive(Debug, Clone, Copy, Deserialize, Serialize)] ++#[serde(rename_all = "snake_case")] ++pub(crate) enum JsonWalletSyncMode { ++ Routine, ++ Recovery, ++} ++ ++impl From for WalletSyncMode { ++ fn from(mode: JsonWalletSyncMode) -> Self { ++ match mode { ++ JsonWalletSyncMode::Routine => WalletSyncMode::Routine, ++ JsonWalletSyncMode::Recovery => WalletSyncMode::Recovery, ++ } ++ } ++} ++ ++impl From for JsonWalletSyncMode { ++ fn from(mode: WalletSyncMode) -> Self { ++ match mode { ++ WalletSyncMode::Routine => JsonWalletSyncMode::Routine, ++ WalletSyncMode::Recovery => JsonWalletSyncMode::Recovery, ++ } ++ } ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub(crate) struct JsonSyncWalletRequest { ++ pub mode: JsonWalletSyncMode, ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSyncKeychainResult { ++ pub status: &'static str, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub error_code: Option, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub checkpoint: Option, ++} ++ ++impl From for JsonWalletSyncKeychainResult { ++ fn from(result: WalletSyncKeychainResult) -> Self { ++ Self { ++ status: if result.succeeded { ++ "succeeded" ++ } else { ++ "failed" ++ }, ++ error_code: result.error_code, ++ checkpoint: result.checkpoint.map(Into::into), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSyncResponse { ++ pub contract_version: u16, ++ pub mode: JsonWalletSyncMode, ++ pub vanilla: JsonWalletSyncKeychainResult, ++ pub colored: JsonWalletSyncKeychainResult, ++} ++ ++impl From for JsonWalletSyncResponse { ++ fn from(result: WalletSyncResult) -> Self { ++ Self { ++ contract_version: 2, ++ mode: result.mode.into(), ++ vanilla: result.vanilla.into(), ++ colored: result.colored.into(), ++ } ++ } ++} ++ ++fn default_snapshot_max_assets() -> u16 { ++ 32 ++} ++ ++fn default_snapshot_max_channels() -> u16 { ++ 128 ++} ++ ++fn default_snapshot_max_activity_items() -> u16 { ++ 1_000 ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub(crate) struct JsonWalletSnapshotRequest { ++ #[serde(default)] ++ pub asset_ids: Vec, ++ #[serde(default = "default_snapshot_max_assets")] ++ pub max_assets: u16, ++ #[serde(default = "default_snapshot_max_channels")] ++ pub max_channels: u16, ++ #[serde(default = "default_snapshot_max_activity_items")] ++ pub max_activity_items: u16, ++ #[serde(default)] ++ pub include_activity: bool, ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonExactBalance { ++ pub settled: String, ++ pub future: String, ++ pub spendable: String, ++} ++ ++impl From for JsonExactBalance { ++ fn from(balance: BtcBalance) -> Self { ++ Self { ++ settled: balance.settled.to_string(), ++ future: balance.future.to_string(), ++ spendable: balance.spendable.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonExactBtcBalanceInfo { ++ pub vanilla: JsonExactBalance, ++ pub colored: JsonExactBalance, ++} ++ ++impl From for JsonExactBtcBalanceInfo { ++ fn from(balance: BtcBalanceInfo) -> Self { ++ Self { ++ vanilla: balance.vanilla.into(), ++ colored: balance.colored.into(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonExactAssetBalance { ++ pub settled: String, ++ pub future: String, ++ pub spendable: String, ++ pub offchain_outbound: String, ++ pub offchain_inbound: String, ++} ++ ++impl From for JsonExactAssetBalance { ++ fn from(balance: AssetBalanceInfo) -> Self { ++ Self { ++ settled: balance.settled.to_string(), ++ future: balance.future.to_string(), ++ spendable: balance.spendable.to_string(), ++ offchain_outbound: balance.offchain_outbound.to_string(), ++ offchain_inbound: balance.offchain_inbound.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotAsset { ++ pub asset_id: String, ++ pub ticker: String, ++ pub name: String, ++ pub precision: u8, ++ pub balance: JsonExactAssetBalance, ++} ++ ++impl From for JsonWalletSnapshotAsset { ++ fn from(asset: AssetNia) -> Self { ++ Self { ++ asset_id: fmt_contract_id(&asset.asset_id), ++ ticker: asset.ticker, ++ name: asset.name, ++ precision: asset.precision, ++ balance: asset.balance.into(), ++ } ++ } ++} ++ ++impl From for JsonWalletSnapshotAsset { ++ fn from(asset: AssetIfa) -> Self { ++ Self { ++ asset_id: fmt_contract_id(&asset.asset_id), ++ ticker: asset.ticker, ++ name: asset.name, ++ precision: asset.precision, ++ balance: asset.balance.into(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotNode { ++ pub pubkey: String, ++ pub num_channels: String, ++ pub num_usable_channels: String, ++ pub claimable_onchain_sat: String, ++ pub eventual_close_fees_sat: String, ++ pub pending_outbound_payments_sat: String, ++ pub num_peers: String, ++ pub latest_rgs_snapshot_timestamp: Option, ++} ++ ++impl From for JsonWalletSnapshotNode { ++ fn from(node: NodeInfo) -> Self { ++ Self { ++ pubkey: fmt_pubkey(&node.pubkey), ++ num_channels: node.num_channels.to_string(), ++ num_usable_channels: node.num_usable_channels.to_string(), ++ claimable_onchain_sat: node.local_balance_sat.to_string(), ++ eventual_close_fees_sat: node.eventual_close_fees_sat.to_string(), ++ pending_outbound_payments_sat: node.pending_outbound_payments_sat.to_string(), ++ num_peers: node.num_peers.to_string(), ++ latest_rgs_snapshot_timestamp: node ++ .latest_rgs_snapshot_timestamp ++ .map(|value| value.to_string()), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotChannel { ++ pub channel_id: String, ++ pub peer_pubkey: String, ++ pub status: ChannelStatusStr, ++ pub ready: bool, ++ pub capacity_sat: String, ++ pub claimable_onchain_sat: String, ++ pub outbound_capacity_msat: String, ++ pub inbound_capacity_msat: String, ++ pub next_outbound_htlc_limit_msat: String, ++ pub next_outbound_htlc_minimum_msat: String, ++ pub is_usable: bool, ++ pub public: bool, ++ pub funding_txid: Option, ++ pub peer_alias: Option, ++ pub short_channel_id: Option, ++ pub asset_id: Option, ++ pub asset_local_amount: Option, ++ pub asset_remote_amount: Option, ++ pub virtual_open_mode: Option, ++} ++ ++impl From for JsonWalletSnapshotChannel { ++ fn from(channel: Channel) -> Self { ++ Self { ++ channel_id: fmt_channel_id(&channel.channel_id), ++ peer_pubkey: fmt_pubkey(&channel.peer_pubkey), ++ status: channel.status.into(), ++ ready: channel.ready, ++ capacity_sat: channel.capacity_sat.to_string(), ++ claimable_onchain_sat: channel.local_balance_sat.to_string(), ++ outbound_capacity_msat: channel.outbound_balance_msat.to_string(), ++ inbound_capacity_msat: channel.inbound_balance_msat.to_string(), ++ next_outbound_htlc_limit_msat: channel.next_outbound_htlc_limit_msat.to_string(), ++ next_outbound_htlc_minimum_msat: channel.next_outbound_htlc_minimum_msat.to_string(), ++ is_usable: channel.is_usable, ++ public: channel.public, ++ funding_txid: channel.funding_txid.as_ref().map(fmt_txid), ++ peer_alias: channel.peer_alias, ++ short_channel_id: channel.short_channel_id.map(|value| value.to_string()), ++ asset_id: channel.asset_id.as_ref().map(fmt_contract_id), ++ asset_local_amount: channel.asset_local_amount.map(|value| value.to_string()), ++ asset_remote_amount: channel.asset_remote_amount.map(|value| value.to_string()), ++ virtual_open_mode: channel.virtual_open_mode, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotBlockTime { ++ pub height: u32, ++ pub timestamp: String, ++} ++ ++impl From for JsonWalletSnapshotBlockTime { ++ fn from(block: BlockTime) -> Self { ++ Self { ++ height: block.height, ++ timestamp: block.timestamp.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotTransaction { ++ pub transaction_type: TransactionTypeStr, ++ pub purpose: &'static str, ++ pub direction: &'static str, ++ pub txid: String, ++ pub received: String, ++ pub sent: String, ++ pub fee: String, ++ pub external_value: Option, ++ pub confirmation_time: Option, ++} ++ ++impl From for JsonWalletSnapshotTransaction { ++ fn from(transaction: Transaction) -> Self { ++ let (purpose, direction, external_value) = match &transaction.transaction_type { ++ TransactionType::Incoming => ( ++ "incoming_bitcoin", ++ "incoming", ++ transaction.received.checked_sub(transaction.sent), ++ ), ++ TransactionType::SendBtc => ( ++ "outgoing_bitcoin", ++ "outgoing", ++ transaction ++ .sent ++ .checked_sub(transaction.received) ++ .and_then(|value| value.checked_sub(transaction.fee)), ++ ), ++ TransactionType::RgbSend => ("rgb_anchor", "internal", None), ++ TransactionType::Drain => ("wallet_drain", "internal", None), ++ TransactionType::CreateUtxos => ("rgb_utxo_maintenance", "internal", None), ++ }; ++ Self { ++ transaction_type: transaction.transaction_type.into(), ++ purpose, ++ direction, ++ txid: fmt_txid(&transaction.txid), ++ received: transaction.received.to_string(), ++ sent: transaction.sent.to_string(), ++ fee: transaction.fee.to_string(), ++ external_value: external_value.map(|value| value.to_string()), ++ confirmation_time: transaction.confirmation_time.map(Into::into), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotPayment { ++ pub amt_msat: Option, ++ pub asset_amount: Option, ++ pub asset_id: Option, ++ pub carrier_msat: Option, ++ pub payment_hash: String, ++ pub payment_type: PaymentTypeStr, ++ pub status: HtlcStatusStr, ++ pub created_at: String, ++ pub updated_at: String, ++ pub expires_at: Option, ++ pub payee_pubkey: String, ++ pub fee_paid_msat: Option, ++ pub failure_code: Option, ++} ++ ++impl From for JsonWalletSnapshotPayment { ++ fn from(payment: Payment) -> Self { ++ Self { ++ amt_msat: payment.amt_msat.map(|value| value.to_string()), ++ asset_amount: payment.asset_amount.map(|value| value.to_string()), ++ asset_id: payment.asset_id.as_ref().map(fmt_contract_id), ++ carrier_msat: payment.carrier_msat.map(|value| value.to_string()), ++ payment_hash: fmt_payment_hash(&payment.payment_hash), ++ payment_type: payment.payment_type.into(), ++ status: payment.status.into(), ++ created_at: payment.created_at.to_string(), ++ updated_at: payment.updated_at.to_string(), ++ expires_at: payment.expires_at.map(|value| value.to_string()), ++ payee_pubkey: fmt_pubkey(&payment.payee_pubkey), ++ fee_paid_msat: payment.fee_paid_msat.map(|value| value.to_string()), ++ failure_code: payment.failure_code, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotTransfer { ++ pub idx: i32, ++ pub created_at: String, ++ pub updated_at: String, ++ pub status: String, ++ pub requested_assignment: Option, ++ pub assignments: Vec, ++ pub kind: String, ++ pub txid: Option, ++ pub recipient_id: Option, ++ pub receive_utxo: Option, ++ pub change_utxo: Option, ++ pub expiration: Option, ++ pub transport_endpoints: Vec, ++} ++ ++impl From for JsonWalletSnapshotTransfer { ++ fn from(transfer: Transfer) -> Self { ++ Self { ++ idx: transfer.idx, ++ created_at: transfer.created_at.to_string(), ++ updated_at: transfer.updated_at.to_string(), ++ status: transfer.status, ++ requested_assignment: transfer.requested_assignment_structured.map(Into::into), ++ assignments: transfer ++ .assignments_structured ++ .into_iter() ++ .map(Into::into) ++ .collect(), ++ kind: transfer.kind, ++ txid: transfer.txid.as_ref().map(fmt_txid), ++ recipient_id: transfer.recipient_id, ++ receive_utxo: transfer.receive_utxo, ++ change_utxo: transfer.change_utxo, ++ expiration: transfer.expiration.map(|value| value.to_string()), ++ transport_endpoints: transfer ++ .transport_endpoints ++ .into_iter() ++ .map(Into::into) ++ .collect(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonRgbAssignment { ++ pub kind: String, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub amount: Option, ++} ++ ++impl From for JsonRgbAssignment { ++ fn from(assignment: RgbAssignmentInfo) -> Self { ++ Self { ++ kind: assignment.kind, ++ amount: assignment.amount.map(|value| value.to_string()), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotAssetTransfers { ++ pub asset_id: String, ++ pub transfers: Vec, ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotResponse { ++ pub contract_version: u16, ++ pub native_source: &'static str, ++ pub capture_sequence: String, ++ pub capture_attempts: u8, ++ pub stable_capture_count: u8, ++ pub started_at_ms: String, ++ pub completed_at_ms: String, ++ pub network_before: JsonNetworkInfo, ++ pub network_after: JsonNetworkInfo, ++ pub node: JsonWalletSnapshotNode, ++ pub btc: JsonExactBtcBalanceInfo, ++ pub assets: Vec, ++ pub channels: Vec, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub transactions: Option>, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub payments: Option>, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub transfers: Option>, ++} ++ ++#[cfg(test)] ++mod wallet_snapshot_contract_tests { ++ use super::*; ++ ++ const ASSET_ID: &str = "rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8"; ++ ++ #[test] ++ fn rgb_contract_import_request_uses_the_documented_json_shape() { ++ let json = format!(r#"{{"contract_base64":"AA==","expected_asset_id":"{ASSET_ID}"}}"#); ++ let request: JsonImportRgbContractRequest = serde_json::from_str(&json).unwrap(); ++ let typed = ImportRgbContractRequest::try_from(request).unwrap(); ++ ++ assert_eq!(typed.contract_base64, "AA=="); ++ assert_eq!(typed.expected_asset_id.to_string(), ASSET_ID); ++ } ++ ++ #[test] ++ fn rgb_transfer_import_request_preserves_optional_asset_verification() { ++ let json = format!( ++ r#"{{"consignment_base64":"AA==","offchain_txid":"offchain-id","expected_asset_id":"{ASSET_ID}"}}"# ++ ); ++ let request: JsonImportRgbTransferConsignmentRequest = serde_json::from_str(&json).unwrap(); ++ let typed = ImportRgbTransferConsignmentRequest::try_from(request).unwrap(); ++ ++ assert_eq!(typed.consignment_base64, "AA=="); ++ assert_eq!(typed.offchain_txid, "offchain-id"); ++ assert_eq!(typed.expected_asset_id.unwrap().to_string(), ASSET_ID); ++ } ++ ++ #[test] ++ fn rgb_import_request_rejects_an_invalid_expected_asset_id() { ++ let request = JsonImportRgbContractRequest { ++ contract_base64: "AA==".to_string(), ++ expected_asset_id: "not-an-asset-id".to_string(), ++ }; ++ ++ assert!(ImportRgbContractRequest::try_from(request).is_err()); ++ } ++ ++ #[test] ++ fn send_payment_request_preserves_explicit_routing_fee_cap() { ++ let request = serde_json::from_str::( ++ r#"{ ++ "invoice":"lnbcrt1example", ++ "amt_msat":100000, ++ "max_total_routing_fee_msat":1250 ++ }"#, ++ ) ++ .expect("parse send payment request"); ++ let request = SdkSendPaymentRequest::try_from(request).expect("map send payment request"); ++ ++ assert_eq!(request.max_total_routing_fee_msat, Some(1_250)); ++ } ++ ++ #[test] ++ fn prepared_send_response_serializes_all_u64_values_losslessly() { ++ let response = JsonPreparedSendResponse::from(SdkPreparedSendResponse { ++ plan_id: Txid::from_str( ++ "0000000000000000000000000000000000000000000000000000000000000000", ++ ) ++ .expect("valid txid"), ++ fee_sat: u64::MAX, ++ total_input_sat: u64::MAX - 1, ++ total_output_sat: u64::MAX - 2, ++ size_vbytes: u64::MAX - 3, ++ }); ++ let json = serde_json::to_value(response).expect("serialize prepared send"); ++ ++ assert_eq!(json["fee_sat"], u64::MAX.to_string()); ++ assert_eq!(json["total_input_sat"], (u64::MAX - 1).to_string()); ++ assert_eq!(json["total_output_sat"], (u64::MAX - 2).to_string()); ++ assert_eq!(json["size_vbytes"], (u64::MAX - 3).to_string()); ++ assert!(json.get("unsigned_psbt").is_none()); ++ } ++ ++ #[test] ++ fn prepared_rgb_send_response_exposes_batch_identity_without_psbt_material() { ++ let response = JsonPreparedRgbSendResponse::from(SdkPreparedRgbSendResponse { ++ plan_id: Txid::from_str( ++ "0000000000000000000000000000000000000000000000000000000000000000", ++ ) ++ .expect("valid txid"), ++ batch_transfer_idx: 7, ++ fee_sat: 10, ++ total_input_sat: 20, ++ total_output_sat: 10, ++ size_vbytes: 100, ++ }); ++ let json = serde_json::to_value(response).expect("serialize prepared RGB send"); ++ ++ assert_eq!(json["batch_transfer_idx"], 7); ++ assert!(json.get("unsigned_psbt").is_none()); ++ } ++ ++ #[test] ++ fn prepared_create_utxos_response_exposes_review_data_without_psbt_material() { ++ let response = JsonPreparedCreateUtxosResponse::from(SdkPreparedCreateUtxosResponse { ++ plan_id: Txid::from_str( ++ "0000000000000000000000000000000000000000000000000000000000000000", ++ ) ++ .expect("valid txid"), ++ fee_sat: 250, ++ total_input_sat: 25_000, ++ total_output_sat: 24_750, ++ size_vbytes: 200, ++ target_count: 5, ++ output_size_sat: 1_000, ++ }); ++ let json = serde_json::to_value(response).expect("serialize prepared UTXO setup"); ++ ++ assert_eq!(json["target_count"], 5); ++ assert_eq!(json["output_size_sat"], 1_000); ++ assert_eq!(json["fee_sat"], "250"); ++ assert!(json.get("unsigned_psbt").is_none()); ++ } ++ ++ #[test] ++ fn unspent_response_preserves_pending_blind_reservations() { ++ let response = JsonUnspent::from(Unspent { ++ utxo: Utxo { ++ outpoint: "0000000000000000000000000000000000000000000000000000000000000000:0" ++ .to_string(), ++ btc_amount: 20_000, ++ colorable: true, ++ }, ++ rgb_allocations: Vec::new(), ++ pending_blinded: 2, ++ }); ++ let json = serde_json::to_value(response).expect("serialize unspent"); ++ ++ assert_eq!(json["pending_blinded"], 2); ++ } ++ ++ #[test] ++ fn prepared_send_commit_accepts_only_a_plan_identity() { ++ let request = serde_json::from_value::( ++ serde_json::json!({ "plan_id": "0".repeat(64) }), ++ ) ++ .expect("parse plan-only commit"); ++ let mapped = SdkCommitPreparedSendRequest::try_from(request).expect("map plan-only commit"); ++ assert_eq!(mapped.plan_id.to_string(), "0".repeat(64)); ++ ++ let legacy = serde_json::from_value::(serde_json::json!({ ++ "plan_id": "0".repeat(64), ++ "unsigned_psbt": "must-not-cross-the-binding" ++ })); ++ assert!(legacy.is_err()); ++ } ++ ++ #[test] ++ fn network_info_uses_canonical_lowercase_names() { ++ let value = JsonNetworkInfo::from(NetworkInfo { ++ network: "Regtest".to_string(), ++ height: 42, ++ block_hash: "0".repeat(64), ++ }); ++ let json = serde_json::to_value(value).expect("serialize network info"); ++ ++ assert_eq!(json["network"], "regtest"); ++ assert_eq!(json["height"], 42); ++ assert_eq!(json["block_hash"], "0".repeat(64)); ++ } ++ ++ #[test] ++ fn decoded_ln_invoice_preserves_minimum_final_cltv_delta() { ++ let value = JsonDecodeLnInvoiceResponse::from(DecodeLnInvoiceResponse { ++ amt_msat: Some(1_000), ++ expiry_sec: 600, ++ timestamp: 1_700_000_000, ++ asset_id: None, ++ asset_amount: None, ++ payment_hash: parse_payment_hash(&"00".repeat(32)).expect("valid payment hash"), ++ payment_secret: "11".repeat(32), ++ payee_pubkey: None, ++ min_final_cltv_expiry_delta: 72, ++ network: "regtest".to_string(), ++ }); ++ let json = serde_json::to_value(value).expect("serialize decoded invoice"); ++ ++ assert_eq!(json["min_final_cltv_expiry_delta"], 72); ++ } ++ ++ #[test] ++ fn decoded_rgb_invoice_uses_a_stable_tagged_assignment() { ++ let response = DecodeRgbInvoiceResponse { ++ recipient_id: "bcrt:utxob:test".to_string(), ++ proxy_recipient_id: "bcrt:utxob:test".to_string(), ++ recipient_type: "Blind".to_string(), ++ asset_schema: Some("Nia".to_string()), ++ asset_id: None, ++ assignment: "Fungible(0)".to_string(), ++ network: "Regtest".to_string(), ++ expiration_timestamp: Some(1_700_003_600), ++ transport_endpoints: vec!["rpc://127.0.0.1:3000/json-rpc".to_string()], ++ }; ++ let value = ++ JsonDecodeRgbInvoiceResponse::try_from(response).expect("map decoded RGB invoice"); ++ let json = serde_json::to_value(value).expect("serialize decoded RGB invoice"); ++ ++ assert_eq!( ++ json["assignment"], ++ serde_json::json!({ "type": "Fungible", "value": 0 }) ++ ); ++ assert_eq!(json["recipient_type"], "Blind"); ++ assert_eq!(json["expiration_timestamp"], 1_700_003_600_i64); ++ } ++ ++ #[test] ++ fn decoded_rgb_invoice_rejects_unknown_debug_assignments() { ++ let error = parse_decoded_rgb_assignment("Unknown(1)") ++ .expect_err("unknown assignment must fail closed"); ++ ++ assert!(error.to_string().contains("invalid decoded RGB assignment")); ++ } ++ ++ #[test] ++ fn sync_request_rejects_unknown_fields() { ++ let error = ++ serde_json::from_str::(r#"{"mode":"routine","typo":true}"#) ++ .expect_err("unknown request fields must fail closed"); ++ ++ assert!(error.to_string().contains("unknown field")); ++ } ++ ++ #[test] ++ fn snapshot_request_has_bounded_documented_defaults() { ++ let request = serde_json::from_str::("{}") ++ .expect("empty snapshot request"); ++ ++ assert!(request.asset_ids.is_empty()); ++ assert_eq!(request.max_assets, 32); ++ assert_eq!(request.max_channels, 128); ++ assert_eq!(request.max_activity_items, 1_000); ++ assert!(!request.include_activity); ++ } ++ ++ #[test] ++ fn exact_btc_balance_serializes_every_amount_as_decimal_text() { ++ let value = JsonExactBtcBalanceInfo::from(BtcBalanceInfo { ++ vanilla: BtcBalance { ++ settled: u64::MAX, ++ future: 2, ++ spendable: 1, ++ }, ++ colored: BtcBalance { ++ settled: 3, ++ future: 4, ++ spendable: 5, ++ }, ++ }); ++ ++ let json = serde_json::to_value(value).expect("serialize exact balance"); ++ assert_eq!(json["vanilla"]["settled"], u64::MAX.to_string()); ++ assert_eq!(json["colored"]["spendable"], "5"); ++ assert!(json["vanilla"]["settled"].is_string()); ++ } ++ ++ #[test] ++ fn wallet_snapshot_payment_serializes_durable_rgb_identity_and_terminal_reason() { ++ let payment = Payment { ++ amt_msat: Some(3_000), ++ asset_amount: Some(12_500_000), ++ asset_id: Some( ++ parse_contract_id("rgb:EIkAVQvq-WbAb5JG-CYxbUER-oqDNwne-ZNxBDID-p0cpf9U") ++ .expect("valid contract id"), ++ ), ++ carrier_msat: Some(3_000), ++ payment_hash: parse_payment_hash(&"11".repeat(32)).expect("valid payment hash"), ++ payment_type: PaymentType::InboundHodl, ++ status: HtlcStatus::Failed, ++ created_at: 1_700_000_000, ++ updated_at: 1_700_000_100, ++ expires_at: Some(1_700_003_600), ++ payee_pubkey: parse_pubkey( ++ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", ++ ) ++ .expect("valid public key"), ++ preimage: None, ++ description: None, ++ description_hash: None, ++ fee_paid_msat: None, ++ failure_code: Some("INVOICE_EXPIRED".to_string()), ++ }; ++ ++ let json = serde_json::to_value(JsonWalletSnapshotPayment::from(payment)) ++ .expect("serialize snapshot payment"); ++ ++ assert_eq!(json["amt_msat"], "3000"); ++ assert_eq!(json["asset_amount"], "12500000"); ++ assert_eq!(json["carrier_msat"], "3000"); ++ assert_eq!(json["expires_at"], "1700003600"); ++ assert_eq!(json["failure_code"], "INVOICE_EXPIRED"); ++ } ++} ++ + #[derive(Debug, Serialize)] + pub(crate) struct JsonSignMessageResponse { + pub signed_message: String, +@@ -1778,6 +2676,169 @@ impl From for JsonSendBtcResponse { + } + } + ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonPreparedSendResponse { ++ pub plan_id: String, ++ pub fee_sat: String, ++ pub total_input_sat: String, ++ pub total_output_sat: String, ++ pub size_vbytes: String, ++} ++ ++impl From for JsonPreparedSendResponse { ++ fn from(r: SdkPreparedSendResponse) -> Self { ++ JsonPreparedSendResponse { ++ plan_id: fmt_txid(&r.plan_id), ++ fee_sat: r.fee_sat.to_string(), ++ total_input_sat: r.total_input_sat.to_string(), ++ total_output_sat: r.total_output_sat.to_string(), ++ size_vbytes: r.size_vbytes.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonPreparedCreateUtxosResponse { ++ pub plan_id: String, ++ pub fee_sat: String, ++ pub total_input_sat: String, ++ pub total_output_sat: String, ++ pub size_vbytes: String, ++ pub target_count: u8, ++ pub output_size_sat: u32, ++} ++ ++impl From for JsonPreparedCreateUtxosResponse { ++ fn from(r: SdkPreparedCreateUtxosResponse) -> Self { ++ JsonPreparedCreateUtxosResponse { ++ plan_id: fmt_txid(&r.plan_id), ++ fee_sat: r.fee_sat.to_string(), ++ total_input_sat: r.total_input_sat.to_string(), ++ total_output_sat: r.total_output_sat.to_string(), ++ size_vbytes: r.size_vbytes.to_string(), ++ target_count: r.target_count, ++ output_size_sat: r.output_size_sat, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonPreparedRgbSendResponse { ++ pub plan_id: String, ++ pub batch_transfer_idx: i32, ++ pub fee_sat: String, ++ pub total_input_sat: String, ++ pub total_output_sat: String, ++ pub size_vbytes: String, ++} ++ ++impl From for JsonPreparedRgbSendResponse { ++ fn from(r: SdkPreparedRgbSendResponse) -> Self { ++ JsonPreparedRgbSendResponse { ++ plan_id: fmt_txid(&r.plan_id), ++ batch_transfer_idx: r.batch_transfer_idx, ++ fee_sat: r.fee_sat.to_string(), ++ total_input_sat: r.total_input_sat.to_string(), ++ total_output_sat: r.total_output_sat.to_string(), ++ size_vbytes: r.size_vbytes.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub(crate) struct JsonCommitPreparedSendRequest { ++ pub plan_id: String, ++} ++ ++impl TryFrom for SdkCommitPreparedSendRequest { ++ type Error = Error; ++ ++ fn try_from(j: JsonCommitPreparedSendRequest) -> Result { ++ Ok(SdkCommitPreparedSendRequest { ++ plan_id: parse_txid(&j.plan_id)?, ++ }) ++ } ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub(crate) struct JsonCancelBtcSendPlanRequest { ++ pub plan_id: String, ++} ++ ++impl TryFrom for SdkCancelBtcSendPlanRequest { ++ type Error = Error; ++ ++ fn try_from(j: JsonCancelBtcSendPlanRequest) -> Result { ++ Ok(SdkCancelBtcSendPlanRequest { ++ plan_id: parse_txid(&j.plan_id)?, ++ }) ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonCancelBtcSendPlanResponse { ++ pub cancelled: bool, ++} ++ ++impl From for JsonCancelBtcSendPlanResponse { ++ fn from(r: SdkCancelBtcSendPlanResponse) -> Self { ++ JsonCancelBtcSendPlanResponse { ++ cancelled: r.cancelled, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonPendingVanillaTransaction { ++ pub txid: String, ++ pub operation_type: String, ++} ++ ++impl From for JsonPendingVanillaTransaction { ++ fn from(transaction: SdkPendingVanillaTransaction) -> Self { ++ JsonPendingVanillaTransaction { ++ txid: fmt_txid(&transaction.txid), ++ operation_type: transaction.operation_type, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonPendingRgbSendPlan { ++ pub plan_id: String, ++ pub batch_transfer_idx: i32, ++} ++ ++impl From for JsonPendingRgbSendPlan { ++ fn from(plan: SdkPendingRgbSendPlan) -> Self { ++ JsonPendingRgbSendPlan { ++ plan_id: fmt_txid(&plan.plan_id), ++ batch_transfer_idx: plan.batch_transfer_idx, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonAddressReceipt { ++ pub txid: String, ++ pub amount_sat: String, ++ pub confirmations: u32, ++ pub block_height: Option, ++} ++ ++impl From for JsonAddressReceipt { ++ fn from(receipt: SdkAddressReceipt) -> Self { ++ JsonAddressReceipt { ++ txid: fmt_txid(&receipt.txid), ++ amount_sat: receipt.amount_sat.to_string(), ++ confirmations: receipt.confirmations, ++ block_height: receipt.block_height, ++ } ++ } ++} ++ + #[derive(Debug, Deserialize)] + pub(crate) struct JsonCreateUtxosRequest { + pub up_to: bool, +@@ -1889,6 +2950,8 @@ pub(crate) struct JsonTransfer { + pub status: String, + pub requested_assignment: Option, + pub assignments: Vec, ++ pub requested_assignment_structured: Option, ++ pub assignments_structured: Vec, + pub kind: String, + pub txid: Option, + pub recipient_id: Option, +@@ -1908,6 +2971,12 @@ impl From for JsonTransfer { + status: t.status, + requested_assignment: t.requested_assignment, + assignments: t.assignments, ++ requested_assignment_structured: t.requested_assignment_structured.map(Into::into), ++ assignments_structured: t ++ .assignments_structured ++ .into_iter() ++ .map(Into::into) ++ .collect(), + kind: t.kind, + txid: t.txid.as_ref().map(fmt_txid), + recipient_id: t.recipient_id, +@@ -1958,6 +3027,7 @@ impl From for JsonUtxo { + pub(crate) struct JsonUnspent { + pub utxo: JsonUtxo, + pub rgb_allocations: Vec, ++ pub pending_blinded: u32, + } + + impl From for JsonUnspent { +@@ -1965,6 +3035,7 @@ impl From for JsonUnspent { + JsonUnspent { + utxo: u.utxo.into(), + rgb_allocations: u.rgb_allocations.into_iter().map(Into::into).collect(), ++ pending_blinded: u.pending_blinded, + } + } + } +diff --git a/bindings/c-ffi/src/lib.rs b/bindings/c-ffi/src/lib.rs +index dfc595a..08208e9 100644 +--- a/bindings/c-ffi/src/lib.rs ++++ b/bindings/c-ffi/src/lib.rs +@@ -9,6 +9,7 @@ + + mod api; + mod json_types; ++mod native_operations; + mod utils; + + use rgb_lightning_node::{NativeExternalSigner, SdkNode}; +@@ -70,7 +71,10 @@ pub extern "C" fn free_sdk_node(obj: COpaqueStruct) { + return; + } + unsafe { +- let _ = Box::from_raw(obj.ptr as *mut SdkNode); ++ let node = Box::from_raw(obj.ptr as *mut SdkNode); ++ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { ++ node.shutdown(); ++ })); + } + } + +@@ -152,6 +156,20 @@ pub extern "C" fn rln_sdk_node_vss_backup(node: &COpaqueStruct) -> CResultString + ffi_call!("rln_sdk_node_vss_backup", api::sdk_node_vss_backup(node)) + } + ++/// Permanently delete every object in the authenticated VSS store. ++/// Request JSON: `{"password":"..."}`. The node must be locked. ++/// Returns `{"deleted_keys": u64}` after a verified empty re-list. ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sdk_node_vss_delete_all( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_sdk_node_vss_delete_all", ++ api::sdk_node_vss_delete_all(node, request_json) ++ ) ++} ++ + /// APay receiver-side registration with an LSP. Argument is the LSP's + /// node_id as a hex string (compressed secp256k1). Returns JSON of + /// `AsyncOrderNewResponse` (request_id, host_node_id, protocol_version, +@@ -342,6 +360,69 @@ pub extern "C" fn rln_send_rgb(node: &COpaqueStruct, request_json: *const c_char + ffi_call!("rln_send_rgb", api::send_rgb(node, request_json)) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_import_rgb_transfer_consignment( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_import_rgb_transfer_consignment", ++ api::import_rgb_transfer_consignment(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_prepare_rgb_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_prepare_rgb_send", ++ api::prepare_rgb_send(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_commit_prepared_rgb_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_commit_prepared_rgb_send", ++ api::commit_prepared_rgb_send(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_cancel_rgb_send_plan( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_cancel_rgb_send_plan", ++ api::cancel_rgb_send_plan(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_list_pending_rgb_send_plans(node: &COpaqueStruct) -> CResultString { ++ ffi_call!( ++ "rln_list_pending_rgb_send_plans", ++ api::list_pending_rgb_send_plans(node) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_import_rgb_contract( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_import_rgb_contract", ++ api::import_rgb_contract(node, request_json) ++ ) ++} ++ + #[unsafe(no_mangle)] + pub extern "C" fn rln_refresh_transfers( + node: &COpaqueStruct, +@@ -569,6 +650,58 @@ pub extern "C" fn rln_send_btc(node: &COpaqueStruct, request_json: *const c_char + ffi_call!("rln_send_btc", api::send_btc(node, request_json)) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_prepare_btc_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_prepare_btc_send", ++ api::prepare_btc_send(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_commit_prepared_btc_send( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_commit_prepared_btc_send", ++ api::commit_prepared_btc_send(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_cancel_btc_send_plan( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_cancel_btc_send_plan", ++ api::cancel_btc_send_plan(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_list_pending_vanilla_transactions(node: &COpaqueStruct) -> CResultString { ++ ffi_call!( ++ "rln_list_pending_vanilla_transactions", ++ api::list_pending_vanilla_transactions(node) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_list_address_receipts( ++ node: &COpaqueStruct, ++ address: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_list_address_receipts", ++ api::list_address_receipts(node, address) ++ ) ++} ++ + #[unsafe(no_mangle)] + pub extern "C" fn rln_create_utxos( + node: &COpaqueStruct, +@@ -577,6 +710,39 @@ pub extern "C" fn rln_create_utxos( + ffi_call!("rln_create_utxos", api::create_utxos(node, request_json)) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_prepare_create_utxos( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_prepare_create_utxos", ++ api::prepare_create_utxos(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_commit_prepared_create_utxos( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_commit_prepared_create_utxos", ++ api::commit_prepared_create_utxos(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_cancel_create_utxos_plan( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_cancel_create_utxos_plan", ++ api::cancel_create_utxos_plan(node, request_json) ++ ) ++} ++ + #[unsafe(no_mangle)] + pub extern "C" fn rln_list_transactions( + node: &COpaqueStruct, +@@ -594,6 +760,25 @@ pub extern "C" fn rln_sync(node: &COpaqueStruct) -> CResultString { + ffi_call!("rln_sync", api::sync(node)) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sync_wallet( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!("rln_sync_wallet", api::sync_wallet(node, request_json)) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_wallet_snapshot( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_wallet_snapshot", ++ api::wallet_snapshot(node, request_json) ++ ) ++} ++ + // --------------------------------------------------------------------------- + // Swaps / onion + // --------------------------------------------------------------------------- +@@ -678,15 +863,17 @@ pub extern "C" fn rln_sdk_shutdown() -> CResultString { + // External-signer surface + // --------------------------------------------------------------------------- + +-/// Drop a `NativeExternalSigner` handle. Safe to call immediately after +-/// attach / init / unlock succeeds: RLN holds its own `Arc` clone. ++/// Shut down and drop a `NativeExternalSigner` handle. Call only after node ++/// shutdown has completed; shutdown invalidates every outstanding `Arc` clone ++/// so the seed-bearing backend and persistent VLS store are released. + #[unsafe(no_mangle)] + pub extern "C" fn free_native_external_signer(obj: COpaqueStruct) { + if obj.ptr.is_null() { + return; + } + unsafe { +- let _ = Box::from_raw(obj.ptr as *mut Arc); ++ let signer = Box::from_raw(obj.ptr as *mut Arc); ++ signer.shutdown(); + } + } + +@@ -713,6 +900,24 @@ pub extern "C" fn rln_native_external_signer_new( + ) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_native_external_signer_new_with_storage( ++ seed_hex: *const c_char, ++ network: *const c_char, ++ permissive_policy: bool, ++ storage_dir_path: *const c_char, ++) -> CResult { ++ ffi_call!( ++ "rln_native_external_signer_new_with_storage", ++ api::native_external_signer_new_with_storage( ++ seed_hex, ++ network, ++ permissive_policy, ++ storage_dir_path ++ ) ++ ) ++} ++ + #[unsafe(no_mangle)] + pub extern "C" fn rln_native_external_signer_bootstrap(signer: &COpaqueStruct) -> CResultString { + ffi_call!( +@@ -755,6 +960,51 @@ pub extern "C" fn rln_sdk_node_unlock_with_native_external_signer( + ) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sdk_node_start_unlock_with_native_external_signer( ++ node: &COpaqueStruct, ++ signer: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_sdk_node_start_unlock_with_native_external_signer", ++ api::sdk_node_start_unlock_with_native_external_signer(node, signer, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sdk_node_native_operation_status( ++ node: &COpaqueStruct, ++ operation_id: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_sdk_node_native_operation_status", ++ api::sdk_node_native_operation_status(node, operation_id) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sdk_node_adopt_native_operation( ++ node: &COpaqueStruct, ++ operation_id: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_sdk_node_adopt_native_operation", ++ api::sdk_node_adopt_native_operation(node, operation_id) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sdk_node_cancel_native_operation( ++ node: &COpaqueStruct, ++ operation_id: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_sdk_node_cancel_native_operation", ++ api::sdk_node_cancel_native_operation(node, operation_id) ++ ) ++} ++ + #[unsafe(no_mangle)] + pub extern "C" fn rln_sdk_node_init_with_external_signer( + node: &COpaqueStruct, +diff --git a/bindings/c-ffi/src/native_operations.rs b/bindings/c-ffi/src/native_operations.rs +new file mode 100644 +index 0000000..856f7d3 +--- /dev/null ++++ b/bindings/c-ffi/src/native_operations.rs +@@ -0,0 +1,378 @@ ++use std::collections::HashMap; ++use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; ++use std::thread; ++use std::time::{SystemTime, UNIX_EPOCH}; ++ ++use rgb_lightning_node::{NativeExternalSigner, SdkNode}; ++use serde::Serialize; ++use uuid::Uuid; ++ ++use crate::json_types::JsonSdkExternalUnlockRequest; ++use crate::utils::{format_error_for_ffi, Error}; ++ ++const CONTRACT_VERSION: u8 = 1; ++const OPERATION_KIND: &str = "unlock_with_native_external_signer"; ++const MAX_TERMINAL_OPERATIONS: usize = 128; ++ ++#[derive(Clone, Copy, Debug, Eq, PartialEq)] ++enum OperationState { ++ Queued, ++ Running, ++ CancelRequested, ++ Succeeded, ++ Failed, ++ Cancelled, ++} ++ ++impl OperationState { ++ fn as_str(self) -> &'static str { ++ match self { ++ Self::Queued => "queued", ++ Self::Running => "running", ++ Self::CancelRequested => "cancel_requested", ++ Self::Succeeded => "succeeded", ++ Self::Failed => "failed", ++ Self::Cancelled => "cancelled", ++ } ++ } ++ ++ fn is_terminal(self) -> bool { ++ matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) ++ } ++} ++ ++#[derive(Debug)] ++struct NativeOperation { ++ operation_id: String, ++ node_identity: usize, ++ state: OperationState, ++ created_at_ms: u64, ++ started_at_ms: Option, ++ finished_at_ms: Option, ++ updated_at_ms: u64, ++ cancellation_requested: bool, ++ adoption_count: u32, ++ error: Option, ++} ++ ++#[derive(Debug, Serialize)] ++struct NativeOperationStatus { ++ contract_version: u8, ++ operation_id: String, ++ kind: &'static str, ++ state: &'static str, ++ created_at_ms: String, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ started_at_ms: Option, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ finished_at_ms: Option, ++ updated_at_ms: String, ++ cancellation_requested: bool, ++ can_cancel_immediately: bool, ++ adoption_count: u32, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ error: Option, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ adopted_existing: Option, ++} ++ ++impl NativeOperation { ++ fn status(&self, adopted_existing: Option) -> NativeOperationStatus { ++ NativeOperationStatus { ++ contract_version: CONTRACT_VERSION, ++ operation_id: self.operation_id.clone(), ++ kind: OPERATION_KIND, ++ state: self.state.as_str(), ++ created_at_ms: self.created_at_ms.to_string(), ++ started_at_ms: self.started_at_ms.map(|value| value.to_string()), ++ finished_at_ms: self.finished_at_ms.map(|value| value.to_string()), ++ updated_at_ms: self.updated_at_ms.to_string(), ++ cancellation_requested: self.cancellation_requested, ++ can_cancel_immediately: self.state == OperationState::Queued, ++ adoption_count: self.adoption_count, ++ error: self.error.clone(), ++ adopted_existing, ++ } ++ } ++} ++ ++type Registry = HashMap; ++ ++fn registry() -> &'static Mutex { ++ static REGISTRY: OnceLock> = OnceLock::new(); ++ REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) ++} ++ ++fn lock_registry() -> Result, Error> { ++ registry() ++ .lock() ++ .map_err(|_| Error::StringParse("native operation registry is poisoned".to_string())) ++} ++ ++fn now_ms() -> Result { ++ let milliseconds = SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .map_err(|_| Error::StringParse("system clock is before Unix epoch".to_string()))? ++ .as_millis(); ++ u64::try_from(milliseconds) ++ .map_err(|_| Error::StringParse("system clock exceeds u64 milliseconds".to_string())) ++} ++ ++fn operation_for_node<'a>( ++ registry: &'a mut Registry, ++ node_identity: usize, ++ operation_id: &str, ++) -> Result<&'a mut NativeOperation, Error> { ++ let operation = registry ++ .get_mut(operation_id) ++ .ok_or_else(|| Error::StringParse("native operation was not found".to_string()))?; ++ if operation.node_identity != node_identity { ++ return Err(Error::StringParse( ++ "native operation belongs to a different node".to_string(), ++ )); ++ } ++ Ok(operation) ++} ++ ++fn prune_terminal_operations(registry: &mut Registry) { ++ let mut terminal = registry ++ .values() ++ .filter(|operation| operation.state.is_terminal()) ++ .map(|operation| { ++ ( ++ operation.finished_at_ms.unwrap_or(operation.updated_at_ms), ++ operation.operation_id.clone(), ++ ) ++ }) ++ .collect::>(); ++ if terminal.len() <= MAX_TERMINAL_OPERATIONS { ++ return; ++ } ++ terminal.sort_unstable(); ++ let excess = terminal.len() - MAX_TERMINAL_OPERATIONS; ++ for (_, operation_id) in terminal.into_iter().take(excess) { ++ registry.remove(&operation_id); ++ } ++} ++ ++fn serialize(status: NativeOperationStatus) -> Result { ++ Ok(serde_json::to_string(&status)?) ++} ++ ++fn finish_operation(operation_id: &str, result: Result<(), Error>) { ++ let Ok(mut registry) = lock_registry() else { ++ return; ++ }; ++ let Some(operation) = registry.get_mut(operation_id) else { ++ return; ++ }; ++ let finished_at_ms = now_ms().unwrap_or(operation.updated_at_ms); ++ operation.finished_at_ms = Some(finished_at_ms); ++ operation.updated_at_ms = finished_at_ms; ++ match result { ++ Ok(()) => { ++ operation.state = OperationState::Succeeded; ++ operation.error = None; ++ } ++ Err(error) => { ++ operation.state = OperationState::Failed; ++ operation.error = Some(format_error_for_ffi(&error)); ++ } ++ } ++} ++ ++fn run_unlock( ++ operation_id: String, ++ node: SdkNode, ++ signer: Arc, ++ request: JsonSdkExternalUnlockRequest, ++) { ++ { ++ let Ok(mut registry) = lock_registry() else { ++ return; ++ }; ++ let Some(operation) = registry.get_mut(&operation_id) else { ++ return; ++ }; ++ if operation.state == OperationState::Cancelled { ++ return; ++ } ++ let started_at_ms = now_ms().unwrap_or(operation.updated_at_ms); ++ operation.state = OperationState::Running; ++ operation.started_at_ms = Some(started_at_ms); ++ operation.updated_at_ms = started_at_ms; ++ } ++ ++ let result = node ++ .unlock_with_native_external_signer( ++ signer, ++ request.bitcoind_rpc_username, ++ request.bitcoind_rpc_password, ++ request.bitcoind_rpc_host, ++ request.bitcoind_rpc_port, ++ request.indexer_url, ++ request.proxy_endpoint, ++ request.announce_addresses, ++ request.announce_alias, ++ ) ++ .map_err(Error::from); ++ finish_operation(&operation_id, result); ++} ++ ++pub(crate) fn start_unlock( ++ node_identity: usize, ++ node: SdkNode, ++ signer: Arc, ++ request: JsonSdkExternalUnlockRequest, ++) -> Result { ++ let created_at_ms = now_ms()?; ++ let (operation_id, initial_status) = { ++ let mut registry = lock_registry()?; ++ prune_terminal_operations(&mut registry); ++ if let Some(existing) = registry.values_mut().find(|operation| { ++ operation.node_identity == node_identity && !operation.state.is_terminal() ++ }) { ++ existing.adoption_count = existing.adoption_count.saturating_add(1); ++ existing.updated_at_ms = created_at_ms.max(existing.updated_at_ms); ++ return serialize(existing.status(Some(true))); ++ } ++ ++ let operation_id = Uuid::new_v4().to_string(); ++ let operation = NativeOperation { ++ operation_id: operation_id.clone(), ++ node_identity, ++ state: OperationState::Queued, ++ created_at_ms, ++ started_at_ms: None, ++ finished_at_ms: None, ++ updated_at_ms: created_at_ms, ++ cancellation_requested: false, ++ adoption_count: 0, ++ error: None, ++ }; ++ let status = operation.status(Some(false)); ++ registry.insert(operation_id.clone(), operation); ++ (operation_id, status) ++ }; ++ ++ let worker_operation_id = operation_id.clone(); ++ if let Err(error) = thread::Builder::new() ++ .name("rln-native-unlock".to_string()) ++ .spawn(move || run_unlock(worker_operation_id, node, signer, request)) ++ { ++ finish_operation( ++ &operation_id, ++ Err(Error::StringParse(format!( ++ "failed to start native operation worker: {error}" ++ ))), ++ ); ++ let mut registry = lock_registry()?; ++ return serialize( ++ operation_for_node(&mut registry, node_identity, &operation_id)?.status(Some(false)), ++ ); ++ } ++ ++ serialize(initial_status) ++} ++ ++pub(crate) fn status(node_identity: usize, operation_id: &str) -> Result { ++ let mut registry = lock_registry()?; ++ serialize(operation_for_node(&mut registry, node_identity, operation_id)?.status(None)) ++} ++ ++pub(crate) fn adopt(node_identity: usize, operation_id: &str) -> Result { ++ let updated_at_ms = now_ms()?; ++ let mut registry = lock_registry()?; ++ let operation = operation_for_node(&mut registry, node_identity, operation_id)?; ++ operation.adoption_count = operation.adoption_count.saturating_add(1); ++ operation.updated_at_ms = updated_at_ms.max(operation.updated_at_ms); ++ serialize(operation.status(None)) ++} ++ ++pub(crate) fn cancel(node_identity: usize, operation_id: &str) -> Result { ++ let updated_at_ms = now_ms()?; ++ let mut registry = lock_registry()?; ++ let operation = operation_for_node(&mut registry, node_identity, operation_id)?; ++ match operation.state { ++ OperationState::Queued => { ++ operation.state = OperationState::Cancelled; ++ operation.cancellation_requested = true; ++ operation.finished_at_ms = Some(updated_at_ms); ++ operation.updated_at_ms = updated_at_ms; ++ } ++ OperationState::Running => { ++ operation.state = OperationState::CancelRequested; ++ operation.cancellation_requested = true; ++ operation.updated_at_ms = updated_at_ms; ++ } ++ OperationState::CancelRequested ++ | OperationState::Succeeded ++ | OperationState::Failed ++ | OperationState::Cancelled => {} ++ } ++ serialize(operation.status(None)) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn operation( ++ id: &str, ++ node_identity: usize, ++ state: OperationState, ++ finished: u64, ++ ) -> NativeOperation { ++ NativeOperation { ++ operation_id: id.to_string(), ++ node_identity, ++ state, ++ created_at_ms: 1, ++ started_at_ms: Some(2), ++ finished_at_ms: state.is_terminal().then_some(finished), ++ updated_at_ms: finished, ++ cancellation_requested: state == OperationState::Cancelled, ++ adoption_count: 0, ++ error: None, ++ } ++ } ++ ++ #[test] ++ fn operation_lookup_enforces_node_ownership() { ++ let mut registry = HashMap::from([( ++ "operation".to_string(), ++ operation("operation", 7, OperationState::Running, 2), ++ )]); ++ assert!(operation_for_node(&mut registry, 8, "operation").is_err()); ++ assert!(operation_for_node(&mut registry, 7, "missing").is_err()); ++ assert!(operation_for_node(&mut registry, 7, "operation").is_ok()); ++ } ++ ++ #[test] ++ fn terminal_retention_removes_only_the_oldest_terminal_records() { ++ let mut registry = HashMap::new(); ++ for index in 0..=MAX_TERMINAL_OPERATIONS { ++ let id = format!("terminal-{index:03}"); ++ registry.insert( ++ id.clone(), ++ operation(&id, 7, OperationState::Succeeded, index as u64), ++ ); ++ } ++ registry.insert( ++ "running".to_string(), ++ operation("running", 7, OperationState::Running, 999), ++ ); ++ ++ prune_terminal_operations(&mut registry); ++ ++ assert_eq!( ++ registry ++ .values() ++ .filter(|value| value.state.is_terminal()) ++ .count(), ++ MAX_TERMINAL_OPERATIONS ++ ); ++ assert!(!registry.contains_key("terminal-000")); ++ assert!(registry.contains_key("running")); ++ } ++} +diff --git a/bindings/c-ffi/src/utils.rs b/bindings/c-ffi/src/utils.rs +index 344f6d9..3b4ed42 100644 +--- a/bindings/c-ffi/src/utils.rs ++++ b/bindings/c-ffi/src/utils.rs +@@ -88,7 +88,7 @@ impl CReturnType for Arc {} + + // Always drain the per-thread APIError detail slot so it stays one-shot — + // stale residue must never leak into an unrelated later error. +-fn format_error_for_ffi(e: &Error) -> String { ++pub(crate) fn format_error_for_ffi(e: &Error) -> String { + let stashed = rgb_lightning_node::take_last_api_error_detail(); + match e { + Error::Rln(inner) => { +@@ -214,6 +214,13 @@ pub(crate) fn require_handle(node: &COpaqueStruct) -> Result<&mut SdkNode, Error + SdkNode::from_opaque(node) + } + ++pub(crate) fn opaque_identity(value: &COpaqueStruct) -> Result { ++ if value.ptr.is_null() { ++ return Err(Error::TypeMismatch); ++ } ++ Ok(value.ptr as usize) ++} ++ + /// Catch panics at the FFI boundary so unwinding doesn't cross `extern "C"` + /// and trigger `panic_cannot_unwind`. + /// +diff --git a/bindings/rgb_lightning_node.udl b/bindings/rgb_lightning_node.udl +index 90e7eea..56db1ca 100644 +--- a/bindings/rgb_lightning_node.udl ++++ b/bindings/rgb_lightning_node.udl +@@ -69,10 +69,22 @@ interface SdkNode { + [Throws=RlnError] + SdkRgbInvoiceResponse rgbinvoice(SdkRgbInvoiceRequest request); + [Throws=RlnError] ++ ImportRgbContractResponse importrgbcontract(ImportRgbContractRequest request); ++ [Throws=RlnError] + SdkKeysendResponse keysend(SdkKeysendRequest request); + [Throws=RlnError] + SdkSendBtcResponse sendbtc(SdkSendBtcRequest request); + [Throws=RlnError] ++ SdkPreparedSendResponse prepare_btc_send(SdkSendBtcRequest request); ++ [Throws=RlnError] ++ SdkSendBtcResponse commit_prepared_btc_send(SdkCommitPreparedSendRequest request); ++ [Throws=RlnError] ++ SdkCancelBtcSendPlanResponse cancel_btc_send_plan(SdkCancelBtcSendPlanRequest request); ++ [Throws=RlnError] ++ sequence list_pending_vanilla_transactions(); ++ [Throws=RlnError] ++ sequence list_address_receipts(string address); ++ [Throws=RlnError] + SdkMakerInitResponse makerinit(SdkMakerInitRequest request); + [Throws=RlnError] + void makerexecute(SdkMakerExecuteRequest request); +@@ -101,6 +113,8 @@ interface SdkNode { + [Throws=RlnError] + AssetMetadataInfo asset_metadata(ContractId asset_id); + [Throws=RlnError] ++ ImportRgbTransferConsignmentResponse importrgbtransferconsignment(ImportRgbTransferConsignmentRequest request); ++ [Throws=RlnError] + AssetMediaResponse get_asset_media(string digest); + [Throws=RlnError] + ListAssetsResponse list_assets(sequence filter_asset_schemas); +@@ -125,6 +139,14 @@ interface SdkNode { + [Throws=RlnError] + SendRgbResponse send_rgb(SendRgbRequest request); + [Throws=RlnError] ++ SdkPreparedRgbSendResponse prepare_rgb_send(SendRgbRequest request); ++ [Throws=RlnError] ++ SendRgbResponse commit_prepared_rgb_send(SdkCommitPreparedSendRequest request); ++ [Throws=RlnError] ++ SdkCancelBtcSendPlanResponse cancel_rgb_send_plan(SdkCancelBtcSendPlanRequest request); ++ [Throws=RlnError] ++ sequence list_pending_rgb_send_plans(); ++ [Throws=RlnError] + AsyncOrderNewResponse apay_new(string host_node_id); + [Throws=RlnError] + AsyncOrderNewResponse apay_new_with_address(string host_node_id, string username, string domain); +@@ -178,6 +200,7 @@ dictionary NodeInfo { + dictionary NetworkInfo { + string network; + u32 height; ++ string block_hash; + }; + + dictionary AddressInfo { +@@ -215,15 +238,19 @@ dictionary Payment { + u64? amt_msat; + u64? asset_amount; + ContractId? asset_id; ++ u64? carrier_msat; + PaymentHash payment_hash; + PaymentType payment_type; + HtlcStatus status; + u64 created_at; + u64 updated_at; ++ u64? expires_at; + PublicKey payee_pubkey; + string? preimage; + string? description; + string? description_hash; ++ u64? fee_paid_msat; ++ string? failure_code; + }; + + enum PaymentType { +@@ -352,6 +379,18 @@ dictionary AssetMetadataInfo { + string? linked_to_asset_id; + }; + ++dictionary ImportRgbTransferConsignmentRequest { ++ string consignment_base64; ++ string offchain_txid; ++ ContractId? expected_asset_id; ++}; ++ ++dictionary ImportRgbTransferConsignmentResponse { ++ ContractId asset_id; ++ boolean already_imported; ++ AssetMetadataInfo metadata; ++}; ++ + dictionary AssetMediaResponse { + string bytes_hex; + }; +@@ -508,6 +547,11 @@ dictionary TransferTransportEndpoint { + boolean used; + }; + ++dictionary RgbAssignmentInfo { ++ string kind; ++ u64? amount; ++}; ++ + dictionary Transfer { + i32 idx; + i64 created_at; +@@ -515,6 +559,8 @@ dictionary Transfer { + string status; + string? requested_assignment; + sequence assignments; ++ RgbAssignmentInfo? requested_assignment_structured; ++ sequence assignments_structured; + string kind; + Txid? txid; + string? recipient_id; +@@ -747,6 +793,52 @@ dictionary SdkSendBtcResponse { + Txid txid; + }; + ++dictionary SdkPreparedSendResponse { ++ Txid plan_id; ++ u64 fee_sat; ++ u64 total_input_sat; ++ u64 total_output_sat; ++ u64 size_vbytes; ++}; ++ ++dictionary SdkPreparedRgbSendResponse { ++ Txid plan_id; ++ i32 batch_transfer_idx; ++ u64 fee_sat; ++ u64 total_input_sat; ++ u64 total_output_sat; ++ u64 size_vbytes; ++}; ++ ++dictionary SdkCommitPreparedSendRequest { ++ Txid plan_id; ++}; ++ ++dictionary SdkCancelBtcSendPlanRequest { ++ Txid plan_id; ++}; ++ ++dictionary SdkCancelBtcSendPlanResponse { ++ boolean cancelled; ++}; ++ ++dictionary SdkPendingVanillaTransaction { ++ Txid txid; ++ string operation_type; ++}; ++ ++dictionary SdkPendingRgbSendPlan { ++ Txid plan_id; ++ i32 batch_transfer_idx; ++}; ++ ++dictionary SdkAddressReceipt { ++ Txid txid; ++ u64 amount_sat; ++ u32 confirmations; ++ u32? block_height; ++}; ++ + dictionary SdkMakerInitRequest { + u64 qty_from; + u64 qty_to; +@@ -801,11 +893,23 @@ dictionary SdkRgbInvoiceResponse { + i32 batch_transfer_idx; + }; + ++dictionary ImportRgbContractRequest { ++ string contract_base64; ++ ContractId expected_asset_id; ++}; ++ ++dictionary ImportRgbContractResponse { ++ ContractId asset_id; ++ boolean already_imported; ++ AssetMetadataInfo metadata; ++}; ++ + dictionary SdkSendPaymentRequest { + string invoice; + u64? amt_msat; + ContractId? asset_id; + u64? asset_amount; ++ u64? max_total_routing_fee_msat; + }; + + dictionary SdkSendPaymentResponse { +@@ -813,6 +917,7 @@ dictionary SdkSendPaymentResponse { + PaymentHash? payment_hash; + string? payment_secret; + HtlcStatus status; ++ string? failure_code; + }; + + dictionary SendRgbRequest { +diff --git a/rust-lightning/lightning-invoice/Cargo.toml b/rust-lightning/lightning-invoice/Cargo.toml +index 9a5239f..16ae185 100644 +--- a/rust-lightning/lightning-invoice/Cargo.toml ++++ b/rust-lightning/lightning-invoice/Cargo.toml +@@ -24,7 +24,7 @@ serde = { version = "1.0", optional = true, default-features = false, features = + bitcoin = { version = "0.32.2", default-features = false, features = ["secp-recovery"] } + + # RGB and related +-rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.32", features = [ ++rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", rev = "95332c41fd715939ac6e078ad859d474b1f6fa9b", features = [ + "electrum", + "esplora", + ] } +diff --git a/rust-lightning/lightning/Cargo.toml b/rust-lightning/lightning/Cargo.toml +index 41878b0..089e606 100644 +--- a/rust-lightning/lightning/Cargo.toml ++++ b/rust-lightning/lightning/Cargo.toml +@@ -56,7 +56,7 @@ amplify = "4.8" + bincode = "1.3" + rgb-strict-encoding = "1.0.1" + futures = "0.3" +-rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", tag = "v0.3.0-beta.32", features = [ ++rgb-lib = { git = "https://github.com/UTEXO-Protocol/rgb-lib.git", rev = "95332c41fd715939ac6e078ad859d474b1f6fa9b", features = [ + "electrum", + "esplora", + ] } +diff --git a/rust-lightning/lightning/src/ln/channel.rs b/rust-lightning/lightning/src/ln/channel.rs +index 7afa028..e411966 100644 +--- a/rust-lightning/lightning/src/ln/channel.rs ++++ b/rust-lightning/lightning/src/ln/channel.rs +@@ -5542,7 +5542,7 @@ where + + let value_to_self_msat = (funding.value_to_self_msat + value_to_self_claimed_msat).checked_sub(value_to_remote_claimed_msat).unwrap(); + +- let (tx, stats) = SpecTxBuilder {}.build_commitment_transaction( ++ let (tx, _stats) = SpecTxBuilder {}.build_commitment_transaction( + local, + commitment_number, + per_commitment_point, +@@ -5558,7 +5558,7 @@ where + { + let PredictedNextFee { predicted_feerate, predicted_nondust_htlc_count, predicted_fee_sat } = if local { *funding.next_local_fee.lock().unwrap() } else { *funding.next_remote_fee.lock().unwrap() }; + if predicted_feerate == tx.negotiated_feerate_per_kw() && predicted_nondust_htlc_count == tx.nondust_htlcs().len() { +- assert_eq!(predicted_fee_sat, stats.commit_tx_fee_sat); ++ assert_eq!(predicted_fee_sat, _stats.commit_tx_fee_sat); + } + } + #[cfg(debug_assertions)] +@@ -5570,10 +5570,10 @@ where + } else { + funding.counterparty_max_commitment_tx_output.lock().unwrap() + }; +- debug_assert!(broadcaster_max_commitment_tx_output.0 <= stats.local_balance_before_fee_msat || stats.local_balance_before_fee_msat / 1000 >= funding.counterparty_selected_channel_reserve_satoshis.unwrap()); +- broadcaster_max_commitment_tx_output.0 = cmp::max(broadcaster_max_commitment_tx_output.0, stats.local_balance_before_fee_msat); +- debug_assert!(broadcaster_max_commitment_tx_output.1 <= stats.remote_balance_before_fee_msat || stats.remote_balance_before_fee_msat / 1000 >= funding.holder_selected_channel_reserve_satoshis); +- broadcaster_max_commitment_tx_output.1 = cmp::max(broadcaster_max_commitment_tx_output.1, stats.remote_balance_before_fee_msat); ++ debug_assert!(broadcaster_max_commitment_tx_output.0 <= _stats.local_balance_before_fee_msat || _stats.local_balance_before_fee_msat / 1000 >= funding.counterparty_selected_channel_reserve_satoshis.unwrap()); ++ broadcaster_max_commitment_tx_output.0 = cmp::max(broadcaster_max_commitment_tx_output.0, _stats.local_balance_before_fee_msat); ++ debug_assert!(broadcaster_max_commitment_tx_output.1 <= _stats.remote_balance_before_fee_msat || _stats.remote_balance_before_fee_msat / 1000 >= funding.holder_selected_channel_reserve_satoshis); ++ broadcaster_max_commitment_tx_output.1 = cmp::max(broadcaster_max_commitment_tx_output.1, _stats.remote_balance_before_fee_msat); + } + + +diff --git a/src/asset_link.rs b/src/asset_link.rs +index da8ef68..77de52c 100644 +--- a/src/asset_link.rs ++++ b/src/asset_link.rs +@@ -802,6 +802,11 @@ pub(crate) async fn send_linked_asset_payment( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: Some(contract_id.to_string()), ++ asset_amount: Some(asset_amount), ++ carrier_msat: Some(amt_msat), + }, + )?; + write_rgb_payment_info_file( +diff --git a/src/error.rs b/src/error.rs +index f991558..9b1567f 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -200,6 +200,12 @@ pub enum APIError { + #[error("Invalid invoice: {0}")] + InvalidInvoice(String), + ++ #[error("Invalid RGB consignment: {0}")] ++ InvalidRgbConsignment(String), ++ ++ #[error("Invalid RGB contract: {0}")] ++ InvalidRgbContract(String), ++ + #[error("Invalid media digest")] + InvalidMediaDigest, + +@@ -564,6 +570,8 @@ impl IntoResponse for APIError { + | APIError::InvalidExpiration + | APIError::InvalidFeeRate(_) + | APIError::InvalidInvoice(_) ++ | APIError::InvalidRgbContract(_) ++ | APIError::InvalidRgbConsignment(_) + | APIError::InvalidMediaDigest + | APIError::InvalidMnemonic(_) + | APIError::InvalidName(_) +@@ -692,6 +700,9 @@ impl IntoResponse for APIError { + /// The error variants returned by the app + #[derive(Debug, thiserror::Error)] + pub enum AppError { ++ #[error("A node instance is already active for storage directory: {0}")] ++ NodeInstanceAlreadyActive(String), ++ + #[error("The provided authentication args are invalid")] + InvalidAuthenticationArgs, + +diff --git a/src/indexer.rs b/src/indexer.rs +index a760299..6cdcb68 100644 +--- a/src/indexer.rs ++++ b/src/indexer.rs +@@ -1,4 +1,4 @@ +-use std::collections::{BTreeMap, HashMap}; ++use std::collections::{BTreeMap, HashMap, HashSet}; + use std::io; + use std::str::FromStr; + use std::sync::atomic::{AtomicU32, Ordering}; +@@ -8,7 +8,7 @@ use std::time::Duration; + use bitcoin::blockdata::transaction::Transaction; + use bitcoin::consensus::encode; + use bitcoin::constants::ChainHash; +-use bitcoin::{Network, TxOut, Txid}; ++use bitcoin::{Address, Network, TxOut, Txid}; + use electrum_client::{Client as ElectrumClient, ElectrumApi, Param}; + use esplora_client::blocking::BlockingClient as EsploraBlockingClient; + use esplora_client::Builder as EsploraBuilder; +@@ -22,6 +22,258 @@ use crate::disk::FilesystemLogger; + use crate::fee_mock::mock_fee; + + pub(crate) const MIN_FEERATE: u32 = 253; ++const MAX_ADDRESS_HISTORY_TRANSACTIONS: usize = 1_000; ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub(crate) struct AddressReceipt { ++ pub(crate) txid: Txid, ++ pub(crate) amount_sat: u64, ++ pub(crate) confirmations: u32, ++ pub(crate) block_height: Option, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq)] ++pub(crate) struct ChainCheckpoint { ++ pub(crate) height: u32, ++ pub(crate) block_hash: bitcoin::BlockHash, ++} ++ ++pub(crate) enum AddressIndexerClient { ++ Esplora(Arc), ++ Electrum(Arc), ++} ++ ++impl AddressIndexerClient { ++ pub(crate) fn connect_esplora(server_url: &str, timeout_secs: u64) -> io::Result { ++ let client = Arc::new( ++ EsploraBuilder::new(server_url) ++ .timeout(timeout_secs) ++ .build_blocking(), ++ ); ++ client ++ .get_tip_hash() ++ .map_err(|e| io::Error::other(format!("failed to connect to esplora server: {e}")))?; ++ Ok(Self::Esplora(client)) ++ } ++ ++ pub(crate) fn connect_electrum(server_url: &str) -> io::Result { ++ let client = ++ Arc::new(ElectrumClient::new(server_url).map_err(|e| { ++ io::Error::other(format!("failed to connect to electrum server: {e}")) ++ })?); ++ client.server_features().map_err(|e| { ++ io::Error::other(format!("failed to query electrum server features: {e}")) ++ })?; ++ Ok(Self::Electrum(client)) ++ } ++ ++ pub(crate) fn list_address_receipts( ++ &self, ++ address: &Address, ++ ) -> Result, String> { ++ match self { ++ Self::Esplora(client) => list_esplora_address_receipts(client, address), ++ Self::Electrum(client) => list_electrum_address_receipts(client, address), ++ } ++ } ++ ++ pub(crate) fn chain_checkpoint(&self) -> Result { ++ match self { ++ Self::Esplora(client) => { ++ let block_hash = client ++ .get_tip_hash() ++ .map_err(|error| format!("failed to query esplora tip hash: {error}"))?; ++ let status = client ++ .get_block_status(&block_hash) ++ .map_err(|error| format!("failed to query esplora tip status: {error}"))?; ++ if !status.in_best_chain { ++ return Err("esplora tip is not in the best chain".to_string()); ++ } ++ let height = status ++ .height ++ .ok_or_else(|| "esplora tip has no block height".to_string())?; ++ Ok(ChainCheckpoint { height, block_hash }) ++ } ++ Self::Electrum(client) => { ++ let notification = client ++ .block_headers_subscribe() ++ .map_err(|error| format!("failed to query electrum tip: {error}"))?; ++ let height = u32::try_from(notification.height) ++ .map_err(|_| "electrum tip height exceeds u32".to_string())?; ++ Ok(ChainCheckpoint { ++ height, ++ block_hash: notification.header.block_hash(), ++ }) ++ } ++ } ++ } ++} ++ ++fn sum_matching_outputs( ++ outputs: impl IntoIterator, ++ address: &Address, ++) -> Result { ++ let expected_script = address.script_pubkey(); ++ outputs ++ .into_iter() ++ .filter(|(script, _)| script == &expected_script) ++ .try_fold(0_u64, |total, (_, value)| { ++ total ++ .checked_add(value) ++ .ok_or_else(|| "address receipt amount overflowed u64".to_string()) ++ }) ++} ++ ++fn list_esplora_address_receipts( ++ client: &EsploraBlockingClient, ++ address: &Address, ++) -> Result, String> { ++ let stats = client ++ .get_address_stats(address) ++ .map_err(|e| format!("failed to query address statistics: {e}"))?; ++ let transaction_count = ++ stats.chain_stats.tx_count as usize + stats.mempool_stats.tx_count as usize; ++ if transaction_count > MAX_ADDRESS_HISTORY_TRANSACTIONS { ++ return Err(format!( ++ "address history has {transaction_count} transactions; maximum supported is {MAX_ADDRESS_HISTORY_TRANSACTIONS}" ++ )); ++ } ++ ++ let tip_height = client ++ .get_height() ++ .map_err(|e| format!("failed to query esplora tip height: {e}"))?; ++ let mut transactions = client ++ .get_address_txs(address, None) ++ .map_err(|e| format!("failed to query address transactions: {e}"))?; ++ let mut seen = transactions ++ .iter() ++ .map(|transaction| transaction.txid) ++ .collect::>(); ++ ++ while seen.len() < transaction_count { ++ let Some(last_confirmed_txid) = transactions ++ .iter() ++ .rev() ++ .find(|transaction| transaction.status.confirmed) ++ .map(|transaction| transaction.txid) ++ else { ++ return Err( ++ "esplora address history was incomplete and had no confirmed pagination cursor" ++ .to_string(), ++ ); ++ }; ++ let page = client ++ .get_address_txs(address, Some(last_confirmed_txid)) ++ .map_err(|e| format!("failed to paginate address transactions: {e}"))?; ++ if page.is_empty() { ++ return Err(format!( ++ "esplora returned {} of {transaction_count} address transactions", ++ seen.len() ++ )); ++ } ++ let previous_count = seen.len(); ++ for transaction in page { ++ if seen.insert(transaction.txid) { ++ transactions.push(transaction); ++ } ++ } ++ if seen.len() == previous_count { ++ return Err("esplora address history pagination made no progress".to_string()); ++ } ++ } ++ ++ let mut receipts = transactions ++ .into_iter() ++ .filter_map(|transaction| { ++ let amount_result = sum_matching_outputs( ++ transaction ++ .vout ++ .into_iter() ++ .map(|output| (output.scriptpubkey, output.value)), ++ address, ++ ); ++ match amount_result { ++ Ok(0) => None, ++ Ok(amount_sat) => { ++ let block_height = transaction.status.block_height; ++ let confirmations = block_height ++ .map(|height| tip_height.saturating_sub(height).saturating_add(1)) ++ .unwrap_or(0); ++ Some(Ok(AddressReceipt { ++ txid: transaction.txid, ++ amount_sat, ++ confirmations, ++ block_height, ++ })) ++ } ++ Err(error) => Some(Err(error)), ++ } ++ }) ++ .collect::, _>>()?; ++ receipts.sort_by_key(|receipt| receipt.txid); ++ Ok(receipts) ++} ++ ++fn list_electrum_address_receipts( ++ client: &ElectrumClient, ++ address: &Address, ++) -> Result, String> { ++ let history = client ++ .script_get_history(&address.script_pubkey()) ++ .map_err(|e| format!("failed to query address history: {e}"))?; ++ if history.len() > MAX_ADDRESS_HISTORY_TRANSACTIONS { ++ return Err(format!( ++ "address history has {} transactions; maximum supported is {MAX_ADDRESS_HISTORY_TRANSACTIONS}", ++ history.len() ++ )); ++ } ++ let tip_height = client ++ .block_headers_subscribe() ++ .map_err(|e| format!("failed to query electrum tip height: {e}"))? ++ .height as u32; ++ ++ let mut receipts = history ++ .into_iter() ++ .filter_map(|entry| { ++ let transaction = match client.transaction_get(&entry.tx_hash) { ++ Ok(transaction) => transaction, ++ Err(error) => { ++ return Some(Err(format!( ++ "failed to fetch address transaction {}: {error}", ++ entry.tx_hash ++ ))) ++ } ++ }; ++ let amount_result = sum_matching_outputs( ++ transaction ++ .output ++ .into_iter() ++ .map(|output| (output.script_pubkey, output.value.to_sat())), ++ address, ++ ); ++ match amount_result { ++ Ok(0) => None, ++ Ok(amount_sat) => { ++ let block_height = u32::try_from(entry.height) ++ .ok() ++ .filter(|height| *height > 0); ++ let confirmations = block_height ++ .map(|height| tip_height.saturating_sub(height).saturating_add(1)) ++ .unwrap_or(0); ++ Some(Ok(AddressReceipt { ++ txid: entry.tx_hash, ++ amount_sat, ++ confirmations, ++ block_height, ++ })) ++ } ++ Err(error) => Some(Err(error)), ++ } ++ }) ++ .collect::, _>>()?; ++ receipts.sort_by_key(|receipt| receipt.txid); ++ Ok(receipts) ++} + + pub(crate) fn default_fee_buckets() -> HashMap { + let mut fees = HashMap::new(); +@@ -378,6 +630,48 @@ impl UtxoLookup for ElectrumIndexerClient { + } + } + ++#[cfg(test)] ++mod address_receipt_tests { ++ use super::*; ++ ++ fn address() -> Address { ++ Address::from_str("bcrt1p4zn6jcu9cg9gnnc8nzjez4m4ph70dn2velu4f7apft5evqcke84qmdn6w8") ++ .unwrap() ++ .assume_checked() ++ } ++ ++ #[test] ++ fn sums_only_outputs_for_the_requested_address() { ++ let address = address(); ++ let other_script = bitcoin::ScriptBuf::new_op_return(&[]); ++ let received = sum_matching_outputs( ++ [ ++ (address.script_pubkey(), 20_000), ++ (other_script, 40_000), ++ (address.script_pubkey(), 30_000), ++ ], ++ &address, ++ ) ++ .unwrap(); ++ ++ assert_eq!(received, 50_000); ++ } ++ ++ #[test] ++ fn rejects_receipt_amount_overflow() { ++ let address = address(); ++ let result = sum_matching_outputs( ++ [ ++ (address.script_pubkey(), u64::MAX), ++ (address.script_pubkey(), 1), ++ ], ++ &address, ++ ); ++ ++ assert_eq!(result.unwrap_err(), "address receipt amount overflowed u64"); ++ } ++} ++ + fn electrum_txid_from_pos( + client: &ElectrumClient, + height: usize, +diff --git a/src/ldk.rs b/src/ldk.rs +index 005b8ee..764dee6 100644 +--- a/src/ldk.rs ++++ b/src/ldk.rs +@@ -102,7 +102,7 @@ use rgb_lib::{ + Wallet as RgbLibWallet, WalletData, WitnessData, + }, + AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, +- Fascia, FileContent, RgbTransfer, RgbTxid, TransferStatus, WitnessOrd, ++ Fascia, FileContent, RgbTransfer, RgbTxid, TransferStatus as RgbTransferStatus, WitnessOrd, + }; + use std::collections::HashMap; + use std::collections::HashSet; +@@ -131,7 +131,7 @@ use crate::core_types::{ + use crate::database::RlnDatabase; + use crate::disk::{self, FilesystemLogger}; + use crate::gossip::{GossipSource, GossipSourceConfig}; +-use crate::indexer::{ElectrumIndexerClient, EsploraIndexerClient}; ++use crate::indexer::{AddressIndexerClient, ElectrumIndexerClient, EsploraIndexerClient}; + + pub(crate) const INBOUND_PAYMENTS_KEY: &str = "inbound_payments"; + const OUTBOUND_PAYMENTS_KEY: &str = "outbound_payments"; +@@ -270,6 +270,13 @@ pub(crate) struct LdkBackgroundServices { + peer_manager: Arc, + bp_exit: Sender<()>, + background_processor: Option>>, ++ service_tasks: Vec>, ++} ++ ++struct LdkShutdownHandles { ++ background_processor: Option>>, ++ service_tasks: Vec>, ++ peer_manager: Arc, + } + + #[derive(Clone, Debug)] +@@ -289,6 +296,11 @@ pub(crate) struct PaymentInfo { + pub(crate) payment_idx: Option, + pub(crate) async_hash_index: Option, + pub(crate) async_host_node_id: Option, ++ pub(crate) fee_paid_msat: Option, ++ pub(crate) failure_code: Option, ++ pub(crate) asset_id: Option, ++ pub(crate) asset_amount: Option, ++ pub(crate) carrier_msat: Option, + } + + impl_writeable_tlv_based!(PaymentInfo, { +@@ -306,10 +318,39 @@ impl_writeable_tlv_based!(PaymentInfo, { + (22, payment_idx, option), + (24, async_hash_index, option), + (26, async_host_node_id, option), +- // odd type so older binaries skip the field instead of failing the whole read ++ (28, fee_paid_msat, option), ++ // Odd type so older binaries skip the field instead of failing the whole read. + (29, description, option), ++ (30, failure_code, option), ++ (32, asset_id, option), ++ (34, asset_amount, option), ++ (36, carrier_msat, option), + }); + ++fn payment_failure_code(reason: PaymentFailureReason) -> &'static str { ++ match reason { ++ PaymentFailureReason::RecipientRejected => "RECIPIENT_REJECTED", ++ PaymentFailureReason::UserAbandoned => "USER_ABANDONED", ++ PaymentFailureReason::RetriesExhausted => "RETRIES_EXHAUSTED", ++ PaymentFailureReason::PaymentExpired => "PAYMENT_EXPIRED", ++ PaymentFailureReason::RouteNotFound => "ROUTE_NOT_FOUND", ++ PaymentFailureReason::UnexpectedError => "UNEXPECTED_ROUTING_ERROR", ++ PaymentFailureReason::UnknownRequiredFeatures => "UNKNOWN_REQUIRED_FEATURES", ++ PaymentFailureReason::InvoiceRequestExpired => "INVOICE_REQUEST_EXPIRED", ++ PaymentFailureReason::InvoiceRequestRejected => "INVOICE_REQUEST_REJECTED", ++ PaymentFailureReason::BlindedPathCreationFailed => "BLINDED_PATH_CREATION_FAILED", ++ } ++} ++ ++fn is_trusted_virtual_inbound( ++ enabled: bool, ++ trusted_peers: &[PublicKey], ++ counterparty: &PublicKey, ++ supports_scid_privacy: bool, ++) -> bool { ++ enabled && supports_scid_privacy && trusted_peers.contains(counterparty) ++} ++ + pub(crate) struct InboundPaymentInfoStorage { + pub(crate) payments: LdkHashMap, + } +@@ -401,6 +442,13 @@ pub(crate) struct VirtualChannelSessionStore { + pub(crate) entries: LdkHashMap, + } + ++fn virtual_channel_session_blocks_reopen( ++ session: &VirtualChannelSession, ++ peer_id: &PublicKey, ++) -> bool { ++ session.peer_id == *peer_id && !matches!(session.status, VirtualChannelSessionStatus::Abandoned) ++} ++ + impl_writeable_tlv_based!(VirtualChannelSessionStore, { + (0, entries, required), + }); +@@ -568,6 +616,7 @@ impl UnlockedAppState { + &self, + payment_hash: PaymentHash, + status: HTLCStatus, ++ failure_code: &'static str, + ) { + self.channel_manager.fail_htlc_backwards(&payment_hash); + self.upsert_inbound_payment( +@@ -579,6 +628,7 @@ impl UnlockedAppState { + self.channel_manager.get_our_node_id(), + None, + None, ++ Some(failure_code), + ); + clear_rgb_payment_pending(&payment_hash, true, self.kv_store.as_ref()); + } +@@ -593,6 +643,7 @@ impl UnlockedAppState { + { + if !recent_payments_payment_ids.contains(payment_id) { + payment_info.status = HTLCStatus::Failed; ++ payment_info.failure_code = Some("PAYMENT_ABANDONED_AFTER_RESTART".to_owned()); + payment_info.updated_at = get_current_timestamp(); + failed = true; + } +@@ -614,6 +665,7 @@ impl UnlockedAppState { + if let Some(expires_at) = payment_info.expires_at { + if now > expires_at { + payment_info.status = HTLCStatus::Failed; ++ payment_info.failure_code = Some("INVOICE_EXPIRED".to_owned()); + payment_info.updated_at = now; + failed = true; + } +@@ -662,7 +714,11 @@ impl UnlockedAppState { + claim_deadline_height, + expires_at + ); +- self.fail_htlc_backwards_and_update_inbound_payment(payment_hash, HTLCStatus::Failed); ++ self.fail_htlc_backwards_and_update_inbound_payment( ++ payment_hash, ++ HTLCStatus::Failed, ++ "INVOICE_CLAIM_WINDOW_EXPIRED", ++ ); + } + + self.inbound_payments() +@@ -699,6 +755,7 @@ impl UnlockedAppState { + payee_pubkey: PublicKey, + claim_deadline_height: Option, + invoice_type: Option, ++ failure_code: Option<&str>, + ) { + let mut inbound = self.get_inbound_payments(); + match inbound.payments.entry(payment_hash) { +@@ -714,6 +771,9 @@ impl UnlockedAppState { + if claim_deadline_height.is_some() { + payment_info.claim_deadline_height = claim_deadline_height; + } ++ if let Some(failure_code) = failure_code { ++ payment_info.failure_code = Some(failure_code.to_owned()); ++ } + } + Entry::Vacant(e) => { + let created_at = get_current_timestamp(); +@@ -733,6 +793,11 @@ impl UnlockedAppState { + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: failure_code.map(str::to_owned), ++ asset_id: None, ++ asset_amount: None, ++ carrier_msat: None, + }; + self.stamp_payment_idx(&mut payment_info); + e.insert(payment_info); +@@ -746,11 +811,13 @@ impl UnlockedAppState { + payment_id: PaymentId, + status: HTLCStatus, + preimage: Option, ++ fee_paid_msat: Option, + ) -> PaymentInfo { + let mut outbound = self.get_outbound_payments(); + let payment_info = outbound.payments.get_mut(&payment_id).unwrap(); + payment_info.status = status; + payment_info.preimage = preimage; ++ payment_info.fee_paid_msat = fee_paid_msat; + payment_info.updated_at = get_current_timestamp(); + let payment = (*payment_info).clone(); + self.save_outbound_payments(outbound); +@@ -765,6 +832,19 @@ impl UnlockedAppState { + self.save_outbound_payments(outbound); + } + ++ pub(crate) fn fail_outbound_payment( ++ &self, ++ payment_id: PaymentId, ++ failure_code: impl Into, ++ ) { ++ let mut outbound = self.get_outbound_payments(); ++ let payment_info = outbound.payments.get_mut(&payment_id).unwrap(); ++ payment_info.status = HTLCStatus::Failed; ++ payment_info.failure_code = Some(failure_code.into()); ++ payment_info.updated_at = get_current_timestamp(); ++ self.save_outbound_payments(outbound); ++ } ++ + pub(crate) fn channel_ids(&self) -> LdkHashMap { + self.get_channel_ids_map().channel_ids.clone() + } +@@ -829,7 +909,7 @@ impl UnlockedAppState { + .get_virtual_channel_session_store() + .entries + .values() +- .any(|session| session.peer_id == peer_id); ++ .any(|session| virtual_channel_session_blocks_reopen(session, &peer_id)); + if duplicate_virtual_session { + return Err(APIError::InvalidRequest( + "virtual channel session already exists for this peer pair".to_string(), +@@ -1333,7 +1413,7 @@ impl AssetLinkAuthorizer for NodeAssetLinkAuthorizer { + .map_err(|_| { + JsonRpcErrorWire::application_error(ASSET_LINK_ERROR_UNKNOWN_LINK, "unknown_link") + })? +- .is_some_and(|transfer| transfer.status == TransferStatus::Settled); ++ .is_some_and(|transfer| transfer.status == RgbTransferStatus::Settled); + if !link_is_settled { + return Err(JsonRpcErrorWire::application_error( + ASSET_LINK_ERROR_UNKNOWN_LINK, +@@ -1576,6 +1656,11 @@ impl AsyncOrderInvoiceProvider for AsyncOrderRecipientInvoiceProvider { + payment_idx: None, + async_hash_index: self.external_signer_mode.then_some(hash_index), + async_host_node_id: self.external_signer_mode.then_some(sender_node_id), ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: None, ++ asset_amount: None, ++ carrier_msat: None, + }, + )?; + +@@ -1780,8 +1865,12 @@ fn handle_funding_prepare_err( + /// transaction was broadcast. For colored channels this fails the pending RGB + /// batch transfer; for vanilla channels it aborts the pending vanilla tx that + /// was created (and locked the UTXOs) during `FundingGenerationReady`. +-async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc) { ++async fn handle_open_chan_fail( ++ channel_id: &ChannelId, ++ unlocked_state: Arc, ++) -> bool { + let channel_id_hex = channel_id.0.as_hex().to_string(); ++ let mut cleanup_succeeded = true; + if let Some(rgb_info) = + get_rgb_channel_info_optional(channel_id, true, unlocked_state.kv_store.as_ref()) + { +@@ -1793,6 +1882,7 @@ async fn handle_open_chan_fail(channel_id: &ChannelId, unlocked_state: Arc { + tracing::info!("Aborted pending vanilla tx {funding_txid} for channel {channel_id}") + } +- Err(e) => tracing::error!( +- "Error aborting pending vanilla tx {funding_txid} for channel {channel_id}: {e:?}" +- ), ++ Err(e) => { ++ cleanup_succeeded = false; ++ tracing::error!( ++ "Error aborting pending vanilla tx {funding_txid} for channel {channel_id}: {e:?}" ++ ); ++ } + } + } +- let _ = unlocked_state +- .kv_store +- .remove(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex, false); ++ if cleanup_succeeded { ++ let _ = ++ unlocked_state ++ .kv_store ++ .remove(PENDING_FUNDING_NAMESPACE, "", &channel_id_hex, false); ++ } ++ cleanup_succeeded ++} ++ ++async fn cleanup_virtual_rgb_transfer( ++ session: &VirtualChannelSession, ++ unlocked_state: Arc, ++) -> bool { ++ let funding_txid = session.virtual_funding_txo.txid.to_string(); ++ let unlocked_state_copy = unlocked_state.clone(); ++ let funding_txid_copy = funding_txid.clone(); ++ let transfers = match tokio::task::spawn_blocking(move || { ++ unlocked_state_copy.rgb_list_transfers( ++ rgb_lib::wallet::AssetFilter::AnyOrNone, ++ Some(funding_txid_copy), ++ ) ++ }) ++ .await ++ { ++ Ok(Ok(transfers)) => transfers, ++ Ok(Err(e)) => { ++ tracing::error!( ++ "Error finding RGB transfers for orphaned virtual funding tx {funding_txid}: {e:?}" ++ ); ++ return false; ++ } ++ Err(e) => { ++ tracing::error!( ++ "RGB transfer lookup task failed for orphaned virtual funding tx {funding_txid}: {e}" ++ ); ++ return false; ++ } ++ }; ++ ++ let mut pending_batch_transfer_idxs = transfers ++ .iter() ++ .filter(|transfer| { ++ !matches!( ++ transfer.status, ++ RgbTransferStatus::Settled | RgbTransferStatus::Failed ++ ) ++ }) ++ .map(|transfer| transfer.batch_transfer_idx) ++ .collect::>(); ++ pending_batch_transfer_idxs.sort_unstable(); ++ pending_batch_transfer_idxs.dedup(); ++ ++ for batch_transfer_idx in pending_batch_transfer_idxs { ++ let unlocked_state_copy = unlocked_state.clone(); ++ let result = tokio::task::spawn_blocking(move || { ++ unlocked_state_copy.rgb_fail_transfers(Some(batch_transfer_idx), false, true) ++ }) ++ .await; ++ match result { ++ Ok(Ok(_)) => {} ++ Ok(Err(e)) => { ++ tracing::error!( ++ "Error releasing orphaned virtual RGB transfer batch {batch_transfer_idx} for funding tx {funding_txid}: {e:?}" ++ ); ++ return false; ++ } ++ Err(e) => { ++ tracing::error!( ++ "RGB transfer cleanup task failed for orphaned virtual funding tx {funding_txid}: {e}" ++ ); ++ return false; ++ } ++ } ++ } ++ ++ let unlocked_state_copy = unlocked_state; ++ let funding_txid_copy = funding_txid.clone(); ++ match tokio::task::spawn_blocking(move || { ++ unlocked_state_copy.rgb_list_transfers( ++ rgb_lib::wallet::AssetFilter::AnyOrNone, ++ Some(funding_txid_copy), ++ ) ++ }) ++ .await ++ { ++ Ok(Ok(transfers)) ++ if transfers.iter().all(|transfer| { ++ matches!( ++ transfer.status, ++ RgbTransferStatus::Settled | RgbTransferStatus::Failed ++ ) ++ }) => ++ { ++ true ++ } ++ Ok(Ok(transfers)) => { ++ let remaining = transfers ++ .iter() ++ .filter(|transfer| { ++ !matches!( ++ transfer.status, ++ RgbTransferStatus::Settled | RgbTransferStatus::Failed ++ ) ++ }) ++ .map(|transfer| { ++ format!( ++ "batch={} status={:?}", ++ transfer.batch_transfer_idx, transfer.status ++ ) ++ }) ++ .collect::>() ++ .join(", "); ++ tracing::error!( ++ "Orphaned virtual funding tx {funding_txid} still owns non-terminal RGB transfers after cleanup: {remaining}" ++ ); ++ false ++ } ++ Ok(Err(e)) => { ++ tracing::error!( ++ "Error verifying RGB transfer cleanup for orphaned virtual funding tx {funding_txid}: {e:?}" ++ ); ++ false ++ } ++ Err(e) => { ++ tracing::error!( ++ "RGB transfer verification task failed for orphaned virtual funding tx {funding_txid}: {e}" ++ ); ++ false ++ } ++ } ++} ++ ++/// Reconcile a trusted virtual-channel session that no longer exists in LDK. ++/// ++/// The host creates RGB transfer state before the remote peer validates the ++/// funding consignment. A peer rejection can therefore remove the LDK channel ++/// while leaving both the session and the RGB allocation persisted. Reopening ++/// must remain blocked until the allocation is released; after cleanup, the ++/// abandoned session is retained as an audit record but no longer blocks the ++/// peer from opening a replacement channel. ++pub(crate) async fn reconcile_orphaned_virtual_session( ++ channel_id: &ChannelId, ++ unlocked_state: Arc, ++) -> bool { ++ let Some(session) = unlocked_state.virtual_channel_session_get(channel_id) else { ++ return false; ++ }; ++ if unlocked_state ++ .channel_manager ++ .list_channels() ++ .iter() ++ .any(|channel| channel.channel_id == *channel_id) ++ { ++ return false; ++ } ++ ++ // Persist a blocking state before touching RGB allocations. This also ++ // repairs legacy sessions that were marked abandoned before their transfer ++ // was actually released. ++ unlocked_state.virtual_channel_session_update_status( ++ &session, ++ VirtualChannelSessionStatus::AbandonPending, ++ ); ++ ++ let final_cleanup = handle_open_chan_fail(&session.channel_id, unlocked_state.clone()).await; ++ let temporary_cleanup = if session.former_temporary_channel_id == session.channel_id { ++ true ++ } else { ++ handle_open_chan_fail(&session.former_temporary_channel_id, unlocked_state.clone()).await ++ }; ++ let transfer_cleanup = cleanup_virtual_rgb_transfer(&session, unlocked_state.clone()).await; ++ if !final_cleanup || !temporary_cleanup || !transfer_cleanup { ++ tracing::error!( ++ "EVENT: virtual session {} remains active because staged funding cleanup failed", ++ session.channel_id, ++ ); ++ return true; ++ } ++ ++ unlocked_state ++ .virtual_channel_session_update_status(&session, VirtualChannelSessionStatus::Abandoned); ++ unlocked_state.virtual_channel_draft_delete(&session.former_temporary_channel_id); ++ unlocked_state.delete_channel_id(session.channel_id); ++ ++ for stale_channel_id in [session.channel_id, session.former_temporary_channel_id] { ++ let stale_channel_id_hex = stale_channel_id.0.as_hex().to_string(); ++ let _ = unlocked_state.kv_store.remove( ++ "", ++ "", ++ &format!("virtual_channel_{stale_channel_id}"), ++ false, ++ ); ++ let _ = unlocked_state ++ .kv_store ++ .remove_rgb_channel_info(&stale_channel_id_hex, false); ++ let _ = unlocked_state ++ .kv_store ++ .remove_rgb_channel_info(&stale_channel_id_hex, true); ++ unlocked_state ++ .kv_store ++ .remove_rgb_consignment(&stale_channel_id_hex); ++ } ++ ++ tracing::warn!( ++ "EVENT: reconciled orphaned trusted virtual channel session {} for peer {}", ++ session.channel_id, ++ session.peer_id, ++ ); ++ true + } + + /// Undo what a standard channel's `FundingGenerationReady` preparation staged, so a failure +@@ -2533,6 +2832,7 @@ async fn handle_ldk_events( + unlocked_state.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, ++ "INVOICE_EXPIRED", + ); + return Ok(()); + } +@@ -2549,6 +2849,7 @@ async fn handle_ldk_events( + unlocked_state.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, ++ "PAYMENT_AMOUNT_BELOW_INVOICE", + ); + return Ok(()); + } +@@ -2607,6 +2908,7 @@ async fn handle_ldk_events( + .fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, ++ "INVALID_ASYNC_PAYMENT_PREIMAGE", + ); + return Ok(()); + } +@@ -2643,6 +2945,7 @@ async fn handle_ldk_events( + unlocked_state.channel_manager.get_our_node_id(), + claim_deadline, + None, ++ None, + ); + unlocked_state + .async_order_handler +@@ -2725,6 +3028,7 @@ async fn handle_ldk_events( + receiver_node_id.unwrap(), + None, + None, ++ None, + ); + } + } +@@ -2757,6 +3061,7 @@ async fn handle_ldk_events( + payment_id.unwrap(), + HTLCStatus::Succeeded, + Some(payment_preimage), ++ fee_paid_msat, + ); + unlocked_state + .async_order_handler +@@ -2800,62 +3105,25 @@ async fn handle_ldk_events( + .copy_from_slice(&unlocked_state.entropy_source.get_secure_random_bytes()[..16]); + let user_channel_id = u128::from_be_bytes(random_bytes); + +- let (res, accepted) = if static_state.enable_virtual_channels_v0 { +- let trusted_virtual_peer = static_state.virtual_peer_pubkeys.is_empty() +- || static_state +- .virtual_peer_pubkeys +- .iter() +- .any(|trusted_peer| trusted_peer == counterparty_node_id); +- if !trusted_virtual_peer { +- let err = "untrusted_virtual_peer".to_string(); +- tracing::error!( +- "EVENT: Rejected inbound trusted virtual channel ({}) from {}: {}", +- temporary_channel_id, +- hex_str(&counterparty_node_id.serialize()), +- err, +- ); +- ( +- unlocked_state +- .channel_manager +- .force_close_broadcasting_latest_txn( +- temporary_channel_id, +- counterparty_node_id, +- err, +- ), +- false, +- ) +- } else if !channel_type.supports_scid_privacy() { +- let err = "unsupported_scid_alias".to_string(); +- tracing::error!( +- "EVENT: Rejected inbound channel ({}) from {}: {}", +- temporary_channel_id, +- hex_str(&counterparty_node_id.serialize()), +- err, +- ); +- ( +- unlocked_state +- .channel_manager +- .force_close_broadcasting_latest_txn( +- temporary_channel_id, +- counterparty_node_id, +- err, +- ), +- false, +- ) +- } else { +- ( +- unlocked_state +- .channel_manager +- .accept_inbound_channel_from_trusted_peer_0conf( +- temporary_channel_id, +- counterparty_node_id, +- user_channel_id, +- None, +- ChannelFundingType::Virtual, +- ), +- true, +- ) +- } ++ let trusted_virtual_peer = is_trusted_virtual_inbound( ++ static_state.enable_virtual_channels_v0, ++ &static_state.virtual_peer_pubkeys, ++ counterparty_node_id, ++ channel_type.supports_scid_privacy(), ++ ); ++ let (res, accepted_as) = if trusted_virtual_peer { ++ ( ++ unlocked_state ++ .channel_manager ++ .accept_inbound_channel_from_trusted_peer_0conf( ++ temporary_channel_id, ++ counterparty_node_id, ++ user_channel_id, ++ None, ++ ChannelFundingType::Virtual, ++ ), ++ "trusted_virtual", ++ ) + } else { + ( + unlocked_state.channel_manager.accept_inbound_channel( +@@ -2864,7 +3132,7 @@ async fn handle_ldk_events( + user_channel_id, + None, + ), +- true, ++ "standard", + ) + }; + +@@ -2875,15 +3143,10 @@ async fn handle_ldk_events( + hex_str(&counterparty_node_id.serialize()), + e, + ); +- } else if accepted { +- tracing::info!( +- "EVENT: Accepted inbound channel ({}) from {}", +- temporary_channel_id, +- hex_str(&counterparty_node_id.serialize()), +- ); + } else { + tracing::info!( +- "EVENT: Rejected inbound channel ({}) from {}", ++ "EVENT: Accepted {} inbound channel ({}) from {}", ++ accepted_as, + temporary_channel_id, + hex_str(&counterparty_node_id.serialize()), + ); +@@ -2899,34 +3162,29 @@ async fn handle_ldk_events( + payment_id, + .. + } => { ++ let failure_reason = reason.unwrap_or(PaymentFailureReason::RetriesExhausted); + if let Some(hash) = payment_hash { + clear_rgb_payment_pending(&hash, false, unlocked_state.kv_store.as_ref()); + tracing::error!( + "EVENT: Failed to send payment to payment ID {}, payment hash {}: {:?}", + payment_id, + hash, +- if let Some(r) = reason { +- r +- } else { +- PaymentFailureReason::RetriesExhausted +- } ++ failure_reason + ); + if unlocked_state.is_maker_swap(&hash) { + unlocked_state.update_maker_swap_status(&hash, SwapStatus::Failed); + } else { +- unlocked_state.update_outbound_payment_status(payment_id, HTLCStatus::Failed); ++ unlocked_state ++ .fail_outbound_payment(payment_id, payment_failure_code(failure_reason)); + } + } else { + tracing::error!( + "EVENT: Failed fetch invoice for payment ID {}: {:?}", + payment_id, +- if let Some(r) = reason { +- r +- } else { +- PaymentFailureReason::RetriesExhausted +- } ++ failure_reason + ); +- unlocked_state.update_outbound_payment_status(payment_id, HTLCStatus::Failed); ++ unlocked_state ++ .fail_outbound_payment(payment_id, payment_failure_code(failure_reason)); + } + } + Event::InvoiceReceived { .. } => { +@@ -3209,7 +3467,9 @@ async fn handle_ldk_events( + ); + + // Release any funds locked for a funding tx that was never broadcast. +- handle_open_chan_fail(&channel_id, unlocked_state.clone()).await; ++ if !reconcile_orphaned_virtual_session(&channel_id, unlocked_state.clone()).await { ++ handle_open_chan_fail(&channel_id, unlocked_state.clone()).await; ++ } + + let former_temporary_channel_id = unlocked_state.delete_channel_id(channel_id); + let virtual_draft_temporary_channel_id = if unlocked_state +@@ -3254,7 +3514,9 @@ async fn handle_ldk_events( + ); + + // The funding tx was discarded before broadcast; release the locked funds. +- handle_open_chan_fail(&channel_id, unlocked_state.clone()).await; ++ if !reconcile_orphaned_virtual_session(&channel_id, unlocked_state.clone()).await { ++ handle_open_chan_fail(&channel_id, unlocked_state.clone()).await; ++ } + + unlocked_state.delete_channel_id(channel_id); + let _ = unlocked_state.kv_store.remove( +@@ -4452,12 +4714,16 @@ pub(crate) async fn start_ldk( + (&static_state.vss_url, &vss_identity) + { + tracing::info!(store_id = %identity.pubkey_hex, "Initializing VSS KV store"); ++ let writer_id = ++ crate::vss_kv_store::load_or_create_writer_id(&static_state.storage_dir_path) ++ .map_err(|e| APIError::FailedVssInit(e.to_string()))?; + let vss_kv_store = Arc::new( +- crate::vss_kv_store::VssKvStore::new_with_retry( ++ crate::vss_kv_store::VssKvStore::new_with_retry_and_instance_id( + vss_url.clone(), + identity.pubkey_hex.clone(), + identity.signing_key, + &static_state.config.vss, ++ writer_id, + ) + .map_err(|e| APIError::FailedVssInit(e.to_string()))?, + ); +@@ -4667,16 +4933,16 @@ pub(crate) async fn start_ldk( + } + + // RGB setup +- let indexer_url = if let Some(indexer_url) = &unlock_request.indexer_url { ++ let (indexer_url, indexer_protocol) = if let Some(indexer_url) = &unlock_request.indexer_url { + let indexer_protocol = check_indexer_url(indexer_url, bitcoin_network)?; + tracing::info!( + "Connected to an indexer with the {} protocol", + indexer_protocol + ); +- indexer_url ++ (indexer_url.as_str(), indexer_protocol) + } else { + tracing::info!("Using the default indexer"); +- match bitcoin_network { ++ let indexer_url = match bitcoin_network { + BitcoinNetwork::Regtest => ELECTRUM_URL_REGTEST, + BitcoinNetwork::Signet => ELECTRUM_URL_SIGNET, + BitcoinNetwork::Testnet => ELECTRUM_URL_TESTNET, +@@ -4687,7 +4953,28 @@ pub(crate) async fn start_ldk( + "with custom signet indexer must be provided" + ))) + } +- } ++ }; ++ let indexer_protocol = check_indexer_url(indexer_url, bitcoin_network)?; ++ (indexer_url, indexer_protocol) ++ }; ++ let address_indexer = match &*chain_backend { ++ ChainBackend::Esplora(client) => AddressIndexerClient::Esplora(Arc::clone(&client.client)), ++ ChainBackend::Electrum(client) => { ++ AddressIndexerClient::Electrum(Arc::clone(&client.client)) ++ } ++ ChainBackend::Bitcoind(_) => match indexer_protocol { ++ rgb_lib::wallet::rust_only::IndexerProtocol::Esplora => { ++ AddressIndexerClient::connect_esplora( ++ indexer_url, ++ static_state.config.chain.indexer_timeout_secs, ++ ) ++ .map_err(|e| APIError::InvalidIndexer(e.to_string()))? ++ } ++ rgb_lib::wallet::rust_only::IndexerProtocol::Electrum => { ++ AddressIndexerClient::connect_electrum(indexer_url) ++ .map_err(|e| APIError::InvalidIndexer(e.to_string()))? ++ } ++ }, + }; + let proxy_endpoint = if let Some(proxy_endpoint) = &unlock_request.proxy_endpoint { + check_rgb_proxy_endpoint(proxy_endpoint).await?; +@@ -5444,8 +5731,9 @@ pub(crate) async fn start_ldk( + let peer_manager_connection_handler = peer_manager.clone(); + let listening_port = ldk_peer_listening_port; + let stop_processing = Arc::new(AtomicBool::new(false)); ++ let mut service_tasks = Vec::new(); + let stop_listen = Arc::clone(&stop_processing); +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + // Dual-stack when available; hosts with IPv6 disabled fall back to IPv4. + let listener = crate::utils::bind_first_available(&[ + format!("[::]:{listening_port}"), +@@ -5467,7 +5755,7 @@ pub(crate) async fn start_ldk( + .await; + }); + } +- }); ++ })); + + // Connect and Disconnect Blocks + let output_sweeper: Arc = Arc::new(output_sweeper); +@@ -5478,7 +5766,7 @@ pub(crate) async fn start_ldk( + let chain_monitor_listener = chain_monitor.clone(); + let output_sweeper_listener = output_sweeper.clone(); + let bitcoind_block_source = bitcoind_client.clone(); +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + let chain_poller = poll::ChainPoller::new(bitcoind_block_source.as_ref(), network); + let chain_listener = ( + chain_monitor_listener, +@@ -5495,7 +5783,7 @@ pub(crate) async fn start_ldk( + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +- }); ++ })); + } else if let Some(tx_sync) = tx_sync_opt.clone() { + let confirmables: Vec> = vec![ + channel_manager.clone(), +@@ -5505,7 +5793,7 @@ pub(crate) async fn start_ldk( + sync_chain_data(tx_sync.clone(), confirmables.clone()) + .await + .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + loop { + if stop_listen.load(Ordering::Acquire) { + return; +@@ -5515,7 +5803,7 @@ pub(crate) async fn start_ldk( + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +- }); ++ })); + } else { + let tx_sync = electrum_tx_sync_opt + .clone() +@@ -5528,7 +5816,7 @@ pub(crate) async fn start_ldk( + sync_chain_data_electrum(tx_sync.clone(), confirmables.clone()) + .await + .map_err(|e| APIError::InvalidIndexer(e.to_string()))?; +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + loop { + if stop_listen.load(Ordering::Acquire) { + return; +@@ -5540,7 +5828,7 @@ pub(crate) async fn start_ldk( + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +- }); ++ })); + } + + // Read payment info from KVStore +@@ -5738,6 +6026,7 @@ pub(crate) async fn start_ldk( + + let unlocked_state = Arc::new(UnlockedAppState { + config: static_state.config.clone(), ++ address_indexer: Arc::new(address_indexer), + channel_manager: Arc::clone(&channel_manager), + gossip_source: Arc::clone(&gossip_source), + inbound_payments, +@@ -5777,6 +6066,29 @@ pub(crate) async fn start_ldk( + taker_swaps: Arc::clone(&taker_swaps), + })); + ++ // LDK has now restored its complete channel set. Any non-terminal virtual ++ // session absent from that set is an interrupted open/close, not a live ++ // channel. Reconcile it before peers reconnect so an old session cannot ++ // permanently block a replacement channel or reserve RGB inventory. ++ let orphaned_virtual_session_ids = unlocked_state ++ .virtual_channel_session_store() ++ .into_iter() ++ .filter_map(|(channel_id, session)| { ++ (!unlocked_state ++ .channel_manager ++ .list_channels() ++ .iter() ++ .any(|channel| channel.channel_id == channel_id)) ++ .then_some((channel_id, session.status)) ++ }) ++ .collect::>(); ++ for (channel_id, previous_status) in orphaned_virtual_session_ids { ++ if matches!(previous_status, VirtualChannelSessionStatus::Abandoned) { ++ tracing::info!("Verifying cleanup for legacy abandoned virtual session {channel_id}"); ++ } ++ reconcile_orphaned_virtual_session(&channel_id, unlocked_state.clone()).await; ++ } ++ + // Refresh the RGS snapshot on a fixed interval (RGS mode only). The first + // tick fires immediately, so a freshly unlocked node syncs right away. + let gossip_shutdown = Arc::new(tokio::sync::Notify::new()); +@@ -5853,7 +6165,7 @@ pub(crate) async fn start_ldk( + { + let drain_store = Arc::clone(&kv_store); + let stop_drain = Arc::clone(&stop_processing); +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { +@@ -5864,7 +6176,7 @@ pub(crate) async fn start_ldk( + let store = Arc::clone(&drain_store); + let _ = tokio::task::spawn_blocking(move || store.drain_pending()).await; + } +- }); ++ })); + } + + // Regularly reconnect to channel peers. +@@ -5873,7 +6185,7 @@ pub(crate) async fn start_ldk( + let connect_db = static_state.db(); + let stop_connect = Arc::clone(&stop_processing); + let reconnect_interval_secs = static_state.config.node.peer_reconnect_interval_secs; +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(reconnect_interval_secs)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { +@@ -5905,7 +6217,7 @@ pub(crate) async fn start_ldk( + ), + } + } +- }); ++ })); + + // Remote external signer force-close resilience. When the signer daemon is briefly unreachable, a + // channel signing call returns LDK's async-unavailable sentinel (`Err(())`) and the operation parks +@@ -5927,7 +6239,7 @@ pub(crate) async fn start_ldk( + let su_channel_manager = Arc::clone(&channel_manager); + let su_chain_monitor = Arc::clone(&chain_monitor); + let su_stop = Arc::clone(&stop_processing); +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + loop { + // Healthy: idle until the link reports a change (with a coarse timer only to notice + // node shutdown — it drives no signer work). +@@ -5970,7 +6282,7 @@ pub(crate) async fn start_ldk( + } + } + } +- }); ++ })); + } + + // Regularly broadcast our node_announcement. This is only required (or possible) if we have +@@ -6005,7 +6317,7 @@ pub(crate) async fn start_ldk( + let chan_man = Arc::clone(&channel_manager); + let announce_initial_delay_secs = static_state.config.node.announce_initial_delay_secs; + let announce_refresh_interval_secs = static_state.config.node.announce_refresh_interval_secs; +- tokio::spawn(async move { ++ service_tasks.push(tokio::spawn(async move { + // First wait until we have some peers and maybe have opened a channel. + tokio::time::sleep(Duration::from_secs(announce_initial_delay_secs)).await; + // Then, update our announcement periodically to keep it fresh but avoid unnecessary churn +@@ -6029,7 +6341,7 @@ pub(crate) async fn start_ldk( + ); + } + } +- }); ++ })); + + tracing::info!("LDK logs are available at /.ldk/logs"); + tracing::info!("Local Node ID is {}", channel_manager.get_our_node_id()); +@@ -6046,6 +6358,7 @@ pub(crate) async fn start_ldk( + peer_manager: peer_manager.clone(), + bp_exit, + background_processor: Some(background_processor), ++ service_tasks, + }, + unlocked_state, + )) +@@ -6092,32 +6405,37 @@ async fn sync_chain_data_electrum( + } + + impl AppState { +- fn stop_ldk(&self) -> Option>> { ++ fn stop_ldk(&self) -> Option { + let mut ldk_background_services = self.get_ldk_background_services(); ++ let mut ldk_background_services = match ldk_background_services.take() { ++ Some(services) => services, ++ None => { ++ // node is locked ++ tracing::info!("LDK is not running"); ++ return None; ++ } ++ }; + +- if ldk_background_services.is_none() { +- // node is locked +- tracing::info!("LDK is not running"); +- return None; +- } +- +- let ldk_background_services = ldk_background_services.as_mut().unwrap(); +- +- // Disconnect our peers and stop accepting new connections. This ensures we don't continue +- // updating our channel data after we've stopped the background processor. ++ // Stop admitting new service work before the final peer disconnect. In ++ // particular, the persisted-peer reconnect task may already be waiting ++ // inside an outbound connection attempt when shutdown starts. + ldk_background_services + .stop_processing + .store(true, Ordering::Release); + ldk_background_services.gossip_shutdown.notify_one(); +- ldk_background_services.peer_manager.disconnect_all_peers(); ++ for task in &ldk_background_services.service_tasks { ++ task.abort(); ++ } + + // Stop the background processor. + if !ldk_background_services.bp_exit.is_closed() { + ldk_background_services.bp_exit.send(()).unwrap(); +- ldk_background_services.background_processor.take() +- } else { +- None + } ++ Some(LdkShutdownHandles { ++ background_processor: ldk_background_services.background_processor.take(), ++ service_tasks: std::mem::take(&mut ldk_background_services.service_tasks), ++ peer_manager: Arc::clone(&ldk_background_services.peer_manager), ++ }) + } + } + +@@ -6150,8 +6468,30 @@ pub(crate) async fn stop_ldk(app_state: Arc) { + ) + }); + ++ let mut shutdown_handles = app_state.stop_ldk(); ++ ++ // Join every aborted listener/reconnect/sync task before disconnecting ++ // peers. Disconnecting first allows an in-flight reconnect to establish a ++ // replacement socket during the VSS flush, leaving the remote peer with a ++ // half-open connection after this runtime has been destroyed. ++ if let Some(handles) = shutdown_handles.as_mut() { ++ for task in std::mem::take(&mut handles.service_tasks) { ++ match task.await { ++ Ok(()) => {} ++ Err(error) if error.is_cancelled() => {} ++ Err(error) => { ++ tracing::warn!(error = %error, "LDK service task failed during shutdown") ++ } ++ } ++ } ++ handles.peer_manager.disconnect_all_peers(); ++ } ++ + #[cfg(feature = "vss")] +- if let Some(mut join_handle) = app_state.stop_ldk() { ++ if let Some(mut join_handle) = shutdown_handles ++ .as_mut() ++ .and_then(|handles| handles.background_processor.take()) ++ { + // Bounded flush: give the final remote-first persists time to reach + // VSS, then abort outage-pending retries so shutdown cannot hang. + match tokio::time::timeout(BP_SHUTDOWN_FLUSH_TIMEOUT, &mut join_handle).await { +@@ -6170,13 +6510,17 @@ pub(crate) async fn stop_ldk(app_state: Arc) { + } + } + #[cfg(not(feature = "vss"))] +- if let Some(join_handle) = app_state.stop_ldk() { ++ if let Some(join_handle) = shutdown_handles ++ .as_mut() ++ .and_then(|handles| handles.background_processor.take()) ++ { + join_handle.await.unwrap().unwrap(); + } + + // Graceful teardown (lock, shutdown, signal): release the VSS fence so +- // the next unlock — a fresh instance id — takes over without an explicit +- // /vssclearfence. Hard kills still leave the fence behind by design. ++ // another installation can take over without an explicit /vssclearfence. ++ // Hard kills retain the fence; this installation can safely reclaim it ++ // because its writer identity survives process restarts. + #[cfg(feature = "vss")] + { + if let Some((kv_store, monitor_kv_store)) = stores { +@@ -6212,6 +6556,12 @@ pub(crate) async fn stop_ldk(app_state: Arc) { + } + } + ++ // Drop every signer-owning LDK object only after persistence and service ++ // shutdown are complete. Leaving these Arcs in AppState keeps persistent ++ // VLS/redb stores locked across a same-process wallet restart. ++ *app_state.get_unlocked_app_state().await = None; ++ app_state.set_attached_external_signer(None); ++ + // connect to the peer port so it can be released + let peer_port = app_state.static_state.ldk_peer_listening_port; + let sock_addr = SocketAddr::from(([127, 0, 0, 1], peer_port)); +@@ -6342,6 +6692,121 @@ mod tests { + assert!(!access.allows_peer(&peer)); + } + ++ #[test] ++ fn standard_inbound_channel_is_not_reclassified_when_virtual_support_is_enabled() { ++ let trusted_peer = test_peer_pubkey(7); ++ assert!(!is_trusted_virtual_inbound( ++ true, ++ &[trusted_peer], ++ &trusted_peer, ++ false, ++ )); ++ } ++ ++ #[test] ++ fn virtual_inbound_requires_both_scid_privacy_and_a_trusted_peer() { ++ let trusted_peer = test_peer_pubkey(8); ++ let other_peer = test_peer_pubkey(9); ++ assert!(is_trusted_virtual_inbound( ++ true, ++ &[trusted_peer], ++ &trusted_peer, ++ true, ++ )); ++ assert!(!is_trusted_virtual_inbound( ++ true, ++ &[trusted_peer], ++ &other_peer, ++ true, ++ )); ++ assert!(!is_trusted_virtual_inbound( ++ false, ++ &[trusted_peer], ++ &trusted_peer, ++ true, ++ )); ++ assert!(!is_trusted_virtual_inbound(true, &[], &trusted_peer, true,)); ++ } ++ ++ #[test] ++ fn payment_info_persists_rgb_identity_with_its_payment_status() { ++ let payment = PaymentInfo { ++ preimage: None, ++ secret: None, ++ status: HTLCStatus::Succeeded, ++ amt_msat: Some(3_000_000), ++ created_at: 100, ++ updated_at: 101, ++ payee_pubkey: test_peer_pubkey(10), ++ expires_at: Some(200), ++ claim_deadline_height: None, ++ invoice_type: Some(InvoiceType::AutoClaim), ++ description: None, ++ description_hash: None, ++ payment_idx: Some(1), ++ async_hash_index: None, ++ async_host_node_id: None, ++ fee_paid_msat: Some(250), ++ failure_code: None, ++ asset_id: Some(test_contract_id().to_string()), ++ asset_amount: Some(2_500_000), ++ carrier_msat: Some(3_000_000), ++ }; ++ ++ let decoded = PaymentInfo::read(&mut &payment.encode()[..]).expect("decode payment info"); ++ assert_eq!(decoded.asset_id, payment.asset_id); ++ assert_eq!(decoded.asset_amount, payment.asset_amount); ++ assert_eq!(decoded.carrier_msat, payment.carrier_msat); ++ assert_eq!(decoded.status, payment.status); ++ } ++ ++ #[test] ++ fn abandoned_virtual_session_does_not_block_reopen_for_same_peer() { ++ let peer = test_peer_pubkey(4); ++ let local = test_peer_pubkey(6); ++ let session = VirtualChannelSession { ++ channel_id: ChannelId::from_bytes([4; 32]), ++ former_temporary_channel_id: ChannelId::from_bytes([6; 32]), ++ peer_id: peer, ++ created_at: 1, ++ updated_at: 2, ++ status: VirtualChannelSessionStatus::Abandoned, ++ virtual_funding_txo: virtual_channel_synthetic_outpoint( ++ BitcoinNetwork::Regtest, ++ &local, ++ &peer, ++ ), ++ }; ++ ++ assert!(!virtual_channel_session_blocks_reopen(&session, &peer)); ++ } ++ ++ #[test] ++ fn non_terminal_virtual_session_blocks_reopen_for_same_peer() { ++ let peer = test_peer_pubkey(5); ++ let local = test_peer_pubkey(6); ++ ++ for status in [ ++ VirtualChannelSessionStatus::Active, ++ VirtualChannelSessionStatus::AbandonPending, ++ ] { ++ let session = VirtualChannelSession { ++ channel_id: ChannelId::from_bytes([5; 32]), ++ former_temporary_channel_id: ChannelId::from_bytes([6; 32]), ++ peer_id: peer, ++ created_at: 1, ++ updated_at: 2, ++ status, ++ virtual_funding_txo: virtual_channel_synthetic_outpoint( ++ BitcoinNetwork::Regtest, ++ &local, ++ &peer, ++ ), ++ }; ++ assert!(virtual_channel_session_blocks_reopen(&session, &peer)); ++ } ++ } ++ + fn build_kv_store() -> Arc { + let db_path = std::env::temp_dir().join(format!("rln-ldk-unit-{}", uuid::Uuid::new_v4())); + let connection_string = format!("sqlite:{}?mode=rwc", db_path.display()); +diff --git a/src/lib.rs b/src/lib.rs +index e22f71c..f474b05 100644 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -29,6 +29,7 @@ mod kv_store; + mod ldk; + mod node; + mod rgb; ++mod rgb_import; + mod routes; + mod runtime; + mod sdk; +diff --git a/src/main.rs b/src/main.rs +index 21df52c..67afa35 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -22,6 +22,7 @@ mod indexer; + mod kv_store; + mod ldk; + mod rgb; ++mod rgb_import; + mod routes; + mod runtime; + mod signer; +@@ -62,6 +63,7 @@ use crate::args::UserArgs; + use crate::auth::conditional_auth_middleware; + use crate::error::AppError; + use crate::ldk::stop_ldk; ++use crate::rgb_import::MAX_RGB_IMPORT_BODY_BYTES; + #[cfg(feature = "remote-signer")] + use crate::routes::init_external_signer; + use crate::routes::{ +@@ -69,13 +71,14 @@ use crate::routes::{ + async_order_outbound_invoice, backup, btc_balance, cancel_hodl_invoice, change_password, + check_indexer_url, check_proxy_endpoint, claim_hodl_invoice, close_channel, connect_peer, + create_utxos, decode_ln_invoice, decode_rgb_invoice, decode_swapstring, disconnect_peer, +- estimate_fee, fail_transfers, get_asset_media, get_channel_id, get_payment, get_swap, inflate, +- init, invoice_status, issue_asset_cfa, issue_asset_ifa, issue_asset_nia, issue_asset_uda, +- keysend, list_assets, list_channels, list_payments, list_peers, list_swaps, list_transactions, +- list_transfers, list_unspents, ln_invoice, lock, maker_execute, maker_init, network_info, +- node_info, open_channel, post_asset_media, refresh_transfers, restore, revoke_token, +- rgb_invoice, rotate_address, send_btc, send_onion_message, send_payment, send_rgb, shutdown, +- sign_message, sync, taker, unlock, ++ estimate_fee, fail_transfers, get_asset_media, get_channel_id, get_payment, get_swap, ++ import_rgb_contract, import_rgb_transfer_consignment, inflate, init, invoice_status, ++ issue_asset_cfa, issue_asset_ifa, issue_asset_nia, issue_asset_uda, keysend, list_assets, ++ list_channels, list_payments, list_peers, list_swaps, list_transactions, list_transfers, ++ list_unspents, ln_invoice, lock, maker_execute, maker_init, network_info, node_info, ++ open_channel, post_asset_media, refresh_transfers, restore, revoke_token, rgb_invoice, ++ rotate_address, send_btc, send_onion_message, send_payment, send_rgb, shutdown, sign_message, ++ sync, taker, unlock, + }; + #[cfg(feature = "vss")] + use crate::routes::{vss_backup, vss_backup_info, vss_clear_fence}; +@@ -158,6 +161,15 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr + .route("/getpayment", post(get_payment)) + .route("/getswap", post(get_swap)) + .route("/inflate", post(inflate)) ++ .route( ++ "/importrgbtransferconsignment", ++ post(import_rgb_transfer_consignment) ++ .layer(RequestBodyLimitLayer::new(MAX_RGB_IMPORT_BODY_BYTES)), ++ ) ++ .route( ++ "/importrgbcontract", ++ post(import_rgb_contract).layer(RequestBodyLimitLayer::new(MAX_RGB_IMPORT_BODY_BYTES)), ++ ) + .route("/init", post(init)) + .route("/invoicestatus", post(invoice_status)) + .route("/issueassetcfa", post(issue_asset_cfa)) +diff --git a/src/node.rs b/src/node.rs +index c67c2ef..0b1cd36 100644 +--- a/src/node.rs ++++ b/src/node.rs +@@ -1,4 +1,6 @@ +-use std::sync::Arc; ++use std::collections::HashMap; ++use std::path::{Path, PathBuf}; ++use std::sync::{Arc, Mutex, OnceLock, Weak}; + + use rgb_lib::BitcoinNetwork; + +@@ -8,7 +10,7 @@ use crate::ldk::stop_ldk; + use crate::utils::{start_daemon, AppState}; + + pub struct NodeConfig { +- pub storage_dir_path: std::path::PathBuf, ++ pub storage_dir_path: PathBuf, + pub daemon_listening_port: u16, + pub ldk_peer_listening_port: u16, + pub network: BitcoinNetwork, +@@ -28,15 +30,79 @@ pub struct NodeConfig { + pub remote_signer_listen_addr: Option, + } + ++#[derive(Debug)] ++struct NodeInstanceLease { ++ storage_dir_path: PathBuf, ++} ++ ++fn node_instance_registry() -> &'static Mutex>> { ++ static REGISTRY: OnceLock>>> = OnceLock::new(); ++ REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) ++} ++ ++impl NodeInstanceLease { ++ fn acquire(storage_dir_path: &Path) -> Result, AppError> { ++ std::fs::create_dir_all(storage_dir_path)?; ++ let canonical_path = std::fs::canonicalize(storage_dir_path)?; ++ let mut registry = node_instance_registry() ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ ++ if registry ++ .get(&canonical_path) ++ .and_then(Weak::upgrade) ++ .is_some() ++ { ++ return Err(AppError::NodeInstanceAlreadyActive( ++ canonical_path.display().to_string(), ++ )); ++ } ++ ++ let lease = Arc::new(Self { ++ storage_dir_path: canonical_path.clone(), ++ }); ++ registry.insert(canonical_path, Arc::downgrade(&lease)); ++ Ok(lease) ++ } ++ ++ fn for_app_state(state: &AppState) -> Option> { ++ let registry = node_instance_registry() ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ registry ++ .get(&state.static_state.storage_dir_path) ++ .and_then(Weak::upgrade) ++ } ++} ++ ++impl Drop for NodeInstanceLease { ++ fn drop(&mut self) { ++ let mut registry = node_instance_registry() ++ .lock() ++ .unwrap_or_else(|poisoned| poisoned.into_inner()); ++ let should_remove = registry ++ .get(&self.storage_dir_path) ++ .is_some_and(|registered| registered.strong_count() == 0); ++ if should_remove { ++ registry.remove(&self.storage_dir_path); ++ } ++ } ++} ++ + #[derive(Clone)] + pub struct NodeHandle { + state: Arc, ++ _instance_lease: Option>, + } + + impl NodeHandle { + #[cfg(feature = "uniffi")] + pub(crate) fn from_app_state(state: Arc) -> Self { +- Self { state } ++ let instance_lease = NodeInstanceLease::for_app_state(&state); ++ Self { ++ state, ++ _instance_lease: instance_lease, ++ } + } + + #[cfg(feature = "uniffi")] +@@ -44,7 +110,9 @@ impl NodeHandle { + self.state.clone() + } + +- pub async fn new(config: NodeConfig) -> Result { ++ pub async fn new(mut config: NodeConfig) -> Result { ++ let instance_lease = NodeInstanceLease::acquire(&config.storage_dir_path)?; ++ config.storage_dir_path = instance_lease.storage_dir_path.clone(); + let args = UserArgs { + storage_dir_path: config.storage_dir_path, + daemon_listening_port: config.daemon_listening_port, +@@ -63,7 +131,10 @@ impl NodeHandle { + config: Default::default(), + }; + let state = start_daemon(&args).await?; +- Ok(Self { state }) ++ Ok(Self { ++ state, ++ _instance_lease: Some(instance_lease), ++ }) + } + + pub async fn shutdown(&self) { +@@ -86,6 +157,24 @@ impl NodeHandle { + mod tests { + use super::*; + ++ #[test] ++ fn node_instance_lease_rejects_duplicate_storage_ownership() { ++ let tmp = tempfile::tempdir().expect("tempdir"); ++ let storage_dir = tmp.path().join("wallet"); ++ let first = NodeInstanceLease::acquire(&storage_dir).expect("first owner"); ++ ++ let duplicate = ++ NodeInstanceLease::acquire(&storage_dir).expect_err("duplicate owner must be rejected"); ++ assert!(matches!( ++ duplicate, ++ AppError::NodeInstanceAlreadyActive(path) ++ if path == first.storage_dir_path.display().to_string() ++ )); ++ ++ drop(first); ++ NodeInstanceLease::acquire(&storage_dir).expect("ownership released after final drop"); ++ } ++ + /// A `NodeConfig` embedder (a direct Rust consumer, not the uniffi FFI surface — see the `None` + /// left in `uniffi_api::handle_from_request`) must be able to configure the remote-signer daemon + /// address; without this field, `NodeHandle`-embedding callers could configure external-signer +diff --git a/src/rgb.rs b/src/rgb.rs +index da103cb..4f4824a 100644 +--- a/src/rgb.rs ++++ b/src/rgb.rs +@@ -19,21 +19,38 @@ use rgb_lib::{ + wallet::{ + rust_only::{check_proxy_url, ColoringInfo}, + AssetCFA, AssetFilter, AssetIFA, AssetNIA, AssetUDA, Assets, Balance, BtcBalance, +- IfaIssuanceType, Metadata, Online, OperationResult, Outpoint, ReceiveData, Recipient, +- RefreshFilter, RefreshResult, RgbWalletOpsOffline, RgbWalletOpsOnline, SendBeginResult, +- SinglesigKeys, SyncOptions, Transaction as RgbLibTransaction, Transfer, TransferKind, +- TransportEndpoint, Unspent, Wallet as RgbLibWallet, ++ IfaIssuanceType, Metadata, Online, OperationResult, Outpoint, PendingVanillaTx, ++ PsbtInspection, ReceiveData, Recipient, RefreshFilter, RefreshResult, RgbWalletOpsOffline, ++ RgbWalletOpsOnline, SendBeginResult, SinglesigKeys, SyncOptions, ++ Transaction as RgbLibTransaction, Transfer, TransferKind, TransportEndpoint, Unspent, ++ Wallet as RgbLibWallet, + }, +- AssetSchema, Assignment, BitcoinNetwork, ContractId, Error as RgbLibError, Fascia, RgbTransfer, +- RgbTransport, RgbTxid, UpdateRes, WitnessOrd, ++ AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, Error as RgbLibError, ++ Fascia, RgbContract, RgbTransfer, RgbTransport, RgbTxid, TransferStatus, UpdateRes, WitnessOrd, + }; + use std::collections::HashMap; ++use std::fmt::Display; ++use std::fs; + use std::path::{Path, PathBuf}; + use std::str::FromStr; + use std::sync::{Arc, Mutex, MutexGuard}; + + use crate::{error::APIError, utils::UnlockedAppState}; + ++fn rgb_utxo_isolation_error( ++ context: &str, ++ cause: impl Display, ++ rollback: Result<(), RgbLibError>, ++) -> RgbLibError { ++ let rollback_context = match rollback { ++ Ok(()) => "reserved transaction rolled back".to_string(), ++ Err(error) => format!("reserved transaction rollback also failed: {error}"), ++ }; ++ RgbLibError::Internal { ++ details: format!("{context}: {cause}; {rollback_context}"), ++ } ++} ++ + /// When `sign_rgb_psbt` fails, internal mode falls back to the local RGB wallet; external mode does not. + fn resolve_rgb_psbt_signer_failure( + external_signer_mode: bool, +@@ -178,6 +195,18 @@ impl UnlockedAppState { + .create_utxos_begin(up_to, num, size, fee_rate, skip_sync) + } + ++ pub(crate) fn rgb_prepare_isolated_create_utxos( ++ &self, ++ up_to: bool, ++ num: u8, ++ size: u32, ++ fee_rate: u64, ++ skip_sync: bool, ++ ) -> Result { ++ self.rgb_wallet_wrapper ++ .prepare_isolated_create_utxos(up_to, num, size, fee_rate, skip_sync) ++ } ++ + pub(crate) fn rgb_create_utxos_end( + &self, + signed_psbt: String, +@@ -197,6 +226,15 @@ impl UnlockedAppState { + .fail_transfers(batch_transfer_idx, no_asset_only, skip_sync) + } + ++ pub(crate) fn rgb_cancel_send_plan( ++ &self, ++ plan_id: String, ++ batch_transfer_idx: i32, ++ ) -> Result { ++ self.rgb_wallet_wrapper ++ .cancel_send_plan(plan_id, batch_transfer_idx) ++ } ++ + pub(crate) fn rgb_get_address(&self) -> Result { + self.rgb_wallet_wrapper.get_address() + } +@@ -219,6 +257,32 @@ impl UnlockedAppState { + self.rgb_wallet_wrapper.get_asset_metadata(contract_id) + } + ++ pub(crate) fn rgb_import_transfer_consignment( ++ &self, ++ consignment: RgbTransfer, ++ offchain_txid: String, ++ ) -> Result<(Metadata, bool), RgbLibError> { ++ let contract_id = consignment.contract_id(); ++ let already_imported = match self.rgb_get_asset_metadata(contract_id) { ++ Ok(_) => true, ++ Err(RgbLibError::AssetNotFound { .. }) => false, ++ Err(error) => return Err(error), ++ }; ++ ++ self.rgb_wallet_wrapper ++ .save_new_asset(consignment, offchain_txid)?; ++ let metadata = self.rgb_get_asset_metadata(contract_id)?; ++ Ok((metadata, already_imported)) ++ } ++ ++ pub(crate) fn rgb_import_asset_contract( ++ &self, ++ contract: RgbContract, ++ ) -> Result<(Metadata, bool), RgbLibError> { ++ let imported = self.rgb_wallet_wrapper.import_asset_contract(contract)?; ++ Ok((imported.metadata, imported.already_imported)) ++ } ++ + pub(crate) fn rgb_get_btc_balance(&self, skip_sync: bool) -> Result { + self.rgb_wallet_wrapper.get_btc_balance(skip_sync) + } +@@ -483,6 +547,30 @@ impl UnlockedAppState { + .send_btc_begin(address, amount, fee_rate, false) + } + ++ pub(crate) fn rgb_prepare_send_btc( ++ &self, ++ address: String, ++ amount: u64, ++ fee_rate: u64, ++ skip_sync: bool, ++ ) -> Result { ++ self.rgb_wallet_wrapper ++ .prepare_send_btc(address, amount, fee_rate, skip_sync) ++ } ++ ++ pub(crate) fn rgb_inspect_psbt( ++ &self, ++ unsigned_psbt: String, ++ ) -> Result { ++ self.rgb_wallet_wrapper.inspect_psbt(unsigned_psbt) ++ } ++ ++ pub(crate) fn rgb_list_pending_vanilla_txs( ++ &self, ++ ) -> Result, RgbLibError> { ++ self.rgb_wallet_wrapper.list_pending_vanilla_txs() ++ } ++ + pub(crate) fn rgb_send_btc_end(&self, signed_psbt: String) -> Result { + self.rgb_wallet_wrapper.send_btc_end(signed_psbt) + } +@@ -669,6 +757,98 @@ impl RgbLibWalletWrapper { + ) + } + ++ pub(crate) fn prepare_isolated_create_utxos( ++ &self, ++ up_to: bool, ++ num: u8, ++ size: u32, ++ fee_rate: u64, ++ skip_sync: bool, ++ ) -> Result { ++ let mut wallet = self.get_rgb_wallet(); ++ let reuse_addresses = wallet.get_wallet_data().reuse_addresses; ++ let setup_address = if reuse_addresses { ++ Some(wallet.rotate_address(KeychainKind::External)?) ++ } else { ++ None ++ }; ++ ++ let unsigned_psbt = wallet.create_utxos_begin( ++ self.online, ++ up_to, ++ Some(num), ++ Some(size), ++ fee_rate, ++ skip_sync, ++ false, ++ )?; ++ ++ let Some(setup_address) = setup_address else { ++ return Ok(unsigned_psbt); ++ }; ++ ++ let psbt = Psbt::from_str(&unsigned_psbt).map_err(|error| RgbLibError::Internal { ++ details: format!("RGB UTXO setup isolation produced an invalid reserved PSBT: {error}"), ++ })?; ++ let plan_txid = psbt.unsigned_tx.compute_txid().to_string(); ++ let setup_script = match Address::from_str(&setup_address) { ++ Ok(address) => address.assume_checked().script_pubkey(), ++ Err(error) => { ++ let rollback = wallet.abort_pending_vanilla_tx(plan_txid.clone()); ++ return Err(rgb_utxo_isolation_error( ++ "RGB UTXO setup isolation produced an invalid setup address", ++ error, ++ rollback, ++ )); ++ } ++ }; ++ let isolated_output_count = psbt ++ .unsigned_tx ++ .output ++ .iter() ++ .filter(|output| { ++ output.script_pubkey == setup_script && output.value.to_sat() == size as u64 ++ }) ++ .count(); ++ if isolated_output_count == 0 { ++ let rollback = wallet.abort_pending_vanilla_tx(plan_txid.clone()); ++ return Err(rgb_utxo_isolation_error( ++ "RGB UTXO setup isolation did not place an allocation output on the isolated \ ++ colored address", ++ "no matching transaction output", ++ rollback, ++ )); ++ } ++ ++ let next_receive_address = match wallet.rotate_address(KeychainKind::External) { ++ Ok(address) => address, ++ Err(error) => { ++ let rollback = wallet.abort_pending_vanilla_tx(plan_txid.clone()); ++ return Err(rgb_utxo_isolation_error( ++ "RGB UTXO setup isolation could not advance the future witness receive address", ++ error, ++ rollback, ++ )); ++ } ++ }; ++ if next_receive_address == setup_address { ++ let rollback = wallet.abort_pending_vanilla_tx(plan_txid); ++ return Err(rgb_utxo_isolation_error( ++ "RGB UTXO setup isolation reused the setup address for future witness receives", ++ "address rotation returned the same address", ++ rollback, ++ )); ++ } ++ ++ tracing::info!( ++ setup_address, ++ next_receive_address, ++ isolated_output_count, ++ "prepared RGB UTXO setup on an isolated colored address" ++ ); ++ Ok(unsigned_psbt) ++ } ++ + pub(crate) fn create_utxos_end( + &self, + signed_psbt: String, +@@ -692,12 +872,109 @@ impl RgbLibWalletWrapper { + ) + } + ++ pub(crate) fn cancel_send_plan( ++ &self, ++ plan_id: String, ++ batch_transfer_idx: i32, ++ ) -> Result { ++ let mut wallet = self.get_rgb_wallet(); ++ let transfers = wallet.list_transfers(AssetFilter::AnyOrNone, Some(plan_id.clone()))?; ++ if transfers.is_empty() { ++ return Ok(false); ++ } ++ if transfers.iter().any(|transfer| { ++ transfer.batch_transfer_idx != batch_transfer_idx ++ || transfer.status != TransferStatus::Initiated ++ }) { ++ return Err(RgbLibError::Internal { ++ details: format!( ++ "RGB send plan {plan_id} does not match initiated batch {batch_transfer_idx}" ++ ), ++ }); ++ } ++ ++ let wallet_dir = wallet.get_wallet_dir(); ++ let mut transfer_dirs = transfers ++ .iter() ++ .map(|transfer| { ++ let psbt_path = ++ transfer ++ .psbt_path ++ .as_ref() ++ .ok_or_else(|| RgbLibError::Internal { ++ details: format!( ++ "RGB send plan {plan_id} is missing its persisted PSBT path" ++ ), ++ })?; ++ let transfer_dir = PathBuf::from(psbt_path) ++ .parent() ++ .map(Path::to_path_buf) ++ .ok_or_else(|| RgbLibError::Internal { ++ details: format!( ++ "RGB send plan {plan_id} has an invalid persisted PSBT path" ++ ), ++ })?; ++ if transfer_dir.file_name().and_then(|name| name.to_str()) != Some(plan_id.as_str()) ++ || !transfer_dir.starts_with(&wallet_dir) ++ { ++ return Err(RgbLibError::Internal { ++ details: format!( ++ "RGB send plan {plan_id} points outside its wallet transfer directory" ++ ), ++ }); ++ } ++ Ok(transfer_dir) ++ }) ++ .collect::, RgbLibError>>()?; ++ transfer_dirs.sort(); ++ transfer_dirs.dedup(); ++ ++ let failed = wallet.fail_transfers(self.online, Some(batch_transfer_idx), false, true)?; ++ if !failed { ++ return Err(RgbLibError::Internal { ++ details: format!( ++ "RGB send plan {plan_id} could not release batch {batch_transfer_idx}" ++ ), ++ }); ++ } ++ ++ for transfer_dir in transfer_dirs { ++ if transfer_dir.exists() { ++ fs::remove_dir_all(&transfer_dir).map_err(|error| RgbLibError::IO { ++ details: format!( ++ "failed to remove cancelled RGB send plan directory {}: {error}", ++ transfer_dir.display() ++ ), ++ })?; ++ } ++ } ++ ++ if !wallet.delete_transfers(Some(batch_transfer_idx), false)? { ++ return Err(RgbLibError::Internal { ++ details: format!( ++ "RGB send plan {plan_id} released batch {batch_transfer_idx} but did not delete it" ++ ), ++ }); ++ } ++ Ok(true) ++ } ++ + pub(crate) fn get_address(&self) -> Result { + self.get_rgb_wallet().get_address() + } + + pub(crate) fn rotate_address(&self) -> Result { +- self.get_rgb_wallet().rotate_address(KeychainKind::Internal) ++ let mut wallet = self.get_rgb_wallet(); ++ let rotated = wallet.rotate_address(KeychainKind::Internal)?; ++ let revealed = wallet.get_address()?; ++ if revealed != rotated { ++ return Err(RgbLibError::Internal { ++ details: format!( ++ "rotated address {rotated} does not match the revealed wallet address {revealed}" ++ ), ++ }); ++ } ++ Ok(revealed) + } + + pub(crate) fn get_asset_balance( +@@ -927,6 +1204,13 @@ impl RgbLibWalletWrapper { + .save_new_asset(consignment, offchain_txid) + } + ++ pub(crate) fn import_asset_contract( ++ &self, ++ contract: RgbContract, ++ ) -> Result { ++ self.get_rgb_wallet().import_asset_contract(contract) ++ } ++ + pub(crate) fn send( + &self, + recipient_map: HashMap>, +@@ -1000,6 +1284,35 @@ impl RgbLibWalletWrapper { + ) + } + ++ pub(crate) fn prepare_send_btc( ++ &self, ++ address: String, ++ amount: u64, ++ fee_rate: u64, ++ skip_sync: bool, ++ ) -> Result { ++ self.get_rgb_wallet().send_btc_begin( ++ self.online, ++ address, ++ amount, ++ fee_rate, ++ skip_sync, ++ false, ++ None, ++ ) ++ } ++ ++ pub(crate) fn inspect_psbt( ++ &self, ++ unsigned_psbt: String, ++ ) -> Result { ++ self.get_rgb_wallet().inspect_psbt(unsigned_psbt) ++ } ++ ++ pub(crate) fn list_pending_vanilla_txs(&self) -> Result, RgbLibError> { ++ self.get_rgb_wallet().list_pending_vanilla_txs() ++ } ++ + pub(crate) fn send_btc_end(&self, signed_psbt: String) -> Result { + self.get_rgb_wallet().send_btc_end(self.online, signed_psbt) + } +diff --git a/src/rgb_import.rs b/src/rgb_import.rs +new file mode 100644 +index 0000000..93ba5f5 +--- /dev/null ++++ b/src/rgb_import.rs +@@ -0,0 +1,247 @@ ++use crate::error::APIError; ++use crate::utils::AppState; ++use base64::{engine::general_purpose, Engine as _}; ++use rgb_lib::wallet::Metadata as RgbLibMetadata; ++use rgb_lib::{ ++ ConsignmentExt, ContractId, Error as RgbLibError, FileContent, RgbContract, RgbTransfer, ++ RgbTxid, ++}; ++use std::str::FromStr; ++use std::sync::Arc; ++ ++pub(crate) const MAX_RGB_IMPORT_BASE64_CHARACTERS: usize = 16 * 1024 * 1024; ++pub(crate) const MAX_RGB_IMPORT_BODY_BYTES: usize = MAX_RGB_IMPORT_BASE64_CHARACTERS + 4 * 1024; ++ ++pub(crate) struct ImportRgbTransferConsignmentRequestData { ++ pub(crate) consignment_base64: String, ++ pub(crate) offchain_txid: String, ++ pub(crate) expected_asset_id: Option, ++} ++ ++pub(crate) struct ImportRgbContractRequestData { ++ pub(crate) contract_base64: String, ++ pub(crate) expected_asset_id: String, ++} ++ ++pub(crate) struct ImportRgbData { ++ pub(crate) asset_id: String, ++ pub(crate) already_imported: bool, ++ pub(crate) metadata: RgbLibMetadata, ++} ++ ++#[derive(Clone, Copy)] ++enum RgbPayloadKind { ++ Contract, ++ TransferConsignment, ++} ++ ++impl RgbPayloadKind { ++ fn invalid(self, details: String) -> APIError { ++ match self { ++ Self::Contract => APIError::InvalidRgbContract(details), ++ Self::TransferConsignment => APIError::InvalidRgbConsignment(details), ++ } ++ } ++ ++ fn label(self) -> &'static str { ++ match self { ++ Self::Contract => "contract", ++ Self::TransferConsignment => "transfer consignment", ++ } ++ } ++} ++ ++fn decode_rgb_base64(value: String, kind: RgbPayloadKind) -> Result, APIError> { ++ if value.is_empty() || value.len() > MAX_RGB_IMPORT_BASE64_CHARACTERS { ++ return Err(kind.invalid(format!("{} payload size is invalid", kind.label()))); ++ } ++ general_purpose::STANDARD ++ .decode(value) ++ .map_err(|error| kind.invalid(format!("invalid base64: {error}"))) ++} ++ ++fn validate_expected_asset_id( ++ expected_asset_id: &str, ++ actual_contract_id: &ContractId, ++ kind: RgbPayloadKind, ++) -> Result<(), APIError> { ++ let expected_contract_id = ContractId::from_str(expected_asset_id) ++ .map_err(|_| APIError::InvalidAssetID(expected_asset_id.to_string()))?; ++ if &expected_contract_id != actual_contract_id { ++ return Err(kind.invalid(format!( ++ "expected asset id {expected_asset_id}, got {actual_contract_id}" ++ ))); ++ } ++ Ok(()) ++} ++ ++async fn parse_rgb_payload(operation: &'static str, parse: F) -> Result ++where ++ T: Send + 'static, ++ F: FnOnce() -> Result + Send + 'static, ++{ ++ tokio::task::spawn_blocking(parse) ++ .await ++ .map_err(|error| APIError::Unexpected(format!("{operation} parse task failed: {error}")))? ++} ++ ++async fn run_rgb_import( ++ state: Arc, ++ operation: &'static str, ++ import: F, ++) -> Result ++where ++ F: FnOnce(Arc) -> Result ++ + Send ++ + 'static, ++{ ++ // The outer task deliberately owns the lifecycle guard. If an HTTP or FFI caller stops ++ // waiting, a mutation that has already started still runs to completion before shutdown can ++ // replace the unlocked state. ++ let task = tokio::spawn(async move { ++ if *state.get_changing_state() { ++ return Err(APIError::ChangingState); ++ } ++ let unlocked_state_guard = state.get_unlocked_app_state().await; ++ let unlocked_state = unlocked_state_guard ++ .as_ref() ++ .cloned() ++ .ok_or(APIError::LockedNode)?; ++ let result = tokio::task::spawn_blocking(move || import(unlocked_state)) ++ .await ++ .map_err(|error| { ++ APIError::Unexpected(format!("{operation} import task failed: {error}")) ++ })?; ++ drop(unlocked_state_guard); ++ result ++ }); ++ ++ task.await ++ .map_err(|error| APIError::Unexpected(format!("{operation} task failed: {error}")))? ++} ++ ++fn map_contract_import_error(error: RgbLibError) -> APIError { ++ match error { ++ RgbLibError::InvalidConsignment => { ++ APIError::InvalidRgbContract("contract validation failed".to_string()) ++ } ++ other => APIError::from(other), ++ } ++} ++ ++fn map_transfer_import_error(error: RgbLibError) -> APIError { ++ match error { ++ RgbLibError::InvalidConsignment => { ++ APIError::InvalidRgbConsignment("transfer validation failed".to_string()) ++ } ++ RgbLibError::InvalidTxid => { ++ APIError::InvalidRgbConsignment("off-chain transaction ID is invalid".to_string()) ++ } ++ other => APIError::from(other), ++ } ++} ++ ++/// Register metadata from a transfer the native RGB receive path has already accepted. ++/// ++/// This does not accept asset allocations or replace the normal receive protocol. The transfer ++/// payload and off-chain transaction ID are still validated on every call; duplicate metadata ++/// registration is idempotent only when the RGB stock remains consistent with the database. ++pub(crate) async fn import_rgb_transfer_consignment( ++ state: Arc, ++ request: ImportRgbTransferConsignmentRequestData, ++) -> Result { ++ RgbTxid::from_str(&request.offchain_txid).map_err(|_| { ++ APIError::InvalidRgbConsignment("off-chain transaction ID is invalid".to_string()) ++ })?; ++ let expected_asset_id = request.expected_asset_id; ++ let (consignment, contract_id) = parse_rgb_payload("transfer consignment", move || { ++ let bytes = decode_rgb_base64( ++ request.consignment_base64, ++ RgbPayloadKind::TransferConsignment, ++ )?; ++ let consignment = RgbTransfer::load(&bytes[..]) ++ .map_err(|error| APIError::InvalidRgbConsignment(error.to_string()))?; ++ let contract_id = consignment.contract_id(); ++ if let Some(expected_asset_id) = expected_asset_id.as_deref() { ++ validate_expected_asset_id( ++ expected_asset_id, ++ &contract_id, ++ RgbPayloadKind::TransferConsignment, ++ )?; ++ } ++ Ok((consignment, contract_id)) ++ }) ++ .await?; ++ let offchain_txid = request.offchain_txid; ++ run_rgb_import(state, "transfer consignment", move |unlocked_state| { ++ let (metadata, already_imported) = unlocked_state ++ .rgb_import_transfer_consignment(consignment, offchain_txid) ++ .map_err(map_transfer_import_error)?; ++ Ok(ImportRgbData { ++ asset_id: contract_id.to_string(), ++ already_imported, ++ metadata, ++ }) ++ }) ++ .await ++} ++ ++/// Validate and register public RGB contract metadata without importing allocations. ++pub(crate) async fn import_rgb_contract( ++ state: Arc, ++ request: ImportRgbContractRequestData, ++) -> Result { ++ let expected_asset_id = request.expected_asset_id; ++ let (contract, contract_id) = parse_rgb_payload("contract", move || { ++ let bytes = decode_rgb_base64(request.contract_base64, RgbPayloadKind::Contract)?; ++ let contract = RgbContract::load(&bytes[..]) ++ .map_err(|error| APIError::InvalidRgbContract(error.to_string()))?; ++ let contract_id = contract.contract_id(); ++ validate_expected_asset_id(&expected_asset_id, &contract_id, RgbPayloadKind::Contract)?; ++ Ok((contract, contract_id)) ++ }) ++ .await?; ++ ++ run_rgb_import(state, "contract", move |unlocked_state| { ++ let (metadata, already_imported) = unlocked_state ++ .rgb_import_asset_contract(contract) ++ .map_err(map_contract_import_error)?; ++ Ok(ImportRgbData { ++ asset_id: contract_id.to_string(), ++ already_imported, ++ metadata, ++ }) ++ }) ++ .await ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn base64_validation_is_bounded_and_payload_specific() { ++ assert!(matches!( ++ decode_rgb_base64(String::new(), RgbPayloadKind::Contract), ++ Err(APIError::InvalidRgbContract(_)) ++ )); ++ assert!(matches!( ++ decode_rgb_base64( ++ "not base64".to_string(), ++ RgbPayloadKind::TransferConsignment ++ ), ++ Err(APIError::InvalidRgbConsignment(_)) ++ )); ++ assert!(matches!( ++ decode_rgb_base64( ++ "A".repeat(MAX_RGB_IMPORT_BASE64_CHARACTERS + 1), ++ RgbPayloadKind::Contract, ++ ), ++ Err(APIError::InvalidRgbContract(_)) ++ )); ++ assert_eq!( ++ decode_rgb_base64("YWJj".to_string(), RgbPayloadKind::Contract).unwrap(), ++ b"abc" ++ ); ++ } ++} +diff --git a/src/routes.rs b/src/routes.rs +index cfc3496..b5fb40b 100644 +--- a/src/routes.rs ++++ b/src/routes.rs +@@ -1,4 +1,7 @@ + use crate::ldk::write_rgb_payment_info_file; ++use crate::rgb_import::{ ++ self, ImportRgbContractRequestData, ImportRgbTransferConsignmentRequestData, ++}; + use amplify::{map, s}; + use axum::{ + extract::{Multipart, State}, +@@ -27,7 +30,9 @@ use lightning::{ + types::payment::{PaymentHash, PaymentPreimage}, + }; + use lightning::{ +- ln::channelmanager::{PaymentId, RecipientOnionFields, Retry}, ++ ln::channelmanager::{ ++ Bolt11PaymentError, PaymentId, RecipientOnionFields, Retry, RetryableSendFailure, ++ }, + routing::{ + gossip::NodeId, + router::{PaymentParameters, RouteParameters}, +@@ -49,12 +54,12 @@ use rgb_lib::{ + AssetCFA as RgbLibAssetCFA, AssetIFA as RgbLibAssetIFA, AssetNIA as RgbLibAssetNIA, + AssetUDA as RgbLibAssetUDA, Balance as RgbLibBalance, EmbeddedMedia as RgbLibEmbeddedMedia, + IfaIssuanceType as RgbLibIfaIssuanceType, Invoice as RgbLibInvoice, Media as RgbLibMedia, +- Outpoint as RgbLibOutpoint, ProofOfReserves as RgbLibProofOfReserves, +- Recipient as RgbLibRecipient, RecipientInfo, RecipientType as RgbLibRecipientType, +- RefreshFilter as RgbLibRefreshFilter, RefreshTransferStatus as RgbLibRefreshTransferStatus, +- SyncKeychain as RgbLibSyncKeychain, SyncOptions as RgbLibSyncOptions, +- SyncStrategy as RgbLibSyncStrategy, Token as RgbLibToken, TokenLight as RgbLibTokenLight, +- WitnessData as RgbLibWitnessData, ++ Metadata as RgbLibMetadata, Outpoint as RgbLibOutpoint, ++ ProofOfReserves as RgbLibProofOfReserves, Recipient as RgbLibRecipient, RecipientInfo, ++ RecipientType as RgbLibRecipientType, RefreshFilter as RgbLibRefreshFilter, ++ RefreshTransferStatus as RgbLibRefreshTransferStatus, SyncKeychain as RgbLibSyncKeychain, ++ SyncOptions as RgbLibSyncOptions, SyncStrategy as RgbLibSyncStrategy, Token as RgbLibToken, ++ TokenLight as RgbLibTokenLight, WitnessData as RgbLibWitnessData, + }, + AssetSchema as RgbLibAssetSchema, Assignment as RgbLibAssignment, + BitcoinNetwork as RgbLibNetwork, ContractId, RgbTransport, +@@ -85,8 +90,8 @@ use crate::core_types::async_order::{ + AsyncOrderOutboundInvoiceResponse, + }; + use crate::ldk::{ +- clear_rgb_payment_pending, peer_has_live_channel, start_ldk, stop_ldk, LdkBackgroundServices, +- VirtualChannelSessionStatus, ++ clear_rgb_payment_pending, peer_has_live_channel, reconcile_orphaned_virtual_session, ++ start_ldk, stop_ldk, LdkBackgroundServices, VirtualChannelSessionStatus, + }; + #[cfg(feature = "vss")] + use crate::ldk::{derive_vss_identity, derive_vss_identity_from_key_source}; +@@ -243,6 +248,33 @@ pub(crate) struct AssetMetadataResponse { + pub(crate) linked_to_asset_id: Option, + } + ++#[derive(Deserialize, Serialize)] ++pub(crate) struct ImportRgbTransferConsignmentRequest { ++ pub(crate) consignment_base64: String, ++ pub(crate) offchain_txid: String, ++ pub(crate) expected_asset_id: Option, ++} ++ ++#[derive(Deserialize, Serialize)] ++pub(crate) struct ImportRgbTransferConsignmentResponse { ++ pub(crate) asset_id: String, ++ pub(crate) already_imported: bool, ++ pub(crate) metadata: AssetMetadataResponse, ++} ++ ++#[derive(Deserialize, Serialize)] ++pub(crate) struct ImportRgbContractRequest { ++ pub(crate) contract_base64: String, ++ pub(crate) expected_asset_id: String, ++} ++ ++#[derive(Deserialize, Serialize)] ++pub(crate) struct ImportRgbContractResponse { ++ pub(crate) asset_id: String, ++ pub(crate) already_imported: bool, ++ pub(crate) metadata: AssetMetadataResponse, ++} ++ + #[derive(Deserialize, Serialize)] + pub(crate) struct AssetNIA { + pub(crate) asset_id: String, +@@ -1060,15 +1092,19 @@ pub(crate) struct Payment { + pub(crate) amt_msat: Option, + pub(crate) asset_amount: Option, + pub(crate) asset_id: Option, ++ pub(crate) carrier_msat: Option, + pub(crate) payment_hash: String, + pub(crate) payment_type: PaymentType, + pub(crate) status: HTLCStatus, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, ++ pub(crate) expires_at: Option, + pub(crate) payee_pubkey: String, + pub(crate) preimage: Option, + pub(crate) description: Option, + pub(crate) description_hash: Option, ++ pub(crate) fee_paid_msat: Option, ++ pub(crate) failure_code: Option, + } + + fn payment_type_from_invoice(invoice_type: Option) -> PaymentType { +@@ -1078,6 +1114,41 @@ fn payment_type_from_invoice(invoice_type: Option) -> PaymentType { + } + } + ++fn payment_rgb_binding( ++ unlocked_state: &UnlockedAppState, ++ payment_hash: &PaymentHash, ++ inbound: bool, ++ payment_info: &PaymentInfo, ++) -> Result<(Option, Option, Option), APIError> { ++ if payment_info.asset_id.is_none() && payment_info.asset_amount.is_some() { ++ return Err(APIError::Unexpected(format!( ++ "payment {} has an RGB amount without an RGB contract ID", ++ hex_str(&payment_hash.0), ++ ))); ++ } ++ if let Some(asset_id) = payment_info.asset_id.as_ref() { ++ return Ok(( ++ payment_info.asset_amount, ++ Some(asset_id.clone()), ++ payment_info.carrier_msat.or(payment_info.amt_msat), ++ )); ++ } ++ Ok( ++ match unlocked_state ++ .kv_store ++ .read_rgb_payment_info(payment_hash, inbound) ++ .ok() ++ { ++ Some(info) => ( ++ Some(info.amount), ++ Some(info.contract_id.to_string()), ++ payment_info.amt_msat, ++ ), ++ None => (None, None, None), ++ }, ++ ) ++} ++ + #[derive(Clone, Deserialize, Serialize)] + pub(crate) struct Peer { + pub(crate) pubkey: String, +@@ -1243,6 +1314,23 @@ pub(crate) struct SendPaymentResponse { + pub(crate) payment_hash: Option, + pub(crate) payment_secret: Option, + pub(crate) status: HTLCStatus, ++ pub(crate) failure_code: Option, ++} ++ ++fn retryable_send_failure_code(reason: &RetryableSendFailure) -> &'static str { ++ match reason { ++ RetryableSendFailure::PaymentExpired => "PAYMENT_EXPIRED", ++ RetryableSendFailure::RouteNotFound => "ROUTE_NOT_FOUND", ++ RetryableSendFailure::DuplicatePayment => "DUPLICATE_PAYMENT", ++ RetryableSendFailure::OnionPacketSizeExceeded => "ONION_PACKET_TOO_LARGE", ++ } ++} ++ ++fn bolt11_payment_failure_code(reason: &Bolt11PaymentError) -> &'static str { ++ match reason { ++ Bolt11PaymentError::InvalidAmount => "INVALID_PAYMENT_AMOUNT", ++ Bolt11PaymentError::SendingFailed(reason) => retryable_send_failure_code(reason), ++ } + } + + #[derive(Deserialize, Serialize)] +@@ -1867,7 +1955,11 @@ pub(crate) async fn asset_metadata( + .unwrap() + .rgb_get_asset_metadata(contract_id)?; + +- Ok(Json(AssetMetadataResponse { ++ Ok(Json(asset_metadata_response_from_metadata(metadata))) ++} ++ ++fn asset_metadata_response_from_metadata(metadata: RgbLibMetadata) -> AssetMetadataResponse { ++ AssetMetadataResponse { + asset_schema: metadata.asset_schema.into(), + initial_supply: metadata.initial_supply, + max_supply: metadata.max_supply, +@@ -1881,6 +1973,49 @@ pub(crate) async fn asset_metadata( + unspent_link_right_outpoint: metadata.unspent_link_right_outpoint, + linked_from_asset_id: metadata.linked_from_asset_id, + linked_to_asset_id: metadata.linked_to_asset_id, ++ } ++} ++ ++pub(crate) async fn import_rgb_transfer_consignment( ++ State(state): State>, ++ WithRejection(Json(payload), _): WithRejection< ++ Json, ++ APIError, ++ >, ++) -> Result, APIError> { ++ let imported = rgb_import::import_rgb_transfer_consignment( ++ state, ++ ImportRgbTransferConsignmentRequestData { ++ consignment_base64: payload.consignment_base64, ++ offchain_txid: payload.offchain_txid, ++ expected_asset_id: payload.expected_asset_id, ++ }, ++ ) ++ .await?; ++ ++ Ok(Json(ImportRgbTransferConsignmentResponse { ++ asset_id: imported.asset_id, ++ already_imported: imported.already_imported, ++ metadata: asset_metadata_response_from_metadata(imported.metadata), ++ })) ++} ++ ++pub(crate) async fn import_rgb_contract( ++ State(state): State>, ++ WithRejection(Json(payload), _): WithRejection, APIError>, ++) -> Result, APIError> { ++ let imported = rgb_import::import_rgb_contract( ++ state, ++ ImportRgbContractRequestData { ++ contract_base64: payload.contract_base64, ++ expected_asset_id: payload.expected_asset_id, ++ }, ++ ) ++ .await?; ++ Ok(Json(ImportRgbContractResponse { ++ asset_id: imported.asset_id, ++ already_imported: imported.already_imported, ++ metadata: asset_metadata_response_from_metadata(imported.metadata), + })) + } + +@@ -1953,8 +2088,11 @@ pub(crate) async fn cancel_hodl_invoice( + _ => return Err(APIError::InvoiceNotClaimable), + } + +- unlocked_state +- .fail_htlc_backwards_and_update_inbound_payment(payment_hash, HTLCStatus::Cancelled); ++ unlocked_state.fail_htlc_backwards_and_update_inbound_payment( ++ payment_hash, ++ HTLCStatus::Cancelled, ++ "INVOICE_CANCELLED", ++ ); + + Ok(Json(EmptyResponse {})) + }) +@@ -2077,8 +2215,15 @@ pub(crate) async fn claim_hodl_invoice( + }; + + if let Some(terminal_error) = terminal_error { +- unlocked_state +- .fail_htlc_backwards_and_update_inbound_payment(payment_hash, HTLCStatus::Failed); ++ let failure_code = match &terminal_error { ++ APIError::ClaimDeadlineExceeded => "INVOICE_CLAIM_DEADLINE_EXCEEDED", ++ _ => "INVOICE_EXPIRED", ++ }; ++ unlocked_state.fail_htlc_backwards_and_update_inbound_payment( ++ payment_hash, ++ HTLCStatus::Failed, ++ failure_code, ++ ); + return Err(terminal_error); + } + +@@ -2149,10 +2294,25 @@ pub(crate) async fn close_channel( + } else { + if let Some(session) = virtual_session.as_ref() { + if !matches!(session.status, VirtualChannelSessionStatus::Abandoned) { +- unlocked_state.virtual_channel_session_update_status( +- session, +- VirtualChannelSessionStatus::Abandoned, +- ); ++ reconcile_orphaned_virtual_session( ++ &requested_cid, ++ Arc::clone(unlocked_state), ++ ) ++ .await; ++ if unlocked_state ++ .virtual_channel_session_get(&requested_cid) ++ .is_some_and(|current| { ++ !matches!( ++ current.status, ++ VirtualChannelSessionStatus::Abandoned ++ ) ++ }) ++ { ++ return Err(APIError::CannotCloseChannel( ++ "virtual funding cleanup failed; the session remains blocked" ++ .to_string(), ++ )); ++ } + } + return Ok(Json(EmptyResponse {})); + } +@@ -2551,27 +2711,27 @@ pub(crate) async fn get_payment( + if payment_hash == &requested_ph + && payment_type_from_invoice(payment_info.invoice_type) == payload.payment_type + { +- let (asset_amount, asset_id) = unlocked_state +- .kv_store +- .read_rgb_payment_info(payment_hash, true) +- .ok() +- .map(|info| (Some(info.amount), Some(info.contract_id.to_string()))) +- .unwrap_or((None, None)); ++ let (asset_amount, asset_id, carrier_msat) = ++ payment_rgb_binding(unlocked_state, payment_hash, true, payment_info)?; + + return Ok(Json(GetPaymentResponse { + payment: Payment { + amt_msat: payment_info.amt_msat, + asset_amount, + asset_id, ++ carrier_msat, + payment_hash: hex_str(&payment_hash.0), + payment_type: payment_type_from_invoice(payment_info.invoice_type), + status: payment_info.status, + created_at: payment_info.created_at, + updated_at: payment_info.updated_at, ++ expires_at: payment_info.expires_at, + payee_pubkey: payment_info.payee_pubkey.to_string(), + preimage: payment_info.preimage.map(|p| hex_str(&p.0)), + description: payment_info.description.clone(), + description_hash: payment_info.description_hash.map(|h| hex_str(&h)), ++ fee_paid_msat: payment_info.fee_paid_msat, ++ failure_code: payment_info.failure_code.clone(), + }, + })); + } +@@ -2582,27 +2742,27 @@ pub(crate) async fn get_payment( + for (payment_id, payment_info) in &outbound_payments { + let payment_hash = &PaymentHash(payment_id.0); + if payment_hash == &requested_ph { +- let (asset_amount, asset_id) = unlocked_state +- .kv_store +- .read_rgb_payment_info(payment_hash, false) +- .ok() +- .map(|info| (Some(info.amount), Some(info.contract_id.to_string()))) +- .unwrap_or((None, None)); ++ let (asset_amount, asset_id, carrier_msat) = ++ payment_rgb_binding(unlocked_state, payment_hash, false, payment_info)?; + + return Ok(Json(GetPaymentResponse { + payment: Payment { + amt_msat: payment_info.amt_msat, + asset_amount, + asset_id, ++ carrier_msat, + payment_hash: hex_str(&payment_hash.0), + payment_type: PaymentType::Outbound, + status: payment_info.status, + created_at: payment_info.created_at, + updated_at: payment_info.updated_at, ++ expires_at: payment_info.expires_at, + payee_pubkey: payment_info.payee_pubkey.to_string(), + preimage: payment_info.preimage.map(|p| hex_str(&p.0)), + description: payment_info.description.clone(), + description_hash: payment_info.description_hash.map(|h| hex_str(&h)), ++ fee_paid_msat: payment_info.fee_paid_msat, ++ failure_code: payment_info.failure_code.clone(), + }, + })); + } +@@ -3004,6 +3164,9 @@ pub(crate) async fn keysend( + amt_msat, + rgb_payment, + ); ++ let durable_rgb = rgb_payment ++ .as_ref() ++ .map(|(contract_id, amount)| (contract_id.to_string(), *amount)); + let created_at = get_current_timestamp(); + unlocked_state.add_outbound_payment( + payment_id, +@@ -3022,6 +3185,11 @@ pub(crate) async fn keysend( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: durable_rgb.as_ref().map(|(asset_id, _)| asset_id.clone()), ++ asset_amount: durable_rgb.as_ref().map(|(_, amount)| *amount), ++ carrier_msat: durable_rgb.as_ref().map(|_| amt_msat), + + updated_at: created_at, + }, +@@ -3259,13 +3427,8 @@ pub(crate) async fn list_payments( + let mut all: Vec<(u64, Payment)> = vec![]; + + for (payment_hash, payment_info) in &inbound_payments { +- let (asset_amount, asset_id) = match unlocked_state +- .kv_store +- .read_rgb_payment_info(payment_hash, true) +- { +- Ok(info) => (Some(info.amount), Some(info.contract_id.to_string())), +- Err(_) => (None, None), +- }; ++ let (asset_amount, asset_id, carrier_msat) = ++ payment_rgb_binding(unlocked_state, payment_hash, true, payment_info)?; + + all.push(( + payment_info.payment_idx.unwrap_or(0), +@@ -3273,15 +3436,19 @@ pub(crate) async fn list_payments( + amt_msat: payment_info.amt_msat, + asset_amount, + asset_id, ++ carrier_msat, + payment_hash: hex_str(&payment_hash.0), + payment_type: payment_type_from_invoice(payment_info.invoice_type), + status: payment_info.status, + created_at: payment_info.created_at, + updated_at: payment_info.updated_at, ++ expires_at: payment_info.expires_at, + payee_pubkey: payment_info.payee_pubkey.to_string(), + preimage: payment_info.preimage.map(|p| hex_str(&p.0)), + description: payment_info.description.clone(), + description_hash: payment_info.description_hash.map(|h| hex_str(&h)), ++ fee_paid_msat: payment_info.fee_paid_msat, ++ failure_code: payment_info.failure_code.clone(), + }, + )); + } +@@ -3289,13 +3456,8 @@ pub(crate) async fn list_payments( + for (payment_id, payment_info) in &outbound_payments { + let payment_hash = &PaymentHash(payment_id.0); + +- let (asset_amount, asset_id) = match unlocked_state +- .kv_store +- .read_rgb_payment_info(payment_hash, false) +- { +- Ok(info) => (Some(info.amount), Some(info.contract_id.to_string())), +- Err(_) => (None, None), +- }; ++ let (asset_amount, asset_id, carrier_msat) = ++ payment_rgb_binding(unlocked_state, payment_hash, false, payment_info)?; + + all.push(( + payment_info.payment_idx.unwrap_or(0), +@@ -3303,15 +3465,19 @@ pub(crate) async fn list_payments( + amt_msat: payment_info.amt_msat, + asset_amount, + asset_id, ++ carrier_msat, + payment_hash: hex_str(&payment_hash.0), + payment_type: PaymentType::Outbound, + status: payment_info.status, + created_at: payment_info.created_at, + updated_at: payment_info.updated_at, ++ expires_at: payment_info.expires_at, + payee_pubkey: payment_info.payee_pubkey.to_string(), + preimage: payment_info.preimage.map(|p| hex_str(&p.0)), + description: payment_info.description.clone(), + description_hash: payment_info.description_hash.map(|h| hex_str(&h)), ++ fee_paid_msat: payment_info.fee_paid_msat, ++ failure_code: payment_info.failure_code.clone(), + }, + )); + } +@@ -3482,7 +3648,7 @@ pub(crate) async fn list_transfers( + } + let filter = match payload.asset_id { + Some(asset_id) => rgb_lib::wallet::AssetFilter::Id(asset_id), +- None => rgb_lib::wallet::AssetFilter::Any, ++ None => rgb_lib::wallet::AssetFilter::AnyOrNone, + }; + let raw_transfers = unlocked_state.rgb_list_transfers(filter, payload.txid)?; + +@@ -3659,6 +3825,9 @@ pub(crate) async fn ln_invoice( + payload.description_hash.as_deref(), + )?; + ++ let durable_rgb = contract_id ++ .as_ref() ++ .map(|contract_id| (contract_id.to_string(), payload.asset_amount)); + let invoice_params = Bolt11InvoiceParameters { + amount_msats: payload.amt_msat, + description, +@@ -3705,6 +3874,11 @@ pub(crate) async fn ln_invoice( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: durable_rgb.as_ref().map(|(asset_id, _)| asset_id.clone()), ++ asset_amount: durable_rgb.as_ref().and_then(|(_, amount)| *amount), ++ carrier_msat: durable_rgb.as_ref().and_then(|_| payload.amt_msat), + }, + ); + +@@ -4799,6 +4973,7 @@ pub(crate) async fn send_payment( + let unlocked_state = guard.as_ref().unwrap(); + + let mut status = HTLCStatus::Pending; ++ let mut failure_code = None; + let created_at = get_current_timestamp(); + + let (payment_id, payment_hash, payment_secret) = if let Ok(offer) = Offer::from_str(&payload.invoice) { +@@ -4842,6 +5017,11 @@ pub(crate) async fn send_payment( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: None, ++ asset_amount: None, ++ carrier_msat: None, + }, + )?; + +@@ -4855,9 +5035,10 @@ pub(crate) async fn send_payment( + .pay_for_offer(&offer, Some(amt_msat), payment_id, params); + if pay.is_err() { + tracing::error!("ERROR: failed to pay: {:?}", pay); +- unlocked_state.update_outbound_payment_status(payment_id, HTLCStatus::Failed); ++ let code = "PAYMENT_INITIATION_FAILED"; ++ unlocked_state.fail_outbound_payment(payment_id, code); + status = HTLCStatus::Failed; +- unlocked_state.update_outbound_payment_status(payment_id, status); ++ failure_code = Some(code.to_owned()); + } + (payment_id, None, secret) + } else { +@@ -4961,6 +5142,7 @@ pub(crate) async fn send_payment( + payment_hash: Some(hex_str(&linked_payment.payment_hash.0)), + payment_secret: Some(hex_str(&linked_payment.payment_secret.0)), + status: linked_payment.status, ++ failure_code: None, + })); + } + } +@@ -4979,6 +5161,9 @@ pub(crate) async fn send_payment( + } + + let secret = payment_secret; ++ let durable_rgb = rgb_payment ++ .as_ref() ++ .map(|(contract_id, amount)| (contract_id.to_string(), *amount)); + unlocked_state.add_outbound_payment( + payment_id, + PaymentInfo { +@@ -4997,6 +5182,11 @@ pub(crate) async fn send_payment( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: durable_rgb.as_ref().map(|(asset_id, _)| asset_id.clone()), ++ asset_amount: durable_rgb.as_ref().map(|(_, amount)| *amount), ++ carrier_msat: durable_rgb.as_ref().map(|_| amt_msat), + }, + )?; + let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); +@@ -5031,8 +5221,10 @@ pub(crate) async fn send_payment( + Err(e) => { + tracing::error!("ERROR: failed to send payment: {:?}", e); + clear_rgb_payment_pending(&payment_hash, false, unlocked_state.kv_store.as_ref()); ++ let code = bolt11_payment_failure_code(&e); ++ unlocked_state.fail_outbound_payment(payment_id, code); + status = HTLCStatus::Failed; +- unlocked_state.update_outbound_payment_status(payment_id, status); ++ failure_code = Some(code.to_owned()); + }, + }; + +@@ -5044,6 +5236,7 @@ pub(crate) async fn send_payment( + payment_hash: payment_hash.map(|h| hex_str(&h.0)), + payment_secret: payment_secret.map(|s| hex_str(&s.0)), + status, ++ failure_code, + })) + }) + .await +diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs +index 14f863d..c3a3976 100644 +--- a/src/sdk/mod.rs ++++ b/src/sdk/mod.rs +@@ -17,12 +17,13 @@ use crate::core_types::async_order::{ + use crate::core_types::PENDING_SWAP_TIMEOUT_SECS; + use crate::error::APIError; + use crate::ldk::{ +- clear_rgb_payment_pending, peer_has_live_channel, start_ldk, write_rgb_payment_info_file, +- InvoiceType, PaymentInfo, VirtualChannelSessionStatus, ++ clear_rgb_payment_pending, peer_has_live_channel, reconcile_orphaned_virtual_session, ++ start_ldk, write_rgb_payment_info_file, InvoiceType, PaymentInfo, VirtualChannelSessionStatus, + }; + #[cfg(feature = "vss")] + use crate::ldk::{derive_vss_identity, derive_vss_identity_from_key_source}; + use crate::rgb::{check_rgb_proxy_endpoint, get_rgb_channel_info_optional}; ++use crate::rgb_import; + use crate::signer::{ + read_key_source_file, validate_bootstrap_payload, validate_key_source_matches_bootstrap, + write_key_source_file, BootstrapData, KeySourceFile, SUPPORTED_SIGNER_API_LEVEL, +@@ -35,19 +36,21 @@ use crate::utils::{ + hex_str_to_compressed_pubkey, hex_str_to_vec, is_external_signer_mode_configured, + new_jsonrpc_request_id, parse_invoice_description, parse_peer_info, + validate_and_parse_payment_hash, validate_and_parse_payment_preimage, AppState, +- UserOnionMessageContents, ++ UnlockedAppState, UserOnionMessageContents, + }; + use amplify::{map, s}; + use bitcoin::hashes::sha256::Hash as Sha256; + use bitcoin::hashes::Hash; + use bitcoin::hex::DisplayHex; ++use bitcoin::io as bitcoin_io; + use bitcoin::secp256k1::PublicKey; + use bitcoin::ScriptBuf; + use lightning::chain::channelmonitor::Balance; + use lightning::ln::channel_state::ChannelShutdownState; + use lightning::ln::channelmanager::Bolt11InvoiceParameters; + use lightning::ln::channelmanager::{ +- OptionalOfferPaymentParams, PaymentId, RecipientOnionFields, Retry, ++ Bolt11PaymentError, OptionalOfferPaymentParams, PaymentId, RecipientOnionFields, Retry, ++ RetryableSendFailure, + }; + use lightning::ln::types::ChannelId; + use lightning::offers::offer::{self, Offer}; +@@ -81,9 +84,13 @@ use rgb_lib::wallet::{ + use rgb_lib::{ + bdk_wallet::keys::bip39::Mnemonic, + keys::{generate_keys, WitnessVersion}, +- ContractId, RgbTransport, ++ ContractId, Error as RgbLibError, FileContent, RgbTransport, ++ TransactionType as RgbLibTransactionType, TransferStatus as RgbLibTransferStatus, ++ WalletTransactionType, + }; +-use std::collections::HashMap; ++use serde::{Deserialize, Serialize}; ++use std::collections::{BTreeMap, HashMap}; ++use std::io; + use std::net::ToSocketAddrs; + use std::str::FromStr; + use std::sync::Arc; +@@ -97,15 +104,157 @@ use rgb_lib::wallet::RecipientType as RgbLibRecipientType; + use rgb_lib::wallet::{ + AssetCFA as RgbLibAssetCFA, AssetIFA as RgbLibAssetIFA, AssetNIA as RgbLibAssetNIA, + AssetUDA as RgbLibAssetUDA, EmbeddedMedia as RgbLibEmbeddedMedia, +- IfaIssuanceType as RgbLibIfaIssuanceType, Media as RgbLibMedia, Outpoint as RgbLibOutpoint, +- ProofOfReserves as RgbLibProofOfReserves, Token as RgbLibToken, TokenLight as RgbLibTokenLight, ++ IfaIssuanceType as RgbLibIfaIssuanceType, Media as RgbLibMedia, Metadata as RgbLibMetadata, ++ Outpoint as RgbLibOutpoint, ProofOfReserves as RgbLibProofOfReserves, Token as RgbLibToken, ++ TokenLight as RgbLibTokenLight, + }; + use rgb_lib::BitcoinNetwork as RgbBitcoinNetwork; + use rgb_lib::{AssetSchema as RgbLibAssetSchema, Assignment as RgbLibAssignment}; +-use serde::{Deserialize, Serialize}; + use serde_json::Value; + + const SDK_VIRTUAL_OPEN_MODE_TRUSTED_NO_BROADCAST: &str = "trusted_no_broadcast"; ++const PREPARED_SEND_PRIMARY_NAMESPACE: &str = "utexo"; ++const PREPARED_SEND_SECONDARY_NAMESPACE: &str = "prepared_send_v1"; ++const PREPARED_SEND_SCHEMA_VERSION: u8 = 1; ++ ++#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] ++#[serde(rename_all = "snake_case")] ++enum PreparedSendPlanKind { ++ BtcOnchain, ++ RgbOnchain, ++ RgbUtxoSetup, ++} ++ ++#[derive(Clone, Debug, Deserialize, Serialize)] ++#[serde(deny_unknown_fields)] ++struct PersistedPreparedSendPlan { ++ schema_version: u8, ++ plan_id: String, ++ kind: PreparedSendPlanKind, ++ unsigned_psbt: String, ++ batch_transfer_idx: Option, ++ #[serde(default)] ++ target_count: Option, ++ #[serde(default)] ++ output_size_sat: Option, ++} ++ ++fn prepared_send_storage_error(operation: &str, error: impl std::fmt::Display) -> APIError { ++ APIError::IO(io::Error::other(format!( ++ "prepared send plan {operation} failed: {error}" ++ ))) ++} ++ ++fn persist_prepared_send_plan( ++ unlocked_state: &crate::utils::UnlockedAppState, ++ plan: &PersistedPreparedSendPlan, ++) -> Result<(), APIError> { ++ match unlocked_state.kv_store.read( ++ PREPARED_SEND_PRIMARY_NAMESPACE, ++ PREPARED_SEND_SECONDARY_NAMESPACE, ++ &plan.plan_id, ++ ) { ++ Ok(_) => { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {} already exists", ++ plan.plan_id ++ ))); ++ } ++ Err(error) if error.kind() == bitcoin_io::ErrorKind::NotFound => {} ++ Err(error) => return Err(prepared_send_storage_error("read-before-write", error)), ++ } ++ let encoded = serde_json::to_vec(plan) ++ .map_err(|error| prepared_send_storage_error("serialization", error))?; ++ unlocked_state ++ .kv_store ++ .write( ++ PREPARED_SEND_PRIMARY_NAMESPACE, ++ PREPARED_SEND_SECONDARY_NAMESPACE, ++ &plan.plan_id, ++ encoded, ++ ) ++ .map_err(|error| prepared_send_storage_error("persistence", error)) ++} ++ ++fn read_prepared_send_plan_optional( ++ unlocked_state: &crate::utils::UnlockedAppState, ++ plan_id: &str, ++) -> Result, APIError> { ++ let encoded = match unlocked_state.kv_store.read( ++ PREPARED_SEND_PRIMARY_NAMESPACE, ++ PREPARED_SEND_SECONDARY_NAMESPACE, ++ plan_id, ++ ) { ++ Ok(encoded) => encoded, ++ Err(error) if error.kind() == bitcoin_io::ErrorKind::NotFound => return Ok(None), ++ Err(error) => return Err(prepared_send_storage_error("read", error)), ++ }; ++ let plan: PersistedPreparedSendPlan = serde_json::from_slice(&encoded) ++ .map_err(|error| prepared_send_storage_error("decoding", error))?; ++ if plan.schema_version != PREPARED_SEND_SCHEMA_VERSION || plan.plan_id != plan_id { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {plan_id} has invalid persisted identity" ++ ))); ++ } ++ match plan.kind { ++ PreparedSendPlanKind::BtcOnchain ++ if plan.batch_transfer_idx.is_some() ++ || plan.target_count.is_some() ++ || plan.output_size_sat.is_some() => ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared BTC send plan {plan_id} has invalid RGB state" ++ ))); ++ } ++ PreparedSendPlanKind::RgbOnchain ++ if plan.batch_transfer_idx.is_none() ++ || plan.target_count.is_some() ++ || plan.output_size_sat.is_some() => ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB send plan {plan_id} is missing its batch identity" ++ ))); ++ } ++ PreparedSendPlanKind::RgbUtxoSetup ++ if plan.batch_transfer_idx.is_some() ++ || plan.target_count.is_none() ++ || plan.output_size_sat.is_none() => ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB UTXO setup plan {plan_id} has invalid setup metadata" ++ ))); ++ } ++ _ => {} ++ } ++ Ok(Some(plan)) ++} ++ ++fn read_prepared_send_plan( ++ unlocked_state: &crate::utils::UnlockedAppState, ++ plan_id: &str, ++) -> Result { ++ read_prepared_send_plan_optional(unlocked_state, plan_id)?.ok_or_else(|| { ++ APIError::InvalidRequest(format!("prepared send plan {plan_id} was not found")) ++ }) ++} ++ ++fn remove_prepared_send_plan( ++ unlocked_state: &crate::utils::UnlockedAppState, ++ plan_id: &str, ++) -> Result<(), APIError> { ++ if read_prepared_send_plan_optional(unlocked_state, plan_id)?.is_none() { ++ return Ok(()); ++ } ++ unlocked_state ++ .kv_store ++ .remove( ++ PREPARED_SEND_PRIMARY_NAMESPACE, ++ PREPARED_SEND_SECONDARY_NAMESPACE, ++ plan_id, ++ false, ++ ) ++ .map_err(|error| prepared_send_storage_error("removal", error)) ++} + + struct OpenChannelVirtualIntentGuard { + unlocked_state: Arc, +@@ -214,6 +363,7 @@ pub(crate) struct NodeInfoData { + pub(crate) struct NetworkInfoData { + pub(crate) network: RgbBitcoinNetwork, + pub(crate) height: u32, ++ pub(crate) block_hash: String, + } + + pub(crate) struct AddressData { +@@ -244,6 +394,29 @@ pub(crate) struct AssetMetadataData { + pub(crate) linked_to_asset_id: Option, + } + ++pub(crate) struct ImportRgbTransferConsignmentRequestData { ++ pub(crate) consignment_base64: String, ++ pub(crate) offchain_txid: String, ++ pub(crate) expected_asset_id: Option, ++} ++ ++pub(crate) struct ImportRgbTransferConsignmentData { ++ pub(crate) asset_id: String, ++ pub(crate) already_imported: bool, ++ pub(crate) metadata: AssetMetadataData, ++} ++ ++pub(crate) struct ImportRgbContractRequestData { ++ pub(crate) contract_base64: String, ++ pub(crate) expected_asset_id: String, ++} ++ ++pub(crate) struct ImportRgbContractData { ++ pub(crate) asset_id: String, ++ pub(crate) already_imported: bool, ++ pub(crate) metadata: AssetMetadataData, ++} ++ + pub(crate) struct BtcBalance { + pub(crate) settled: u64, + pub(crate) future: u64, +@@ -309,6 +482,36 @@ pub(crate) struct SendRgbData { + pub(crate) batch_transfer_idx: i32, + } + ++pub(crate) struct PreparedSendData { ++ pub(crate) plan_id: String, ++ pub(crate) fee_sat: u64, ++ pub(crate) total_input_sat: u64, ++ pub(crate) total_output_sat: u64, ++ pub(crate) size_vbytes: u64, ++} ++ ++pub(crate) struct PreparedRgbSendData { ++ pub(crate) plan: PreparedSendData, ++ pub(crate) batch_transfer_idx: i32, ++} ++ ++pub(crate) struct PendingVanillaTransactionData { ++ pub(crate) txid: String, ++ pub(crate) operation_type: String, ++} ++ ++pub(crate) struct PendingRgbSendPlanData { ++ pub(crate) plan_id: String, ++ pub(crate) batch_transfer_idx: i32, ++} ++ ++pub(crate) struct AddressReceiptData { ++ pub(crate) txid: String, ++ pub(crate) amount_sat: u64, ++ pub(crate) confirmations: u32, ++ pub(crate) block_height: Option, ++} ++ + pub(crate) enum AssignmentKindData { + Fungible, + NonFungible, +@@ -424,6 +627,7 @@ pub(crate) struct SendPaymentRequestData { + pub(crate) amt_msat: Option, + pub(crate) asset_id: Option, + pub(crate) asset_amount: Option, ++ pub(crate) max_total_routing_fee_msat: Option, + } + + pub(crate) struct SendPaymentData { +@@ -431,6 +635,7 @@ pub(crate) struct SendPaymentData { + pub(crate) payment_hash: Option, + pub(crate) payment_secret: Option, + pub(crate) status: HtlcStatus, ++ pub(crate) failure_code: Option, + } + + pub(crate) struct RefreshTransfersRequestData { +@@ -455,6 +660,12 @@ pub(crate) struct CreateUtxosRequestData { + pub(crate) skip_sync: bool, + } + ++pub(crate) struct PreparedCreateUtxosData { ++ pub(crate) plan: PreparedSendData, ++ pub(crate) target_count: u8, ++ pub(crate) output_size_sat: u32, ++} ++ + pub(crate) struct IssueAssetNiaRequestData { + pub(crate) amounts: Vec, + pub(crate) ticker: String, +@@ -513,6 +724,18 @@ pub(crate) struct SendBtcData { + pub(crate) txid: String, + } + ++pub(crate) struct CommitPreparedSendRequestData { ++ pub(crate) plan_id: String, ++} ++ ++pub(crate) struct CancelBtcSendPlanRequestData { ++ pub(crate) plan_id: String, ++} ++ ++pub(crate) struct CancelBtcSendPlanData { ++ pub(crate) cancelled: bool, ++} ++ + pub(crate) struct MakerInitRequestData { + pub(crate) qty_from: u64, + pub(crate) qty_to: u64, +@@ -578,15 +801,19 @@ pub(crate) struct PaymentData { + pub(crate) amt_msat: Option, + pub(crate) asset_amount: Option, + pub(crate) asset_id: Option, ++ pub(crate) carrier_msat: Option, + pub(crate) payment_hash: String, + pub(crate) payment_type: PaymentType, + pub(crate) status: HtlcStatus, + pub(crate) created_at: u64, + pub(crate) updated_at: u64, ++ pub(crate) expires_at: Option, + pub(crate) payee_pubkey: String, + pub(crate) preimage: Option, + pub(crate) description: Option, + pub(crate) description_hash: Option, ++ pub(crate) fee_paid_msat: Option, ++ pub(crate) failure_code: Option, + } + + pub(crate) struct CancelHodlInvoiceRequestData { +@@ -720,6 +947,22 @@ pub(crate) enum ChannelStatus { + + pub(crate) type HtlcStatus = crate::core_types::HTLCStatus; + ++fn retryable_send_failure_code(reason: &RetryableSendFailure) -> &'static str { ++ match reason { ++ RetryableSendFailure::PaymentExpired => "PAYMENT_EXPIRED", ++ RetryableSendFailure::RouteNotFound => "ROUTE_NOT_FOUND", ++ RetryableSendFailure::DuplicatePayment => "DUPLICATE_PAYMENT", ++ RetryableSendFailure::OnionPacketSizeExceeded => "ONION_PACKET_TOO_LARGE", ++ } ++} ++ ++fn bolt11_payment_failure_code(reason: &Bolt11PaymentError) -> &'static str { ++ match reason { ++ Bolt11PaymentError::InvalidAmount => "INVALID_PAYMENT_AMOUNT", ++ Bolt11PaymentError::SendingFailed(reason) => retryable_send_failure_code(reason), ++ } ++} ++ + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub(crate) enum PaymentType { + Outbound, +@@ -1147,11 +1390,15 @@ pub(crate) async fn node_info(state: Arc) -> Result) -> Result { + let guard = check_unlocked(&state).await?; + let unlocked_state = guard.as_ref().unwrap(); +- let best_block = unlocked_state.channel_manager.current_best_block(); ++ let checkpoint = unlocked_state ++ .address_indexer ++ .chain_checkpoint() ++ .map_err(APIError::InvalidIndexer)?; + + Ok(NetworkInfoData { + network: state.static_state.network, +- height: best_block.height, ++ height: checkpoint.height, ++ block_hash: checkpoint.block_hash.to_string(), + }) + } + +@@ -1609,7 +1856,11 @@ pub(crate) async fn asset_metadata( + .unwrap() + .rgb_get_asset_metadata(contract_id)?; + +- Ok(AssetMetadataData { ++ Ok(asset_metadata_data_from_metadata(metadata)) ++} ++ ++fn asset_metadata_data_from_metadata(metadata: RgbLibMetadata) -> AssetMetadataData { ++ AssetMetadataData { + asset_schema: metadata.asset_schema, + initial_supply: metadata.initial_supply, + max_supply: metadata.max_supply, +@@ -1623,6 +1874,47 @@ pub(crate) async fn asset_metadata( + unspent_link_right_outpoint: metadata.unspent_link_right_outpoint, + linked_from_asset_id: metadata.linked_from_asset_id, + linked_to_asset_id: metadata.linked_to_asset_id, ++ } ++} ++ ++pub(crate) async fn import_rgb_transfer_consignment( ++ state: Arc, ++ request: ImportRgbTransferConsignmentRequestData, ++) -> Result { ++ let imported = rgb_import::import_rgb_transfer_consignment( ++ state, ++ rgb_import::ImportRgbTransferConsignmentRequestData { ++ consignment_base64: request.consignment_base64, ++ offchain_txid: request.offchain_txid, ++ expected_asset_id: request.expected_asset_id, ++ }, ++ ) ++ .await?; ++ ++ Ok(ImportRgbTransferConsignmentData { ++ asset_id: imported.asset_id, ++ already_imported: imported.already_imported, ++ metadata: asset_metadata_data_from_metadata(imported.metadata), ++ }) ++} ++ ++pub(crate) async fn import_rgb_contract( ++ state: Arc, ++ request: ImportRgbContractRequestData, ++) -> Result { ++ let imported = rgb_import::import_rgb_contract( ++ state, ++ rgb_import::ImportRgbContractRequestData { ++ contract_base64: request.contract_base64, ++ expected_asset_id: request.expected_asset_id, ++ }, ++ ) ++ .await?; ++ ++ Ok(ImportRgbContractData { ++ asset_id: imported.asset_id, ++ already_imported: imported.already_imported, ++ metadata: asset_metadata_data_from_metadata(imported.metadata), + }) + } + +@@ -1787,18 +2079,10 @@ pub(crate) async fn send_rgb( + }) + } + +-pub(crate) async fn send_rgb_from_groups( +- state: Arc, +- request: SendRgbRequestData, +-) -> Result { +- if request.recipient_groups.is_empty() { +- return Err(APIError::InvalidAmount( +- "recipient_groups cannot be empty".to_string(), +- )); +- } +- +- let recipient_map = request +- .recipient_groups ++fn rgb_recipient_map_from_groups( ++ recipient_groups: Vec, ++) -> Result>, APIError> { ++ recipient_groups + .into_iter() + .map(|group| { + let recipients = group +@@ -1821,7 +2105,20 @@ pub(crate) async fn send_rgb_from_groups( + .collect::, APIError>>()?; + Ok((group.asset_id, recipients)) + }) +- .collect::, APIError>>()?; ++ .collect::, APIError>>() ++} ++ ++pub(crate) async fn send_rgb_from_groups( ++ state: Arc, ++ request: SendRgbRequestData, ++) -> Result { ++ if request.recipient_groups.is_empty() { ++ return Err(APIError::InvalidAmount( ++ "recipient_groups cannot be empty".to_string(), ++ )); ++ } ++ ++ let recipient_map = rgb_recipient_map_from_groups(request.recipient_groups)?; + + send_rgb( + state, +@@ -1833,6 +2130,302 @@ pub(crate) async fn send_rgb_from_groups( + .await + } + ++pub(crate) async fn prepare_rgb_send_from_groups( ++ state: Arc, ++ request: SendRgbRequestData, ++) -> Result { ++ if request.recipient_groups.is_empty() { ++ return Err(APIError::InvalidAmount( ++ "recipient_groups cannot be empty".to_string(), ++ )); ++ } ++ ++ let recipient_map = rgb_recipient_map_from_groups(request.recipient_groups)?; ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let unlocked_state_copy = unlocked_state.clone(); ++ let begin_result = tokio::task::spawn_blocking(move || { ++ unlocked_state_copy.rgb_send_begin( ++ recipient_map, ++ request.donation, ++ request.fee_rate, ++ request.min_confirmations, ++ None, ++ false, ++ None, ++ ) ++ }) ++ .await ++ .map_err(|e| APIError::InvalidRequest(format!("RGB send planning task failed: {e}")))??; ++ ++ let inspection = unlocked_state.rgb_inspect_psbt(begin_result.psbt.clone())?; ++ let batch_transfer_idx = begin_result.batch_transfer_idx.ok_or_else(|| { ++ APIError::Unexpected( ++ "reserved RGB send planning did not return a batch transfer identity".to_string(), ++ ) ++ })?; ++ let persisted_plan = PersistedPreparedSendPlan { ++ schema_version: PREPARED_SEND_SCHEMA_VERSION, ++ plan_id: inspection.txid.clone(), ++ kind: PreparedSendPlanKind::RgbOnchain, ++ unsigned_psbt: begin_result.psbt, ++ batch_transfer_idx: Some(batch_transfer_idx), ++ target_count: None, ++ output_size_sat: None, ++ }; ++ if let Err(persistence_error) = persist_prepared_send_plan(unlocked_state, &persisted_plan) { ++ if let Err(rollback_error) = ++ unlocked_state.rgb_cancel_send_plan(inspection.txid.clone(), batch_transfer_idx) ++ { ++ return Err(APIError::Unexpected(format!( ++ "{persistence_error}; RGB plan rollback also failed: {rollback_error}" ++ ))); ++ } ++ return Err(persistence_error); ++ } ++ ++ Ok(PreparedRgbSendData { ++ plan: PreparedSendData { ++ plan_id: inspection.txid, ++ fee_sat: inspection.fee_sat, ++ total_input_sat: inspection.total_input_sat, ++ total_output_sat: inspection.total_output_sat, ++ size_vbytes: inspection.size_vbytes, ++ }, ++ batch_transfer_idx, ++ }) ++} ++ ++pub(crate) async fn commit_prepared_rgb_send( ++ state: Arc, ++ request: CommitPreparedSendRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let existing_transfers = unlocked_state.rgb_list_transfers( ++ rgb_lib::wallet::AssetFilter::AnyOrNone, ++ Some(request.plan_id.clone()), ++ )?; ++ if !existing_transfers.is_empty() ++ && existing_transfers ++ .iter() ++ .all(|transfer| transfer.status != RgbLibTransferStatus::Initiated) ++ { ++ let batch_transfer_idx = existing_transfers[0].batch_transfer_idx; ++ if existing_transfers ++ .iter() ++ .any(|transfer| transfer.batch_transfer_idx != batch_transfer_idx) ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "committed RGB send {} has inconsistent batch identity", ++ request.plan_id ++ ))); ++ } ++ if existing_transfers ++ .iter() ++ .any(|transfer| transfer.status == RgbLibTransferStatus::Failed) ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB send plan {} is terminally failed", ++ request.plan_id ++ ))); ++ } ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ return Ok(SendRgbData { ++ txid: request.plan_id, ++ batch_transfer_idx, ++ }); ++ } ++ let plan = read_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ if plan.kind != PreparedSendPlanKind::RgbOnchain { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {} is not an RGB plan", ++ request.plan_id ++ ))); ++ } ++ let batch_transfer_idx = plan.batch_transfer_idx.ok_or_else(|| { ++ APIError::InvalidRequest(format!( ++ "prepared RGB send plan {} is missing its batch identity", ++ request.plan_id ++ )) ++ })?; ++ let transfers = existing_transfers; ++ if transfers.is_empty() ++ || transfers.iter().any(|transfer| { ++ transfer.batch_transfer_idx != batch_transfer_idx ++ || transfer.status != RgbLibTransferStatus::Initiated ++ }) ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB send plan {} no longer owns initiated batch {}", ++ request.plan_id, batch_transfer_idx ++ ))); ++ } ++ ++ let inspection = unlocked_state.rgb_inspect_psbt(plan.unsigned_psbt.clone())?; ++ if inspection.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "prepared RGB send plan does not match its PSBT".to_string(), ++ )); ++ } ++ ++ let signed_psbt = unlocked_state ++ .rgb_sign_psbt(plan.unsigned_psbt) ++ .map_err(|e| { ++ tracing::error!("rgb_sign_psbt failed during prepared RGB send: {e}"); ++ APIError::from(e) ++ })?; ++ let signed_inspection = unlocked_state.rgb_inspect_psbt(signed_psbt.clone())?; ++ if signed_inspection.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "signing changed the prepared RGB send transaction".to_string(), ++ )); ++ } ++ ++ let result = unlocked_state.rgb_send_end(signed_psbt)?; ++ if result.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "broadcast RGB transaction does not match the prepared plan".to_string(), ++ )); ++ } ++ if result.batch_transfer_idx != batch_transfer_idx { ++ return Err(APIError::InvalidRequest( ++ "broadcast RGB transaction does not match the prepared batch".to_string(), ++ )); ++ } ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ Ok(SendRgbData { ++ txid: result.txid, ++ batch_transfer_idx: result.batch_transfer_idx, ++ }) ++} ++ ++pub(crate) async fn cancel_rgb_send_plan( ++ state: Arc, ++ request: CancelBtcSendPlanRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let transfers = unlocked_state.rgb_list_transfers( ++ rgb_lib::wallet::AssetFilter::AnyOrNone, ++ Some(request.plan_id.clone()), ++ )?; ++ if transfers.is_empty() { ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ return Ok(CancelBtcSendPlanData { cancelled: true }); ++ } ++ let batch_transfer_idx = transfers ++ .first() ++ .map(|transfer| transfer.batch_transfer_idx) ++ .ok_or_else(|| { ++ APIError::InvalidRequest(format!( ++ "prepared RGB send plan {} was not found", ++ request.plan_id ++ )) ++ })?; ++ if transfers ++ .iter() ++ .any(|transfer| transfer.batch_transfer_idx != batch_transfer_idx) ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB send plan {} has inconsistent batch identity", ++ request.plan_id ++ ))); ++ } ++ if let Some(plan) = read_prepared_send_plan_optional(unlocked_state, &request.plan_id)? { ++ if plan.kind != PreparedSendPlanKind::RgbOnchain ++ || plan.batch_transfer_idx != Some(batch_transfer_idx) ++ { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB send plan {} does not match persisted state", ++ request.plan_id ++ ))); ++ } ++ } ++ let cancelled = ++ unlocked_state.rgb_cancel_send_plan(request.plan_id.clone(), batch_transfer_idx)?; ++ if !cancelled { ++ return Err(APIError::Unexpected(format!( ++ "prepared RGB send plan {} was not cancelled", ++ request.plan_id ++ ))); ++ } ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ Ok(CancelBtcSendPlanData { cancelled: true }) ++} ++ ++pub(crate) async fn list_pending_rgb_send_plans( ++ state: Arc, ++) -> Result, APIError> { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let assets = unlocked_state.rgb_list_assets(vec![])?; ++ let mut asset_ids = Vec::new(); ++ asset_ids.extend( ++ assets ++ .nia ++ .unwrap_or_default() ++ .into_iter() ++ .map(|asset| asset.asset_id), ++ ); ++ asset_ids.extend( ++ assets ++ .uda ++ .unwrap_or_default() ++ .into_iter() ++ .map(|asset| asset.asset_id), ++ ); ++ asset_ids.extend( ++ assets ++ .cfa ++ .unwrap_or_default() ++ .into_iter() ++ .map(|asset| asset.asset_id), ++ ); ++ asset_ids.extend( ++ assets ++ .ifa ++ .unwrap_or_default() ++ .into_iter() ++ .map(|asset| asset.asset_id), ++ ); ++ asset_ids.sort(); ++ asset_ids.dedup(); ++ ++ let mut plans = BTreeMap::new(); ++ for asset_id in asset_ids { ++ for transfer in ++ unlocked_state.rgb_list_transfers(rgb_lib::wallet::AssetFilter::Id(asset_id), None)? ++ { ++ if transfer.status != RgbLibTransferStatus::Initiated { ++ continue; ++ } ++ let plan_id = transfer.txid.ok_or_else(|| { ++ APIError::Unexpected( ++ "initiated RGB send plan is missing its transaction identity".to_string(), ++ ) ++ })?; ++ if let Some(existing_batch_transfer_idx) = ++ plans.insert(plan_id.clone(), transfer.batch_transfer_idx) ++ { ++ if existing_batch_transfer_idx != transfer.batch_transfer_idx { ++ return Err(APIError::Unexpected(format!( ++ "initiated RGB send plan {plan_id} has inconsistent batch identity" ++ ))); ++ } ++ } ++ } ++ } ++ ++ Ok(plans ++ .into_iter() ++ .map(|(plan_id, batch_transfer_idx)| PendingRgbSendPlanData { ++ plan_id, ++ batch_transfer_idx, ++ }) ++ .collect()) ++} ++ + pub(crate) async fn init( + state: Arc, + password: String, +@@ -1905,24 +2498,86 @@ pub(crate) async fn vss_backup(state: Arc) -> Result { + rt.block_on(wallet.vss_backup(&vss_client)) + }) + .await +- .map_err(|e| APIError::Unexpected(format!("VSS backup task failed: {e}")))? +- .map_err(|e| APIError::Unexpected(format!("VSS backup failed: {e}")))?; ++ .map_err(|e| APIError::Unexpected(format!("VSS backup task failed: {e}")))? ++ .map_err(|e| APIError::Unexpected(format!("VSS backup failed: {e}")))?; ++ ++ Ok(version) ++ } ++} ++ ++/// Clears the VSS single-writer fence so a fresh instance can take over a ++/// store whose previous owner did not release it (the normal case after any ++/// shutdown — `acquire_fence` writes the fence but no code path deletes it). ++/// ++/// Must be called on a locked node (the unlock path acquires the fence ++/// itself, so clearing it while unlocked would race against the periodic ++/// re-check and panic the running instance). ++pub(crate) async fn vss_clear_fence( ++ state: Arc, ++ request: VssClearFenceRequest, ++) -> Result<(), APIError> { ++ let _locked_state = check_locked(&state).await?; ++ ++ #[cfg(not(feature = "vss"))] ++ { ++ let _ = request; ++ Err(APIError::Unexpected( ++ "VSS support is not compiled in".to_string(), ++ )) ++ } ++ ++ #[cfg(feature = "vss")] ++ { ++ let vss_url = state ++ .static_state ++ .vss_url ++ .clone() ++ .ok_or_else(|| APIError::FailedVssInit("VSS is not configured".to_string()))?; ++ ++ // Internal-mnemonic mode authenticates with the password and derives ++ // the `m/535'/1'` identity. External-signer mode holds no mnemonic, so ++ // it reconstructs the bootstrap identity from the persisted ++ // key_source.json — the same store id start_ldk acquired the fence ++ // under. Without this branch, external-signer nodes could never clear ++ // a leftover fence and would be wedged after their first shutdown. ++ // [[derive_vss_identity_from_key_source]] ++ let identity = match read_key_source_file(&state.static_state.storage_dir_path) ++ .map_err(|e| APIError::ExternalSignerProtocolError(e.to_string()))? ++ { ++ Some(key_source) => derive_vss_identity_from_key_source(&key_source)?, ++ None => { ++ let mnemonic = check_password_validity(&request.password, &state.db())?; ++ derive_vss_identity(&mnemonic, state.static_state.network.into())? ++ } ++ }; ++ ++ let vss_retry = state.static_state.config.vss.clone(); ++ tokio::task::spawn_blocking(move || { ++ let store = crate::vss_kv_store::VssKvStore::new_with_retry( ++ vss_url, ++ identity.pubkey_hex, ++ identity.signing_key, ++ &vss_retry, ++ )?; ++ store.delete_fence() ++ }) ++ .await ++ .map_err(|e| APIError::Unexpected(format!("vss_clear_fence task failed: {e}")))? ++ .map_err(|e| APIError::FailedVssInit(format!("vss_clear_fence failed: {e}")))?; + +- Ok(version) ++ Ok(()) + } + } + +-/// Clears the VSS single-writer fence so a fresh instance can take over a +-/// store whose previous owner did not release it (the normal case after any +-/// shutdown — `acquire_fence` writes the fence but no code path deletes it). ++/// Permanently deletes every object in the authenticated VSS store. + /// +-/// Must be called on a locked node (the unlock path acquires the fence +-/// itself, so clearing it while unlocked would race against the periodic +-/// re-check and panic the running instance). +-pub(crate) async fn vss_clear_fence( ++/// The node must be locked so no local persistence task can race the remote ++/// deletion. External-signer mode derives the VSS identity from key_source.json; ++/// internal-mnemonic mode authenticates with the supplied wallet password. ++pub(crate) async fn vss_delete_all( + state: Arc, + request: VssClearFenceRequest, +-) -> Result<(), APIError> { ++) -> Result { + let _locked_state = check_locked(&state).await?; + + #[cfg(not(feature = "vss"))] +@@ -1940,16 +2595,8 @@ pub(crate) async fn vss_clear_fence( + .vss_url + .clone() + .ok_or_else(|| APIError::FailedVssInit("VSS is not configured".to_string()))?; +- +- // Internal-mnemonic mode authenticates with the password and derives +- // the `m/535'/1'` identity. External-signer mode holds no mnemonic, so +- // it reconstructs the bootstrap identity from the persisted +- // key_source.json — the same store id start_ldk acquired the fence +- // under. Without this branch, external-signer nodes could never clear +- // a leftover fence and would be wedged after their first shutdown. +- // [[derive_vss_identity_from_key_source]] + let identity = match read_key_source_file(&state.static_state.storage_dir_path) +- .map_err(|e| APIError::ExternalSignerProtocolError(e.to_string()))? ++ .map_err(|error| APIError::ExternalSignerProtocolError(error.to_string()))? + { + Some(key_source) => derive_vss_identity_from_key_source(&key_source)?, + None => { +@@ -1957,7 +2604,6 @@ pub(crate) async fn vss_clear_fence( + derive_vss_identity(&mnemonic, state.static_state.network.into())? + } + }; +- + let vss_retry = state.static_state.config.vss.clone(); + tokio::task::spawn_blocking(move || { + let store = crate::vss_kv_store::VssKvStore::new_with_retry( +@@ -1966,13 +2612,11 @@ pub(crate) async fn vss_clear_fence( + identity.signing_key, + &vss_retry, + )?; +- store.delete_fence() ++ store.delete_all() + }) + .await +- .map_err(|e| APIError::Unexpected(format!("vss_clear_fence task failed: {e}")))? +- .map_err(|e| APIError::FailedVssInit(format!("vss_clear_fence failed: {e}")))?; +- +- Ok(()) ++ .map_err(|error| APIError::Unexpected(format!("vss_delete_all task failed: {error}")))? ++ .map_err(|error| APIError::FailedVssInit(format!("vss_delete_all failed: {error}"))) + } + } + +@@ -2258,10 +2902,18 @@ pub(crate) async fn close_channel( + } else { + if let Some(session) = virtual_session.as_ref() { + if !matches!(session.status, VirtualChannelSessionStatus::Abandoned) { +- unlocked_state.virtual_channel_session_update_status( +- session, +- VirtualChannelSessionStatus::Abandoned, +- ); ++ reconcile_orphaned_virtual_session(&requested_cid, Arc::clone(unlocked_state)) ++ .await; ++ if unlocked_state ++ .virtual_channel_session_get(&requested_cid) ++ .is_some_and(|current| { ++ !matches!(current.status, VirtualChannelSessionStatus::Abandoned) ++ }) ++ { ++ return Err(APIError::CannotCloseChannel( ++ "virtual funding cleanup failed; the session remains blocked".to_string(), ++ )); ++ } + } + return Ok(()); + } +@@ -2438,6 +3090,144 @@ pub(crate) async fn create_utxos( + Ok(()) + } + ++pub(crate) async fn prepare_create_utxos( ++ state: Arc, ++ request: CreateUtxosRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let target_count = request.num.unwrap_or(unlocked_state.config.rgb.utxo_num); ++ let output_size_sat = request ++ .size ++ .unwrap_or(unlocked_state.config.rgb.utxo_size_sat); ++ let unsigned_psbt = unlocked_state.rgb_prepare_isolated_create_utxos( ++ request.up_to, ++ target_count, ++ output_size_sat, ++ request.fee_rate, ++ request.skip_sync, ++ )?; ++ let inspection = unlocked_state.rgb_inspect_psbt(unsigned_psbt.clone())?; ++ let plan = PersistedPreparedSendPlan { ++ schema_version: PREPARED_SEND_SCHEMA_VERSION, ++ plan_id: inspection.txid.clone(), ++ kind: PreparedSendPlanKind::RgbUtxoSetup, ++ unsigned_psbt, ++ batch_transfer_idx: None, ++ target_count: Some(target_count), ++ output_size_sat: Some(output_size_sat), ++ }; ++ if let Err(persistence_error) = persist_prepared_send_plan(unlocked_state, &plan) { ++ if let Err(rollback_error) = ++ unlocked_state.rgb_abort_pending_vanilla_tx(inspection.txid.clone()) ++ { ++ return Err(APIError::Unexpected(format!( ++ "{persistence_error}; RGB UTXO setup rollback also failed: {rollback_error}" ++ ))); ++ } ++ return Err(persistence_error); ++ } ++ ++ Ok(PreparedCreateUtxosData { ++ plan: PreparedSendData { ++ plan_id: inspection.txid, ++ fee_sat: inspection.fee_sat, ++ total_input_sat: inspection.total_input_sat, ++ total_output_sat: inspection.total_output_sat, ++ size_vbytes: inspection.size_vbytes, ++ }, ++ target_count, ++ output_size_sat, ++ }) ++} ++ ++pub(crate) async fn commit_prepared_create_utxos( ++ state: Arc, ++ request: CommitPreparedSendRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ if unlocked_state ++ .rgb_list_transactions(true)? ++ .iter() ++ .any(|transaction| { ++ transaction.txid == request.plan_id ++ && transaction.transaction_type == RgbLibTransactionType::CreateUtxos ++ }) ++ { ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ return Ok(SendBtcData { ++ txid: request.plan_id, ++ }); ++ } ++ ++ let plan = read_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ if plan.kind != PreparedSendPlanKind::RgbUtxoSetup { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {} is not an RGB UTXO setup plan", ++ request.plan_id ++ ))); ++ } ++ let pending = unlocked_state.rgb_list_pending_vanilla_txs()?; ++ if !pending.iter().any(|transaction| { ++ transaction.txid == request.plan_id ++ && transaction.r#type == WalletTransactionType::CreateUtxos ++ }) { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared RGB UTXO setup plan {} no longer owns its inputs", ++ request.plan_id ++ ))); ++ } ++ let inspection = unlocked_state.rgb_inspect_psbt(plan.unsigned_psbt.clone())?; ++ if inspection.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "prepared RGB UTXO setup plan does not match its PSBT".to_string(), ++ )); ++ } ++ let signed_psbt = unlocked_state ++ .rgb_sign_psbt(plan.unsigned_psbt) ++ .map_err(|error| { ++ tracing::error!("rgb_sign_psbt failed during prepared RGB UTXO setup: {error}"); ++ APIError::from(error) ++ })?; ++ let signed_inspection = unlocked_state.rgb_inspect_psbt(signed_psbt.clone())?; ++ if signed_inspection.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "signing changed the prepared RGB UTXO setup transaction".to_string(), ++ )); ++ } ++ let _created = unlocked_state.rgb_create_utxos_end(signed_psbt, false)?; ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ Ok(SendBtcData { ++ txid: request.plan_id, ++ }) ++} ++ ++pub(crate) async fn cancel_create_utxos_plan( ++ state: Arc, ++ request: CancelBtcSendPlanRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ if let Some(plan) = read_prepared_send_plan_optional(unlocked_state, &request.plan_id)? { ++ if plan.kind != PreparedSendPlanKind::RgbUtxoSetup { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {} is not an RGB UTXO setup plan", ++ request.plan_id ++ ))); ++ } ++ } ++ let pending = unlocked_state.rgb_list_pending_vanilla_txs()?; ++ if pending.iter().any(|transaction| { ++ transaction.txid == request.plan_id ++ && transaction.r#type == WalletTransactionType::CreateUtxos ++ }) { ++ unlocked_state.rgb_abort_pending_vanilla_tx(request.plan_id.clone())?; ++ } ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ Ok(CancelBtcSendPlanData { cancelled: true }) ++} ++ + pub(crate) async fn issue_asset_nia( + state: Arc, + request: IssueAssetNiaRequestData, +@@ -2600,6 +3390,9 @@ pub(crate) async fn keysend( + amt_msat, + rgb_payment, + ); ++ let durable_rgb = rgb_payment ++ .as_ref() ++ .map(|(contract_id, amount)| (contract_id.to_string(), *amount)); + let created_at = get_current_timestamp(); + unlocked_state.add_outbound_payment( + payment_id, +@@ -2619,6 +3412,11 @@ pub(crate) async fn keysend( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: durable_rgb.as_ref().map(|(asset_id, _)| asset_id.clone()), ++ asset_amount: durable_rgb.as_ref().map(|(_, amount)| *amount), ++ carrier_msat: durable_rgb.as_ref().map(|_| amt_msat), + }, + )?; + if let Some((contract_id, rgb_amount)) = rgb_payment { +@@ -2689,6 +3487,181 @@ pub(crate) async fn send_btc( + Ok(SendBtcData { txid }) + } + ++pub(crate) async fn prepare_btc_send( ++ state: Arc, ++ request: SendBtcRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let unsigned_psbt = unlocked_state.rgb_prepare_send_btc( ++ request.address, ++ request.amount, ++ request.fee_rate, ++ request.skip_sync, ++ )?; ++ let inspection = unlocked_state.rgb_inspect_psbt(unsigned_psbt.clone())?; ++ let plan = PersistedPreparedSendPlan { ++ schema_version: PREPARED_SEND_SCHEMA_VERSION, ++ plan_id: inspection.txid.clone(), ++ kind: PreparedSendPlanKind::BtcOnchain, ++ unsigned_psbt, ++ batch_transfer_idx: None, ++ target_count: None, ++ output_size_sat: None, ++ }; ++ if let Err(persistence_error) = persist_prepared_send_plan(unlocked_state, &plan) { ++ if let Err(rollback_error) = ++ unlocked_state.rgb_abort_pending_vanilla_tx(inspection.txid.clone()) ++ { ++ return Err(APIError::Unexpected(format!( ++ "{persistence_error}; BTC plan rollback also failed: {rollback_error}" ++ ))); ++ } ++ return Err(persistence_error); ++ } ++ ++ Ok(PreparedSendData { ++ plan_id: inspection.txid, ++ fee_sat: inspection.fee_sat, ++ total_input_sat: inspection.total_input_sat, ++ total_output_sat: inspection.total_output_sat, ++ size_vbytes: inspection.size_vbytes, ++ }) ++} ++ ++pub(crate) async fn commit_prepared_btc_send( ++ state: Arc, ++ request: CommitPreparedSendRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ if unlocked_state ++ .rgb_list_transactions(true)? ++ .iter() ++ .any(|transaction| { ++ transaction.txid == request.plan_id ++ && transaction.transaction_type == RgbLibTransactionType::SendBtc ++ }) ++ { ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ return Ok(SendBtcData { ++ txid: request.plan_id, ++ }); ++ } ++ let plan = read_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ if plan.kind != PreparedSendPlanKind::BtcOnchain { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {} is not a BTC plan", ++ request.plan_id ++ ))); ++ } ++ let pending = unlocked_state.rgb_list_pending_vanilla_txs()?; ++ if !pending.iter().any(|transaction| { ++ transaction.txid == request.plan_id && transaction.r#type == WalletTransactionType::SendBtc ++ }) { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared BTC send plan {} no longer owns its inputs", ++ request.plan_id ++ ))); ++ } ++ let inspection = unlocked_state.rgb_inspect_psbt(plan.unsigned_psbt.clone())?; ++ if inspection.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "prepared BTC send plan does not match its PSBT".to_string(), ++ )); ++ } ++ ++ let signed_psbt = unlocked_state ++ .rgb_sign_psbt(plan.unsigned_psbt) ++ .map_err(|e| { ++ tracing::error!("rgb_sign_psbt failed during prepared BTC send: {e}"); ++ APIError::from(e) ++ })?; ++ let signed_inspection = unlocked_state.rgb_inspect_psbt(signed_psbt.clone())?; ++ if signed_inspection.txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "signing changed the prepared BTC send transaction".to_string(), ++ )); ++ } ++ ++ let txid = unlocked_state.rgb_send_btc_end(signed_psbt)?; ++ if txid != request.plan_id { ++ return Err(APIError::InvalidRequest( ++ "broadcast BTC transaction does not match the prepared plan".to_string(), ++ )); ++ } ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ Ok(SendBtcData { txid }) ++} ++ ++pub(crate) async fn cancel_btc_send_plan( ++ state: Arc, ++ request: CancelBtcSendPlanRequestData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ if let Some(plan) = read_prepared_send_plan_optional(unlocked_state, &request.plan_id)? { ++ if plan.kind != PreparedSendPlanKind::BtcOnchain { ++ return Err(APIError::InvalidRequest(format!( ++ "prepared send plan {} is not a BTC plan", ++ request.plan_id ++ ))); ++ } ++ } ++ let pending = unlocked_state.rgb_list_pending_vanilla_txs()?; ++ if pending.iter().any(|transaction| { ++ transaction.txid == request.plan_id && transaction.r#type == WalletTransactionType::SendBtc ++ }) { ++ unlocked_state.rgb_abort_pending_vanilla_tx(request.plan_id.clone())?; ++ } ++ remove_prepared_send_plan(unlocked_state, &request.plan_id)?; ++ Ok(CancelBtcSendPlanData { cancelled: true }) ++} ++ ++pub(crate) async fn list_pending_vanilla_transactions( ++ state: Arc, ++) -> Result, APIError> { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ Ok(unlocked_state ++ .rgb_list_pending_vanilla_txs()? ++ .into_iter() ++ .map(|transaction| PendingVanillaTransactionData { ++ txid: transaction.txid, ++ operation_type: match transaction.r#type { ++ rgb_lib::WalletTransactionType::CreateUtxos => "CreateUtxos".to_string(), ++ rgb_lib::WalletTransactionType::SendBtc => "SendBtc".to_string(), ++ rgb_lib::WalletTransactionType::Drain => "Drain".to_string(), ++ }, ++ }) ++ .collect()) ++} ++ ++pub(crate) async fn list_address_receipts( ++ state: Arc, ++ address: String, ++) -> Result, APIError> { ++ let network: bitcoin::Network = state.static_state.network.into(); ++ let address = bitcoin::Address::from_str(&address) ++ .map_err(|e| APIError::InvalidRequest(format!("invalid Bitcoin address: {e}")))? ++ .require_network(network) ++ .map_err(|e| APIError::InvalidRequest(format!("Bitcoin address network mismatch: {e}")))?; ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ Ok(unlocked_state ++ .address_indexer ++ .list_address_receipts(&address) ++ .map_err(APIError::Network)? ++ .into_iter() ++ .map(|receipt| AddressReceiptData { ++ txid: receipt.txid.to_string(), ++ amount_sat: receipt.amount_sat, ++ confirmations: receipt.confirmations, ++ block_height: receipt.block_height, ++ }) ++ .collect()) ++} ++ + pub(crate) async fn post_asset_media( + state: Arc, + file_bytes: Vec, +@@ -3115,6 +4088,7 @@ pub(crate) async fn send_payment( + let unlocked_state = guard.as_ref().unwrap(); + + let mut status = HtlcStatus::Pending; ++ let mut failure_code = None; + let created_at = get_current_timestamp(); + + let (payment_id, payment_hash, payment_secret) = if let Ok(offer) = +@@ -3159,10 +4133,19 @@ pub(crate) async fn send_payment( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: None, ++ asset_amount: None, ++ carrier_msat: None, + }, + )?; + + let params = OptionalOfferPaymentParams { ++ route_params_config: RouteParametersConfig { ++ max_total_routing_fee_msat: request.max_total_routing_fee_msat, ++ ..Default::default() ++ }, + retry_strategy: Retry::Timeout(Duration::from_secs(10)), + ..Default::default() + }; +@@ -3174,9 +4157,10 @@ pub(crate) async fn send_payment( + ); + if pay.is_err() { + tracing::error!("ERROR: failed to pay: {:?}", pay); +- unlocked_state.update_outbound_payment_status(payment_id, HtlcStatus::Failed); ++ let code = "PAYMENT_INITIATION_FAILED"; ++ unlocked_state.fail_outbound_payment(payment_id, code); + status = HtlcStatus::Failed; +- unlocked_state.update_outbound_payment_status(payment_id, status); ++ failure_code = Some(code.to_owned()); + } + (payment_id, None, secret) + } else { +@@ -3271,12 +4255,16 @@ pub(crate) async fn send_payment( + payment_hash: Some(hex_str(&linked_payment.payment_hash.0)), + payment_secret: Some(hex_str(&linked_payment.payment_secret.0)), + status: linked_payment.status, ++ failure_code: None, + }); + } + } + } + + let secret = payment_secret; ++ let durable_rgb = rgb_payment ++ .as_ref() ++ .map(|(contract_id, amount)| (contract_id.to_string(), *amount)); + unlocked_state.add_outbound_payment( + payment_id, + PaymentInfo { +@@ -3295,6 +4283,11 @@ pub(crate) async fn send_payment( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: durable_rgb.as_ref().map(|(asset_id, _)| asset_id.clone()), ++ asset_amount: durable_rgb.as_ref().map(|(_, amount)| *amount), ++ carrier_msat: durable_rgb.as_ref().map(|_| amt_msat), + }, + )?; + let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); +@@ -3318,7 +4311,10 @@ pub(crate) async fn send_payment( + &invoice, + payment_id, + Some(amt_msat), +- RouteParametersConfig::default(), ++ RouteParametersConfig { ++ max_total_routing_fee_msat: request.max_total_routing_fee_msat, ++ ..Default::default() ++ }, + bolt11_retry, + ) { + Ok(_) => { +@@ -3332,8 +4328,10 @@ pub(crate) async fn send_payment( + Err(e) => { + tracing::error!("ERROR: failed to send payment: {:?}", e); + clear_rgb_payment_pending(&payment_hash, false, unlocked_state.kv_store.as_ref()); ++ let code = bolt11_payment_failure_code(&e); ++ unlocked_state.fail_outbound_payment(payment_id, code); + status = HtlcStatus::Failed; +- unlocked_state.update_outbound_payment_status(payment_id, status); ++ failure_code = Some(code.to_owned()); + } + }; + +@@ -3345,6 +4343,7 @@ pub(crate) async fn send_payment( + payment_hash: payment_hash.map(|h| hex_str(&h.0)), + payment_secret: payment_secret.map(|s| hex_str(&s.0)), + status, ++ failure_code, + }) + } + +@@ -3736,6 +4735,156 @@ pub(crate) async fn sync(state: Arc) -> Result<(), APIError> { + Ok(()) + } + ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub(crate) enum WalletSyncModeData { ++ Routine, ++ Recovery, ++} ++ ++pub(crate) struct WalletSyncKeychainData { ++ pub succeeded: bool, ++ pub error_code: Option, ++ pub checkpoint: Option, ++} ++ ++pub(crate) struct WalletSyncData { ++ pub mode: WalletSyncModeData, ++ pub vanilla: WalletSyncKeychainData, ++ pub colored: WalletSyncKeychainData, ++} ++ ++fn sync_keychain( ++ unlocked_state: &crate::utils::UnlockedAppState, ++ network: RgbBitcoinNetwork, ++ keychain: rgb_lib::wallet::SyncKeychain, ++ strategy: rgb_lib::wallet::SyncStrategy, ++) -> WalletSyncKeychainData { ++ if unlocked_state ++ .rgb_sync(rgb_lib::wallet::SyncOptions { keychain, strategy }) ++ .is_err() ++ { ++ return WalletSyncKeychainData { ++ succeeded: false, ++ error_code: Some("FAILED_BDK_SYNC".to_string()), ++ checkpoint: None, ++ }; ++ } ++ ++ match unlocked_state.address_indexer.chain_checkpoint() { ++ Ok(checkpoint) => WalletSyncKeychainData { ++ succeeded: true, ++ error_code: None, ++ checkpoint: Some(NetworkInfoData { ++ network, ++ height: checkpoint.height, ++ block_hash: checkpoint.block_hash.to_string(), ++ }), ++ }, ++ Err(_) => WalletSyncKeychainData { ++ succeeded: false, ++ error_code: Some("FAILED_CHECKPOINT_READ".to_string()), ++ checkpoint: None, ++ }, ++ } ++} ++ ++fn sync_strategy_for_mode(mode: WalletSyncModeData) -> rgb_lib::wallet::SyncStrategy { ++ match mode { ++ WalletSyncModeData::Routine => rgb_lib::wallet::SyncStrategy::FullSync, ++ WalletSyncModeData::Recovery => rgb_lib::wallet::SyncStrategy::FullScan, ++ } ++} ++ ++/// Synchronize both RGB-lib keychains using an explicit production mode. ++/// ++/// Routine refresh uses `FullSync`, which updates every script already known ++/// to the wallet. Recovery uses `FullScan`, which performs address discovery ++/// and must therefore be reserved for create/restore/migration boundaries. ++/// Both keychains are attempted so callers receive structured partial-failure ++/// evidence instead of losing the first successful result when the second ++/// keychain fails. ++pub(crate) async fn sync_wallet( ++ state: Arc, ++ mode: WalletSyncModeData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let strategy = sync_strategy_for_mode(mode); ++ let network = state.static_state.network; ++ ++ for _ in 0..2 { ++ let round_before = unlocked_state ++ .address_indexer ++ .chain_checkpoint() ++ .map_err(APIError::InvalidIndexer)?; ++ let vanilla = sync_keychain( ++ unlocked_state, ++ network, ++ rgb_lib::wallet::SyncKeychain::Vanilla { lookback: 0 }, ++ strategy, ++ ); ++ let colored = sync_keychain( ++ unlocked_state, ++ network, ++ rgb_lib::wallet::SyncKeychain::Colored, ++ strategy, ++ ); ++ let round_after = unlocked_state ++ .address_indexer ++ .chain_checkpoint() ++ .map_err(APIError::InvalidIndexer)?; ++ let expected_hash = round_before.block_hash.to_string(); ++ let checkpoint_matches = |checkpoint: &NetworkInfoData| { ++ checkpoint.height == round_before.height && checkpoint.block_hash == expected_hash ++ }; ++ let coherent = round_before == round_after ++ && vanilla.checkpoint.as_ref().is_some_and(checkpoint_matches) ++ && colored.checkpoint.as_ref().is_some_and(checkpoint_matches); ++ if coherent || !vanilla.succeeded || !colored.succeeded { ++ return Ok(WalletSyncData { ++ mode, ++ vanilla, ++ colored, ++ }); ++ } ++ } ++ ++ Ok(WalletSyncData { ++ mode, ++ vanilla: WalletSyncKeychainData { ++ succeeded: false, ++ error_code: Some("CHAIN_CHECKPOINT_UNSTABLE".to_string()), ++ checkpoint: None, ++ }, ++ colored: WalletSyncKeychainData { ++ succeeded: false, ++ error_code: Some("CHAIN_CHECKPOINT_UNSTABLE".to_string()), ++ checkpoint: None, ++ }, ++ }) ++} ++ ++#[cfg(test)] ++mod wallet_sync_mode_tests { ++ use super::*; ++ ++ #[test] ++ fn routine_syncs_every_revealed_script_on_both_keychains() { ++ assert_eq!( ++ sync_strategy_for_mode(WalletSyncModeData::Routine), ++ rgb_lib::wallet::SyncStrategy::FullSync ++ ); ++ } ++ ++ #[test] ++ fn recovery_performs_address_discovery_on_both_keychains() { ++ assert_eq!( ++ sync_strategy_for_mode(WalletSyncModeData::Recovery), ++ rgb_lib::wallet::SyncStrategy::FullScan ++ ); ++ } ++} ++ + pub(crate) async fn decode_ln_invoice( + state: Arc, + invoice: String, +@@ -3868,6 +5017,9 @@ pub(crate) async fn create_ln_invoice( + let description = + parse_invoice_description(description.as_deref(), description_hash.as_deref())?; + ++ let durable_rgb = contract_id ++ .as_ref() ++ .map(|contract_id| (contract_id.to_string(), asset_amount)); + let invoice_params = Bolt11InvoiceParameters { + amount_msats: amt_msat, + description, +@@ -3916,6 +5068,11 @@ pub(crate) async fn create_ln_invoice( + payment_idx: None, + async_hash_index: None, + async_host_node_id: None, ++ fee_paid_msat: None, ++ failure_code: None, ++ asset_id: durable_rgb.as_ref().map(|(asset_id, _)| asset_id.clone()), ++ asset_amount: durable_rgb.as_ref().and_then(|(_, amount)| *amount), ++ carrier_msat: durable_rgb.as_ref().and_then(|_| amt_msat), + }, + ); + +@@ -3931,6 +5088,41 @@ fn payment_type_from_invoice(invoice_type: Option) -> PaymentType { + } + } + ++fn payment_rgb_binding( ++ unlocked_state: &UnlockedAppState, ++ payment_hash: &PaymentHash, ++ inbound: bool, ++ payment_info: &PaymentInfo, ++) -> Result<(Option, Option, Option), APIError> { ++ if payment_info.asset_id.is_none() && payment_info.asset_amount.is_some() { ++ return Err(APIError::Unexpected(format!( ++ "payment {} has an RGB amount without an RGB contract ID", ++ hex_str(&payment_hash.0), ++ ))); ++ } ++ ++ if let Some(asset_id) = payment_info.asset_id.as_ref() { ++ return Ok(( ++ payment_info.asset_amount, ++ Some(asset_id.clone()), ++ payment_info.carrier_msat.or(payment_info.amt_msat), ++ )); ++ } ++ ++ let legacy = unlocked_state ++ .kv_store ++ .read_rgb_payment_info(payment_hash, inbound) ++ .ok(); ++ Ok(match legacy { ++ Some(info) => ( ++ Some(info.amount), ++ Some(info.contract_id.to_string()), ++ payment_info.amt_msat, ++ ), ++ None => (None, None, None), ++ }) ++} ++ + pub(crate) async fn list_payments(state: Arc) -> Result, APIError> { + let guard = check_unlocked(&state).await?; + let unlocked_state = guard.as_ref().unwrap(); +@@ -3941,52 +5133,52 @@ pub(crate) async fn list_payments(state: Arc) -> Result return Err(APIError::InvoiceNotClaimable), + } + +- unlocked_state +- .fail_htlc_backwards_and_update_inbound_payment(payment_hash, HtlcStatus::Cancelled); ++ unlocked_state.fail_htlc_backwards_and_update_inbound_payment( ++ payment_hash, ++ HtlcStatus::Cancelled, ++ "INVOICE_CANCELLED", ++ ); + Ok(()) + } + +@@ -4170,8 +5365,15 @@ pub(crate) async fn claim_hodl_invoice( + }; + + if let Some(terminal_error) = terminal_error { +- unlocked_state +- .fail_htlc_backwards_and_update_inbound_payment(payment_hash, HtlcStatus::Failed); ++ let failure_code = match &terminal_error { ++ APIError::ClaimDeadlineExceeded => "INVOICE_CLAIM_DEADLINE_EXCEEDED", ++ _ => "INVOICE_EXPIRED", ++ }; ++ unlocked_state.fail_htlc_backwards_and_update_inbound_payment( ++ payment_hash, ++ HtlcStatus::Failed, ++ failure_code, ++ ); + return Err(terminal_error); + } + +@@ -4388,7 +5590,7 @@ pub(crate) async fn list_transfers( + } + let filter = match asset_id { + Some(asset_id) => rgb_lib::wallet::AssetFilter::Id(asset_id), +- None => rgb_lib::wallet::AssetFilter::Any, ++ None => rgb_lib::wallet::AssetFilter::AnyOrNone, + }; + Ok(unlocked_state + .rgb_list_transfers(filter, txid)? +@@ -4450,6 +5652,26 @@ mod tests { + use tokio::sync::Mutex as TokioMutex; + use tokio_util::sync::CancellationToken; + ++ #[test] ++ fn bolt11_initiation_failures_have_stable_codes() { ++ assert_eq!( ++ bolt11_payment_failure_code(&Bolt11PaymentError::InvalidAmount), ++ "INVALID_PAYMENT_AMOUNT" ++ ); ++ assert_eq!( ++ bolt11_payment_failure_code(&Bolt11PaymentError::SendingFailed( ++ RetryableSendFailure::RouteNotFound, ++ )), ++ "ROUTE_NOT_FOUND" ++ ); ++ assert_eq!( ++ bolt11_payment_failure_code(&Bolt11PaymentError::SendingFailed( ++ RetryableSendFailure::DuplicatePayment, ++ )), ++ "DUPLICATE_PAYMENT" ++ ); ++ } ++ + #[test] + fn verify_message_signature_accepts_known_lightning_vector_and_rejects_tampering() { + let message = b"is this compatible?"; +@@ -4511,13 +5733,7 @@ mod tests { + } + + fn mock_locked_state() -> Arc { +- let unique = format!( +- "rln-sdk-external-tests-{}", +- std::time::SystemTime::now() +- .duration_since(std::time::UNIX_EPOCH) +- .expect("clock") +- .as_nanos() +- ); ++ let unique = format!("rln-sdk-external-tests-{}", uuid::Uuid::new_v4()); + let storage_dir = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&storage_dir).expect("create temp storage dir"); + let db_path = storage_dir.join("rln_db"); +diff --git a/src/signer/channel_signer.rs b/src/signer/channel_signer.rs +index 6e24dec..c38c65a 100644 +--- a/src/signer/channel_signer.rs ++++ b/src/signer/channel_signer.rs +@@ -410,15 +410,58 @@ impl ChannelSigner for ExternalChannelSigner { + idx: u64, + _secp_ctx: &Secp256k1, + ) -> Result { +- let point_hex = self.get_per_commitment_point(idx).map_err(|_| ())?; +- let point = Vec::::from_hex(&point_hex).map_err(|_| ())?; +- PublicKey::from_slice(&point).map_err(|_| ()) ++ let point_hex = self.get_per_commitment_point(idx).map_err(|err| { ++ tracing::error!( ++ channel_keys_id = %self.channel_keys_id_hex, ++ idx, ++ error = %err, ++ "external signer get_per_commitment_point failed" ++ ); ++ })?; ++ let point = Vec::::from_hex(&point_hex).map_err(|err| { ++ tracing::error!( ++ channel_keys_id = %self.channel_keys_id_hex, ++ idx, ++ error = %err, ++ "external signer returned an invalid per-commitment point encoding" ++ ); ++ })?; ++ PublicKey::from_slice(&point).map_err(|err| { ++ tracing::error!( ++ channel_keys_id = %self.channel_keys_id_hex, ++ idx, ++ error = %err, ++ "external signer returned an invalid per-commitment point" ++ ); ++ }) + } + + fn release_commitment_secret(&self, idx: u64) -> Result<[u8; 32], ()> { +- let secret_hex = self.release_commitment_secret(idx).map_err(|_| ())?; +- let secret = Vec::::from_hex(&secret_hex).map_err(|_| ())?; +- secret.try_into().map_err(|_| ()) ++ let secret_hex = self.release_commitment_secret(idx).map_err(|err| { ++ tracing::error!( ++ channel_keys_id = %self.channel_keys_id_hex, ++ idx, ++ error = %err, ++ "external signer release_commitment_secret failed" ++ ); ++ })?; ++ let secret = Vec::::from_hex(&secret_hex).map_err(|err| { ++ tracing::error!( ++ channel_keys_id = %self.channel_keys_id_hex, ++ idx, ++ error = %err, ++ "external signer returned an invalid commitment-secret encoding" ++ ); ++ })?; ++ let decoded_length = secret.len(); ++ secret.try_into().map_err(|_| { ++ tracing::error!( ++ channel_keys_id = %self.channel_keys_id_hex, ++ idx, ++ decoded_length, ++ "external signer returned a commitment secret with the wrong length" ++ ); ++ }) + } + + fn validate_holder_commitment( +diff --git a/src/test/address_reuse.rs b/src/test/address_reuse.rs +index ce82964..dc16cdb 100644 +--- a/src/test/address_reuse.rs ++++ b/src/test/address_reuse.rs +@@ -92,17 +92,18 @@ async fn rotate_advances_pin_and_new_address_receives_funds() { + + let rotated = rotate_address(node_addr).await; + assert_ne!(rotated, pinned); ++ // Fund before calling `/address` again. A follow-up address lookup reveals ++ // the script to BDK and used to hide a rotate-only discovery defect. ++ fund_and_mine(rotated.clone(), FUND_SATS); ++ assert!( ++ settled_btc(node_addr).await >= 2 * FUND_SATS, ++ "funds sent to the rotated address must be spendable" ++ ); + assert_eq!( + rotated, + address(node_addr).await, + "the pin must move to the rotated address" + ); +- +- fund_and_mine(rotated, FUND_SATS); +- assert!( +- settled_btc(node_addr).await >= 2 * FUND_SATS, +- "funds sent to the rotated address must be spendable" +- ); + } + + /// `/rotateaddress` is rejected when reuse is disabled. +diff --git a/src/test/lib_sdk/close_coop_vanilla.rs b/src/test/lib_sdk/close_coop_vanilla.rs +index c25fcd9..0dec51b 100644 +--- a/src/test/lib_sdk/close_coop_vanilla.rs ++++ b/src/test/lib_sdk/close_coop_vanilla.rs +@@ -207,6 +207,7 @@ fn run_close_coop_vanilla(name: &str, port_offset: u16, with_anchors: bool) { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("node B sendpayment"); + let payment_hash = send_payment.payment_hash.expect("vanilla payment hash"); +diff --git a/src/test/lib_sdk/external_signer.rs b/src/test/lib_sdk/external_signer.rs +index 5d08e02..7129d73 100644 +--- a/src/test/lib_sdk/external_signer.rs ++++ b/src/test/lib_sdk/external_signer.rs +@@ -683,6 +683,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_quick() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("sendpayment"); + let payment_hash = send.payment_hash.expect("payment_hash"); +@@ -700,6 +701,185 @@ fn rgb_native_external_signer_mixed_one_hop_payment_quick() { + } + } + ++/// Production-shaped RGB virtual-channel regression: an internal-signer LSP opens a ++/// `trusted_no_broadcast` channel to a persistent native external-signer wallet, keeps the ++/// colored balance on the LSP side, and pays the first asset-bound invoice created by the wallet. ++/// ++/// Regtest: `./regtest.sh start`, then: ++/// `cargo test -p rgb-lightning-node --features uniffi,test-utils,vls --test lib_sdk rgb_native_external_signer_accepts_virtual_channel_payment -- --nocapture` ++#[test] ++#[serial] ++fn rgb_native_external_signer_accepts_virtual_channel_payment() { ++ ensure_regtest_available(); ++ let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner()); ++ ++ const PORT_OFF: u16 = 450; ++ const CHANNEL_ASSET_AMOUNT: u64 = 1_000; ++ const PAYMENT_ASSET_AMOUNT: u64 = 100; ++ let lsp_daemon_port = NODE_A_DAEMON_PORT + PORT_OFF; ++ let lsp_peer_port = NODE_A_PEER_PORT + PORT_OFF; ++ let wallet_daemon_port = NODE_B_DAEMON_PORT + PORT_OFF; ++ let wallet_peer_port = NODE_B_PEER_PORT + PORT_OFF; ++ ++ let test_dir = test_dir("sdk_rgb_native_external_virtual_receive"); ++ if test_dir.exists() { ++ fs::remove_dir_all(&test_dir).expect("remove previous lib_sdk test dir"); ++ } ++ fs::create_dir_all(&test_dir).expect("create lib_sdk test dir"); ++ let lsp_dir = test_dir.join("lsp"); ++ let wallet_dir = test_dir.join("wallet"); ++ let signer_dir = test_dir.join("wallet_signer"); ++ ++ let signer = make_native_signer_with_storage(&signer_dir, None); ++ let wallet_pubkey = signer ++ .bootstrap() ++ .expect("signer bootstrap") ++ .node_id ++ .parse::() ++ .expect("wallet pubkey"); ++ ++ let lsp = make_node_with_virtual(&lsp_dir, lsp_daemon_port, lsp_peer_port, None); ++ lsp.init("lspPassword".to_string(), None).expect("LSP init"); ++ lsp.unlock(unlock_request("lspPassword")) ++ .expect("LSP unlock"); ++ let lsp_pubkey = lsp.node_info().expect("LSP node info").pubkey; ++ let wallet = make_node_with_virtual( ++ &wallet_dir, ++ wallet_daemon_port, ++ wallet_peer_port, ++ Some(vec![lsp_pubkey]), ++ ); ++ ++ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { ++ wallet ++ .init_with_native_external_signer(signer.clone()) ++ .expect("wallet init with native external signer"); ++ ++ wallet ++ .unlock_with_native_external_signer( ++ signer.clone(), ++ Some("user".to_string()), ++ Some("password".to_string()), ++ Some("localhost".to_string()), ++ Some(18443), ++ Some("127.0.0.1:50001".to_string()), ++ Some(PROXY_ENDPOINT_LOCAL.to_string()), ++ vec![], ++ Some("RLN_rgb_native_virtual_wallet".to_string()), ++ ) ++ .expect("wallet unlock with native external signer"); ++ ++ fund_and_create_utxos(&lsp, "LSP"); ++ lsp.createutxos(SdkCreateUtxosRequest { ++ up_to: false, ++ num: Some(25), ++ size: Some(32_000), ++ fee_rate: CREATE_UTXOS_FEE_RATE, ++ skip_sync: false, ++ }) ++ .expect("LSP createutxos for RGB allocation headroom"); ++ ++ let asset_id = lsp ++ .issueassetnia(SdkIssueAssetNiaRequest { ++ amounts: vec![10_000], ++ ticker: "VIRT".to_string(), ++ name: "VirtualRgb".to_string(), ++ precision: 0, ++ }) ++ .expect("issue virtual-channel asset") ++ .asset_id; ++ ++ let wallet_uri = format!("{wallet_pubkey}@127.0.0.1:{wallet_peer_port}"); ++ lsp.connectpeer(wallet_uri.clone()).expect("connect wallet"); ++ lsp.openchannel(SdkOpenChannelRequest { ++ peer_pubkey_and_opt_addr: wallet_uri, ++ capacity_sat: 200_000, ++ push_msat: 0, ++ public: false, ++ with_anchors: true, ++ fee_base_msat: None, ++ fee_proportional_millionths: None, ++ temporary_channel_id: None, ++ asset_id: Some(asset_id.clone()), ++ asset_amount: Some(CHANNEL_ASSET_AMOUNT), ++ push_asset_amount: None, ++ virtual_open_mode: Some("trusted_no_broadcast".to_string()), ++ }) ++ .expect("LSP opens RGB trusted virtual channel"); ++ ++ let wait_usable = |node: &SdkNode, peer: &str, label: &str| { ++ let deadline = std::time::Instant::now() + Duration::from_secs(60); ++ loop { ++ if node ++ .list_channels() ++ .unwrap_or_default() ++ .iter() ++ .any(|channel| { ++ channel.peer_pubkey.to_string() == peer ++ && channel.ready ++ && channel.is_usable ++ && channel.asset_id.as_ref() == Some(&asset_id) ++ }) ++ { ++ break; ++ } ++ assert!( ++ std::time::Instant::now() < deadline, ++ "{label} RGB virtual channel did not become usable" ++ ); ++ thread::sleep(Duration::from_millis(500)); ++ } ++ }; ++ ++ let lsp_pubkey = lsp_pubkey.to_string(); ++ wait_usable(&lsp, &wallet_pubkey.to_string(), "LSP"); ++ wait_usable(&wallet, &lsp_pubkey, "wallet"); ++ ++ let invoice = wallet ++ .ln_invoice(LnInvoiceRequest { ++ amt_msat: Some(PAYMENT_MSAT), ++ expiry_sec: 900, ++ asset_id: Some(asset_id.clone()), ++ asset_amount: Some(PAYMENT_ASSET_AMOUNT), ++ payment_hash: None, ++ description_hash: None, ++ min_final_cltv_expiry_delta: None, ++ }) ++ .expect("wallet creates RGB Lightning invoice") ++ .invoice; ++ ++ let payment_hash = lsp ++ .sendpayment(SdkSendPaymentRequest { ++ invoice: invoice.to_string(), ++ amt_msat: None, ++ asset_id: None, ++ asset_amount: None, ++ max_total_routing_fee_msat: None, ++ }) ++ .expect("LSP sends RGB virtual-channel payment") ++ .payment_hash ++ .expect("payment hash"); ++ ++ wait_for_payment_status(&lsp, &payment_hash, Duration::from_secs(120)); ++ wait_for_payment_status(&wallet, &payment_hash, Duration::from_secs(120)); ++ wait_for_ln_balance( ++ &wallet, ++ &asset_id, ++ PAYMENT_ASSET_AMOUNT, ++ Duration::from_secs(120), ++ ); ++ ++ lsp.shutdown(); ++ wallet.shutdown(); ++ thread::sleep(Duration::from_millis(300)); ++ })); ++ ++ if result.is_err() { ++ lsp.shutdown(); ++ panic!("rgb_native_external_signer_accepts_virtual_channel_payment failed"); ++ } ++} ++ + /// Mixed internal/external RGB channel: after receiving RGB, the external-signer node must be able + /// to send RGB back over the same channel. + #[test] +@@ -996,6 +1176,7 @@ fn rgb_native_external_signer_mixed_one_hop_payment_coop_close_settles_to_chain( + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("sendpayment"); + let payment_hash = send.payment_hash.expect("payment_hash"); +@@ -1133,6 +1314,7 @@ fn external_signer_virtual_channel_survives_restart() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .unwrap_or_else(|e| panic!("{label}: sendpayment: {e:?}")); + let payment_hash = send.payment_hash.expect("payment_hash"); +diff --git a/src/test/lib_sdk/helpers.rs b/src/test/lib_sdk/helpers.rs +index a20ac6d..095b12c 100644 +--- a/src/test/lib_sdk/helpers.rs ++++ b/src/test/lib_sdk/helpers.rs +@@ -3,11 +3,11 @@ use once_cell::sync::Lazy; + pub(crate) use rgb_lightning_node::{ + AssetBalanceInfo, AssetRecipients, AssignmentKind, Channel, ContractId, HtlcStatus, + InvoiceStatus, LnInvoiceRequest, Payment, PaymentHash, RecipientId, RgbRecipient, +- SdkCloseChannelRequest, SdkCreateUtxosRequest, SdkExternalSignerBootstrap, SdkInitRequest, +- SdkIssueAssetCfaRequest, SdkIssueAssetNiaRequest, SdkKeysendRequest, SdkNode, +- SdkOpenChannelRequest, SdkRefreshTransfersRequest, SdkRgbInvoiceRequest, SdkSendBtcRequest, +- SdkSendPaymentRequest, SdkUnlockRequest, SdkVssClearFenceRequest, SendRgbRequest, +- TransactionType, TransportEndpoint, WitnessData, ++ SdkCloseChannelRequest, SdkCommitPreparedSendRequest, SdkCreateUtxosRequest, ++ SdkExternalSignerBootstrap, SdkInitRequest, SdkIssueAssetCfaRequest, SdkIssueAssetNiaRequest, ++ SdkKeysendRequest, SdkNode, SdkOpenChannelRequest, SdkRefreshTransfersRequest, ++ SdkRgbInvoiceRequest, SdkSendBtcRequest, SdkSendPaymentRequest, SdkUnlockRequest, ++ SdkVssClearFenceRequest, SendRgbRequest, TransactionType, TransportEndpoint, WitnessData, + }; + use std::fs; + use std::path::{Path, PathBuf}; +@@ -232,6 +232,30 @@ pub(crate) fn make_node( + ) + } + ++pub(crate) fn make_node_with_reuse_addresses( ++ storage_dir_path: &Path, ++ daemon_listening_port: u16, ++ ldk_peer_listening_port: u16, ++) -> SdkNode { ++ fs::create_dir_all(storage_dir_path).expect("create storage dir"); ++ SdkNode::create(SdkInitRequest { ++ storage_dir_path: storage_dir_path.display().to_string(), ++ daemon_listening_port, ++ ldk_peer_listening_port, ++ network: "regtest".to_string(), ++ max_media_upload_size_mb: 20, ++ enable_virtual_channels_v0: Some(false), ++ virtual_peer_pubkeys: None, ++ lsp_base_url: None, ++ lsp_bearer_token: None, ++ vss_url: None, ++ vss_allow_http: true, ++ vss_allow_empty_restore: false, ++ reuse_addresses: true, ++ }) ++ .expect("create SDK node with address reuse") ++} ++ + #[allow(dead_code)] // used by VSS-only tests + pub(crate) fn make_node_with_vss( + storage_dir_path: &Path, +@@ -826,6 +850,7 @@ pub(crate) fn send_payment_with_ln_balance( + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("sendpayment with ln balance checks"); + +diff --git a/src/test/lib_sdk/mod.rs b/src/test/lib_sdk/mod.rs +index 25c7d7d..259b326 100644 +--- a/src/test/lib_sdk/mod.rs ++++ b/src/test/lib_sdk/mod.rs +@@ -13,6 +13,7 @@ mod multi_hop; + mod openchannel_push_asset_amount; + mod payment; + mod restart; ++mod rgb_utxo_isolation; + mod send_receive; + mod swap_roundtrip_buy; + mod vanilla_payment_on_rgb_channel; +diff --git a/src/test/lib_sdk/multi_hop.rs b/src/test/lib_sdk/multi_hop.rs +index 9f858b9..8454264 100644 +--- a/src/test/lib_sdk/multi_hop.rs ++++ b/src/test/lib_sdk/multi_hop.rs +@@ -254,6 +254,7 @@ fn multi_hop() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("node A sendpayment"); + let payment_hash = send_payment +diff --git a/src/test/lib_sdk/payment.rs b/src/test/lib_sdk/payment.rs +index b2697af..a0f5ea1 100644 +--- a/src/test/lib_sdk/payment.rs ++++ b/src/test/lib_sdk/payment.rs +@@ -288,6 +288,7 @@ fn success() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("node A sendpayment third"); + let decoded = node_a +@@ -323,6 +324,7 @@ fn success() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("node B sendpayment fourth"); + let decoded = node_a +diff --git a/src/test/lib_sdk/restart.rs b/src/test/lib_sdk/restart.rs +index f3d1548..456d315 100644 +--- a/src/test/lib_sdk/restart.rs ++++ b/src/test/lib_sdk/restart.rs +@@ -170,6 +170,7 @@ fn restart() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("node A sendpayment"); + let payment_hash = send_payment.payment_hash.expect("payment hash"); +diff --git a/src/test/lib_sdk/rgb_utxo_isolation.rs b/src/test/lib_sdk/rgb_utxo_isolation.rs +new file mode 100644 +index 0000000..eb3dd0f +--- /dev/null ++++ b/src/test/lib_sdk/rgb_utxo_isolation.rs +@@ -0,0 +1,140 @@ ++use crate::helpers::*; ++use sea_orm::{ConnectionTrait, Database, DbBackend, Statement}; ++use serial_test::serial; ++use std::fs; ++use std::path::Path; ++ ++const SETUP_OUTPUT_COUNT: u8 = 4; ++const SETUP_OUTPUT_SIZE_SAT: u32 = 20_000; ++ ++fn sqlite_scalar(database: &Path, query: &str) -> i64 { ++ let runtime = tokio::runtime::Runtime::new().expect("create SQLite test runtime"); ++ let connection = runtime ++ .block_on(Database::connect(format!( ++ "sqlite:{}?mode=ro", ++ database.display() ++ ))) ++ .expect("open RGB wallet database"); ++ let row = runtime ++ .block_on(connection.query_one(Statement::from_string(DbBackend::Sqlite, query.to_owned()))) ++ .expect("query RGB wallet database") ++ .expect("SQLite scalar row"); ++ row.try_get_by_index(0).expect("SQLite scalar value") ++} ++ ++fn rgb_database(node_dir: &Path) -> std::path::PathBuf { ++ let mut databases = fs::read_dir(node_dir) ++ .expect("read node storage directory") ++ .filter_map(Result::ok) ++ .map(|entry| entry.path().join("rgb_lib_db")) ++ .filter(|path| path.is_file()) ++ .collect::>(); ++ assert_eq!( ++ databases.len(), ++ 1, ++ "expected one initialized RGB wallet database under node storage" ++ ); ++ databases.pop().expect("RGB wallet database") ++} ++ ++fn assert_setup_outputs_are_available(database: &Path, txid: &str) { ++ let output_count = sqlite_scalar( ++ database, ++ &format!("select count(*) from txo where txid = '{txid}';"), ++ ); ++ assert_eq!(output_count, i64::from(SETUP_OUTPUT_COUNT)); ++ let pending_count = sqlite_scalar( ++ database, ++ &format!("select count(*) from txo where txid = '{txid}' and pending_witness = 1;"), ++ ); ++ assert_eq!( ++ pending_count, 0, ++ "isolated RGB setup outputs must remain available for sends" ++ ); ++} ++ ++#[test] ++#[serial] ++fn prepared_rgb_utxos_are_isolated_from_existing_and_future_witness_invoices() { ++ ensure_regtest_available(); ++ ++ let test_dir = test_dir("sdk_rgb_utxo_isolation"); ++ if test_dir.exists() { ++ fs::remove_dir_all(&test_dir).expect("remove previous isolation test dir"); ++ } ++ fs::create_dir_all(&test_dir).expect("create isolation test dir"); ++ let node_dir = test_dir.join("node"); ++ let node = ++ make_node_with_reuse_addresses(&node_dir, NODE_A_DAEMON_PORT + 750, NODE_A_PEER_PORT + 750); ++ ++ let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { ++ node.init("isolation-pass".to_string(), None) ++ .expect("initialize isolation wallet"); ++ node.unlock(unlock_request("isolation-pass")) ++ .expect("unlock isolation wallet"); ++ ensure_funded(&node, 100_000, "isolation wallet"); ++ let database = rgb_database(&node_dir); ++ ++ let existing_invoice = node ++ .rgbinvoice(SdkRgbInvoiceRequest { ++ asset_id: None, ++ assignment_kind: Some(AssignmentKind::Fungible), ++ assignment_amount: Some(1), ++ duration_seconds: Some(3_600), ++ min_confirmations: 1, ++ witness: true, ++ }) ++ .expect("create existing witness invoice"); ++ ++ let plan = node ++ .prepare_create_utxos(SdkCreateUtxosRequest { ++ up_to: false, ++ num: Some(SETUP_OUTPUT_COUNT), ++ size: Some(SETUP_OUTPUT_SIZE_SAT), ++ fee_rate: CREATE_UTXOS_FEE_RATE, ++ skip_sync: false, ++ }) ++ .expect("prepare isolated RGB UTXO setup"); ++ ++ assert_eq!( ++ sqlite_scalar( ++ &database, ++ "select derivation_index from reuse_address_index where keychain = 0;" ++ ), ++ 2, ++ "setup preparation must use one colored address and reserve the next for receives" ++ ); ++ ++ node.commit_prepared_create_utxos(SdkCommitPreparedSendRequest { ++ plan_id: plan.plan_id, ++ }) ++ .expect("commit isolated RGB UTXO setup"); ++ mine(1); ++ node.sync().expect("sync committed RGB UTXO setup"); ++ let setup_txid = plan.plan_id.to_string(); ++ assert_setup_outputs_are_available(&database, &setup_txid); ++ ++ let future_invoice = node ++ .rgbinvoice(SdkRgbInvoiceRequest { ++ asset_id: None, ++ assignment_kind: Some(AssignmentKind::Fungible), ++ assignment_amount: Some(1), ++ duration_seconds: Some(3_600), ++ min_confirmations: 1, ++ witness: true, ++ }) ++ .expect("create future witness invoice"); ++ assert_ne!( ++ future_invoice.recipient_id.0, existing_invoice.recipient_id.0, ++ "future witness invoices must not reuse the pre-setup receive script" ++ ); ++ ++ node.sync().expect("sync after future witness invoice"); ++ assert_setup_outputs_are_available(&database, &setup_txid); ++ })); ++ ++ node.shutdown(); ++ if let Err(panic) = result { ++ std::panic::resume_unwind(panic); ++ } ++} +diff --git a/src/test/lib_sdk/vanilla_payment_on_rgb_channel.rs b/src/test/lib_sdk/vanilla_payment_on_rgb_channel.rs +index 84e5e58..a57d1bf 100644 +--- a/src/test/lib_sdk/vanilla_payment_on_rgb_channel.rs ++++ b/src/test/lib_sdk/vanilla_payment_on_rgb_channel.rs +@@ -115,6 +115,7 @@ fn vanilla_payment_on_rgb_channel() { + amt_msat: None, + asset_id: None, + asset_amount: None, ++ max_total_routing_fee_msat: None, + }) + .expect("node A sendpayment"); + let payment_hash = send_payment +diff --git a/src/test/vss.rs b/src/test/vss.rs +index ab7e5f4..4fc2e95 100644 +--- a/src/test/vss.rs ++++ b/src/test/vss.rs +@@ -12,6 +12,7 @@ mod tests { + use hex::DisplayHex; + use lightning::util::persist::KVStoreSync; + use sea_orm::{ConnectOptions, Database}; ++ use uuid::Uuid; + + use crate::kv_store::SeaOrmKvStore; + use crate::synced_kv_store::SyncedKvStore; +@@ -573,6 +574,59 @@ mod tests { + .expect("b acquires fence after clear"); + } + ++ /// An abrupt process exit leaves the remote fence in place. A restart ++ /// from the same local node data must reuse its writer identity and reclaim ++ /// that fence without weakening the different-installation exclusion. ++ #[tokio::test(flavor = "multi_thread", worker_threads = 2)] ++ async fn vss_same_installation_reclaims_fence_after_restart() { ++ if !vss_server_available() { ++ eprintln!("SKIP: VSS server not available at {VSS_URL}"); ++ return; ++ } ++ ++ let (signing_key, store_id) = generate_test_keys(); ++ let writer_id = Uuid::new_v4(); ++ let retry = crate::config::VssSection::default(); ++ let original = VssKvStore::new_with_retry_and_instance_id( ++ VSS_URL.to_string(), ++ store_id.clone(), ++ signing_key, ++ &retry, ++ writer_id, ++ ) ++ .expect("original store"); ++ original.acquire_fence().expect("original acquires fence"); ++ drop(original); ++ ++ let restarted = VssKvStore::new_with_retry_and_instance_id( ++ VSS_URL.to_string(), ++ store_id.clone(), ++ signing_key, ++ &retry, ++ writer_id, ++ ) ++ .expect("restarted store"); ++ restarted ++ .acquire_fence() ++ .expect("same installation reclaims its fence"); ++ ++ let other_installation = VssKvStore::new_with_retry_and_instance_id( ++ VSS_URL.to_string(), ++ store_id, ++ signing_key, ++ &retry, ++ Uuid::new_v4(), ++ ) ++ .expect("other store"); ++ other_installation ++ .acquire_fence() ++ .expect_err("a different installation must remain fenced out"); ++ ++ restarted ++ .release_fence_if_owned() ++ .expect("test cleanup releases fence"); ++ } ++ + /// `delete_fence` is idempotent: clearing an absent fence must not error, + /// so it stays safe to call defensively before unlock. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +@@ -698,8 +752,8 @@ mod tests { + crate::test::shutdown(&[node_address]).await; + } + +- /// A graceful lock must release the VSS fence: the next unlock runs under +- /// a fresh instance id and must take over without `/vssclearfence`. ++ /// A graceful lock must release the VSS fence so the next unlock can ++ /// proceed without `/vssclearfence`. + #[serial_test::serial] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn vss_lock_releases_fence_for_next_unlock() { +diff --git a/src/uniffi_api/mod.rs b/src/uniffi_api/mod.rs +index 8b5149d..3d49316 100644 +--- a/src/uniffi_api/mod.rs ++++ b/src/uniffi_api/mod.rs +@@ -116,11 +116,8 @@ fn handle_from_request(request: SdkInitRequest) -> Result + block_on_app(NodeHandle::new(config)) + } + +-fn send_rgb_from_state( +- state: std::sync::Arc, +- request: SendRgbRequest, +-) -> Result { +- let sdk_request = sdk::SendRgbRequestData { ++fn into_sdk_send_rgb_request(request: SendRgbRequest) -> sdk::SendRgbRequestData { ++ sdk::SendRgbRequestData { + donation: request.donation, + fee_rate: request.fee_rate, + min_confirmations: request.min_confirmations, +@@ -157,9 +154,17 @@ fn send_rgb_from_state( + .collect(), + }) + .collect(), +- }; ++ } ++} + +- let data = block_on_sdk(sdk::send_rgb_from_groups(state, sdk_request))?; ++fn send_rgb_from_state( ++ state: std::sync::Arc, ++ request: SendRgbRequest, ++) -> Result { ++ let data = block_on_sdk(sdk::send_rgb_from_groups( ++ state, ++ into_sdk_send_rgb_request(request), ++ ))?; + let txid = Txid::from_str(&data.txid).map_err(RlnError::internal)?; + Ok(SendRgbResponse { + txid, +@@ -193,15 +198,19 @@ fn map_payment_data(data: crate::sdk::PaymentData) -> Result + amt_msat: data.amt_msat, + asset_amount: data.asset_amount, + asset_id, ++ carrier_msat: data.carrier_msat, + payment_hash, + payment_type, + status, + created_at: data.created_at, + updated_at: data.updated_at, ++ expires_at: data.expires_at, + payee_pubkey, + preimage: data.preimage, + description: data.description, + description_hash: data.description_hash, ++ fee_paid_msat: data.fee_paid_msat, ++ failure_code: data.failure_code, + }) + } + +@@ -273,6 +282,24 @@ fn map_asset_link(data: AssetLinkResponse) -> Result + }) + } + ++fn map_asset_metadata_data(data: crate::sdk::AssetMetadataData) -> AssetMetadataInfo { ++ AssetMetadataInfo { ++ asset_schema: format!("{:?}", data.asset_schema), ++ initial_supply: data.initial_supply, ++ max_supply: data.max_supply, ++ known_circulating_supply: data.known_circulating_supply, ++ timestamp: data.timestamp, ++ name: data.name, ++ precision: data.precision, ++ ticker: data.ticker, ++ details: data.details, ++ token: data.token.map(map_token), ++ unspent_link_right_outpoint: data.unspent_link_right_outpoint.map(map_rgb_outpoint), ++ linked_from_asset_id: data.linked_from_asset_id, ++ linked_to_asset_id: data.linked_to_asset_id, ++ } ++} ++ + fn map_media(data: crate::sdk::Media) -> Media { + Media { + file_path: data.file_path, +@@ -352,11 +379,38 @@ fn map_transaction(tx: crate::sdk::TransactionData) -> Result RgbAssignmentInfo { ++ match assignment { ++ rgb_lib::Assignment::Fungible(amount) => RgbAssignmentInfo { ++ kind: "Fungible".to_string(), ++ amount: Some(amount), ++ }, ++ rgb_lib::Assignment::NonFungible => RgbAssignmentInfo { ++ kind: "NonFungible".to_string(), ++ amount: None, ++ }, ++ rgb_lib::Assignment::InflationRight(amount) => RgbAssignmentInfo { ++ kind: "InflationRight".to_string(), ++ amount: Some(amount), ++ }, ++ rgb_lib::Assignment::Any => RgbAssignmentInfo { ++ kind: "Any".to_string(), ++ amount: None, ++ }, ++ rgb_lib::Assignment::LinkRight => RgbAssignmentInfo { ++ kind: "LinkRight".to_string(), ++ amount: None, ++ }, ++ } ++} ++ + fn map_transfer(t: crate::sdk::TransferData) -> Result { + let txid = t + .txid + .map(|v| Txid::from_str(&v).map_err(RlnError::internal)) + .transpose()?; ++ let requested_assignment_structured = t.requested_assignment.clone().map(map_assignment); ++ let assignments_structured = t.assignments.iter().cloned().map(map_assignment).collect(); + Ok(Transfer { + idx: t.idx, + created_at: t.created_at, +@@ -368,6 +422,8 @@ fn map_transfer(t: crate::sdk::TransferData) -> Result { + .into_iter() + .map(|a| format!("{:?}", a)) + .collect(), ++ requested_assignment_structured, ++ assignments_structured, + kind: format!("{:?}", t.kind), + txid, + recipient_id: t.recipient_id, +@@ -456,6 +512,22 @@ impl SdkNode { + Ok(version) + } + ++ pub fn vss_delete_all(&self, request: SdkVssClearFenceRequest) -> Result { ++ let state = self.handle.app_state(); ++ let handle = self.handle.clone(); ++ let deleted = block_on_sdk(async move { ++ handle.shutdown().await; ++ sdk::vss_delete_all( ++ state, ++ sdk::VssClearFenceRequest { ++ password: request.password, ++ }, ++ ) ++ .await ++ })?; ++ Ok(deleted as u64) ++ } ++ + pub fn connectpeer(&self, peer_pubkey_and_addr: String) -> Result<(), RlnError> { + let state = self.handle.app_state(); + block_on_sdk(sdk::connect_peer(state, peer_pubkey_and_addr))?; +@@ -501,6 +573,61 @@ impl SdkNode { + Ok(()) + } + ++ pub fn prepare_create_utxos( ++ &self, ++ request: SdkCreateUtxosRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::prepare_create_utxos( ++ self.handle.app_state(), ++ sdk::CreateUtxosRequestData { ++ up_to: request.up_to, ++ num: request.num, ++ size: request.size, ++ fee_rate: request.fee_rate, ++ skip_sync: request.skip_sync, ++ }, ++ ))?; ++ Ok(SdkPreparedCreateUtxosResponse { ++ plan_id: Txid::from_str(&response.plan.plan_id).map_err(RlnError::internal)?, ++ fee_sat: response.plan.fee_sat, ++ total_input_sat: response.plan.total_input_sat, ++ total_output_sat: response.plan.total_output_sat, ++ size_vbytes: response.plan.size_vbytes, ++ target_count: response.target_count, ++ output_size_sat: response.output_size_sat, ++ }) ++ } ++ ++ pub fn commit_prepared_create_utxos( ++ &self, ++ request: SdkCommitPreparedSendRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::commit_prepared_create_utxos( ++ self.handle.app_state(), ++ sdk::CommitPreparedSendRequestData { ++ plan_id: request.plan_id.to_string(), ++ }, ++ ))?; ++ Ok(SdkSendBtcResponse { ++ txid: Txid::from_str(&response.txid).map_err(RlnError::internal)?, ++ }) ++ } ++ ++ pub fn cancel_create_utxos_plan( ++ &self, ++ request: SdkCancelBtcSendPlanRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::cancel_create_utxos_plan( ++ self.handle.app_state(), ++ sdk::CancelBtcSendPlanRequestData { ++ plan_id: request.plan_id.to_string(), ++ }, ++ ))?; ++ Ok(SdkCancelBtcSendPlanResponse { ++ cancelled: response.cancelled, ++ }) ++ } ++ + pub fn issueassetnia(&self, request: SdkIssueAssetNiaRequest) -> Result { + let state = self.handle.app_state(); + let asset = block_on_sdk(sdk::issue_asset_nia( +@@ -735,6 +862,91 @@ impl SdkNode { + Ok(SdkSendBtcResponse { txid }) + } + ++ pub fn prepare_btc_send( ++ &self, ++ request: SdkSendBtcRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::prepare_btc_send( ++ self.handle.app_state(), ++ sdk::SendBtcRequestData { ++ amount: request.amount, ++ address: request.address, ++ fee_rate: request.fee_rate, ++ skip_sync: request.skip_sync, ++ }, ++ ))?; ++ Ok(SdkPreparedSendResponse { ++ plan_id: Txid::from_str(&response.plan_id).map_err(RlnError::internal)?, ++ fee_sat: response.fee_sat, ++ total_input_sat: response.total_input_sat, ++ total_output_sat: response.total_output_sat, ++ size_vbytes: response.size_vbytes, ++ }) ++ } ++ ++ pub fn commit_prepared_btc_send( ++ &self, ++ request: SdkCommitPreparedSendRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::commit_prepared_btc_send( ++ self.handle.app_state(), ++ sdk::CommitPreparedSendRequestData { ++ plan_id: request.plan_id.to_string(), ++ }, ++ ))?; ++ Ok(SdkSendBtcResponse { ++ txid: Txid::from_str(&response.txid).map_err(RlnError::internal)?, ++ }) ++ } ++ ++ pub fn cancel_btc_send_plan( ++ &self, ++ request: SdkCancelBtcSendPlanRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::cancel_btc_send_plan( ++ self.handle.app_state(), ++ sdk::CancelBtcSendPlanRequestData { ++ plan_id: request.plan_id.to_string(), ++ }, ++ ))?; ++ Ok(SdkCancelBtcSendPlanResponse { ++ cancelled: response.cancelled, ++ }) ++ } ++ ++ pub fn list_pending_vanilla_transactions( ++ &self, ++ ) -> Result, RlnError> { ++ block_on_sdk(sdk::list_pending_vanilla_transactions( ++ self.handle.app_state(), ++ ))? ++ .into_iter() ++ .map(|transaction| { ++ Ok(SdkPendingVanillaTransaction { ++ txid: Txid::from_str(&transaction.txid).map_err(RlnError::internal)?, ++ operation_type: transaction.operation_type, ++ }) ++ }) ++ .collect() ++ } ++ ++ pub fn list_address_receipts( ++ &self, ++ address: String, ++ ) -> Result, RlnError> { ++ block_on_sdk(sdk::list_address_receipts(self.handle.app_state(), address))? ++ .into_iter() ++ .map(|receipt| { ++ Ok(SdkAddressReceipt { ++ txid: Txid::from_str(&receipt.txid).map_err(RlnError::internal)?, ++ amount_sat: receipt.amount_sat, ++ confirmations: receipt.confirmations, ++ block_height: receipt.block_height, ++ }) ++ }) ++ .collect() ++ } ++ + pub fn makerinit( + &self, + request: SdkMakerInitRequest, +@@ -849,6 +1061,7 @@ impl SdkNode { + amt_msat: request.amt_msat, + asset_id: request.asset_id.map(|id| id.to_string()), + asset_amount: request.asset_amount, ++ max_total_routing_fee_msat: request.max_total_routing_fee_msat, + }, + ))?; + let status = match response.status { +@@ -871,6 +1084,7 @@ impl SdkNode { + payment_hash, + payment_secret: response.payment_secret, + status, ++ failure_code: response.failure_code, + }) + } + +@@ -909,6 +1123,35 @@ impl SdkNode { + Ok(()) + } + ++ pub fn sync_wallet(&self, mode: WalletSyncMode) -> Result { ++ let state = self.handle.app_state(); ++ let data = block_on_sdk(sdk::sync_wallet( ++ state, ++ match mode { ++ WalletSyncMode::Routine => sdk::WalletSyncModeData::Routine, ++ WalletSyncMode::Recovery => sdk::WalletSyncModeData::Recovery, ++ }, ++ ))?; ++ ++ let map_keychain = |result: sdk::WalletSyncKeychainData| WalletSyncKeychainResult { ++ succeeded: result.succeeded, ++ error_code: result.error_code, ++ checkpoint: result.checkpoint.map(|checkpoint| NetworkInfo { ++ network: format!("{:?}", checkpoint.network), ++ height: checkpoint.height, ++ block_hash: checkpoint.block_hash, ++ }), ++ }; ++ Ok(WalletSyncResult { ++ mode: match data.mode { ++ sdk::WalletSyncModeData::Routine => WalletSyncMode::Routine, ++ sdk::WalletSyncModeData::Recovery => WalletSyncMode::Recovery, ++ }, ++ vanilla: map_keychain(data.vanilla), ++ colored: map_keychain(data.colored), ++ }) ++ } ++ + pub fn node_info(&self) -> Result { + let state = self.handle.app_state(); + let data = block_on_sdk(sdk::node_info(state))?; +@@ -1085,6 +1328,7 @@ impl SdkNode { + Ok(NetworkInfo { + network: format!("{:?}", info.network), + height: info.height, ++ block_hash: info.block_hash, + }) + } + +@@ -1171,20 +1415,47 @@ impl SdkNode { + pub fn asset_metadata(&self, asset_id: ContractId) -> Result { + let state = self.handle.app_state(); + let resp = block_on_sdk(sdk::asset_metadata(state, asset_id.to_string()))?; +- Ok(AssetMetadataInfo { +- asset_schema: format!("{:?}", resp.asset_schema), +- initial_supply: resp.initial_supply, +- max_supply: resp.max_supply, +- known_circulating_supply: resp.known_circulating_supply, +- timestamp: resp.timestamp, +- name: resp.name, +- precision: resp.precision, +- ticker: resp.ticker, +- details: resp.details, +- token: resp.token.map(map_token), +- unspent_link_right_outpoint: resp.unspent_link_right_outpoint.map(map_rgb_outpoint), +- linked_from_asset_id: resp.linked_from_asset_id, +- linked_to_asset_id: resp.linked_to_asset_id, ++ Ok(map_asset_metadata_data(resp)) ++ } ++ ++ pub fn importrgbtransferconsignment( ++ &self, ++ request: ImportRgbTransferConsignmentRequest, ++ ) -> Result { ++ let state = self.handle.app_state(); ++ let resp = block_on_sdk(sdk::import_rgb_transfer_consignment( ++ state, ++ sdk::ImportRgbTransferConsignmentRequestData { ++ consignment_base64: request.consignment_base64, ++ offchain_txid: request.offchain_txid, ++ expected_asset_id: request.expected_asset_id.map(|id| id.to_string()), ++ }, ++ ))?; ++ let asset_id = ContractId::from_str(&resp.asset_id).map_err(RlnError::internal)?; ++ Ok(ImportRgbTransferConsignmentResponse { ++ asset_id, ++ already_imported: resp.already_imported, ++ metadata: map_asset_metadata_data(resp.metadata), ++ }) ++ } ++ ++ pub fn importrgbcontract( ++ &self, ++ request: ImportRgbContractRequest, ++ ) -> Result { ++ let state = self.handle.app_state(); ++ let resp = block_on_sdk(sdk::import_rgb_contract( ++ state, ++ sdk::ImportRgbContractRequestData { ++ contract_base64: request.contract_base64, ++ expected_asset_id: request.expected_asset_id.to_string(), ++ }, ++ ))?; ++ let asset_id = ContractId::from_str(&resp.asset_id).map_err(RlnError::internal)?; ++ Ok(ImportRgbContractResponse { ++ asset_id, ++ already_imported: resp.already_imported, ++ metadata: map_asset_metadata_data(resp.metadata), + }) + } + +@@ -1510,6 +1781,67 @@ impl SdkNode { + send_rgb_from_state(self.handle.app_state(), request) + } + ++ pub fn prepare_rgb_send( ++ &self, ++ request: SendRgbRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::prepare_rgb_send_from_groups( ++ self.handle.app_state(), ++ into_sdk_send_rgb_request(request), ++ ))?; ++ Ok(SdkPreparedRgbSendResponse { ++ plan_id: Txid::from_str(&response.plan.plan_id).map_err(RlnError::internal)?, ++ batch_transfer_idx: response.batch_transfer_idx, ++ fee_sat: response.plan.fee_sat, ++ total_input_sat: response.plan.total_input_sat, ++ total_output_sat: response.plan.total_output_sat, ++ size_vbytes: response.plan.size_vbytes, ++ }) ++ } ++ ++ pub fn commit_prepared_rgb_send( ++ &self, ++ request: SdkCommitPreparedSendRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::commit_prepared_rgb_send( ++ self.handle.app_state(), ++ sdk::CommitPreparedSendRequestData { ++ plan_id: request.plan_id.to_string(), ++ }, ++ ))?; ++ Ok(SendRgbResponse { ++ txid: Txid::from_str(&response.txid).map_err(RlnError::internal)?, ++ batch_transfer_idx: response.batch_transfer_idx, ++ }) ++ } ++ ++ pub fn cancel_rgb_send_plan( ++ &self, ++ request: SdkCancelBtcSendPlanRequest, ++ ) -> Result { ++ let response = block_on_sdk(sdk::cancel_rgb_send_plan( ++ self.handle.app_state(), ++ sdk::CancelBtcSendPlanRequestData { ++ plan_id: request.plan_id.to_string(), ++ }, ++ ))?; ++ Ok(SdkCancelBtcSendPlanResponse { ++ cancelled: response.cancelled, ++ }) ++ } ++ ++ pub fn list_pending_rgb_send_plans(&self) -> Result, RlnError> { ++ block_on_sdk(sdk::list_pending_rgb_send_plans(self.handle.app_state()))? ++ .into_iter() ++ .map(|plan| { ++ Ok(SdkPendingRgbSendPlan { ++ plan_id: Txid::from_str(&plan.plan_id).map_err(RlnError::internal)?, ++ batch_transfer_idx: plan.batch_transfer_idx, ++ }) ++ }) ++ .collect() ++ } ++ + pub fn apay_new(&self, host_node_id: String) -> Result { + let state = self.handle.app_state(); + let response = block_on_sdk(sdk::async_order_new( +@@ -1771,6 +2103,20 @@ pub fn sdk_asset_metadata(asset_id: ContractId) -> Result Result { ++ let handle = NodeHandle::from_app_state(get_uniffi_app_state()?); ++ SdkNode { handle }.importrgbtransferconsignment(request) ++} ++ ++pub fn sdk_import_rgb_contract( ++ request: ImportRgbContractRequest, ++) -> Result { ++ let handle = NodeHandle::from_app_state(get_uniffi_app_state()?); ++ SdkNode { handle }.importrgbcontract(request) ++} ++ + pub fn sdk_get_asset_media(digest: String) -> Result { + let handle = NodeHandle::from_app_state(get_uniffi_app_state()?); + SdkNode { handle }.get_asset_media(digest) +diff --git a/src/uniffi_api/native_signer.rs b/src/uniffi_api/native_signer.rs +index 42f6c14..cf1f4ff 100644 +--- a/src/uniffi_api/native_signer.rs ++++ b/src/uniffi_api/native_signer.rs +@@ -11,14 +11,18 @@ use bitcoin::Network; + use rand::rngs::OsRng; + use rand::RngCore; + use signer_external::contract::{BootstrapData, ExternalSignerBackend, SignerRequest}; +-use std::sync::Arc; ++use std::sync::{Arc, RwLock}; + +-#[derive(uniffi::Object)] +-pub struct NativeExternalSigner { ++struct NativeExternalSignerState { + backend: Arc, + transport: Arc, + } + ++#[derive(uniffi::Object)] ++pub struct NativeExternalSigner { ++ state: RwLock>, ++} ++ + impl NativeExternalSigner { + fn parse_network(network: &str) -> Result { + match network.to_lowercase().as_str() { +@@ -74,6 +78,29 @@ impl NativeExternalSigner { + api_level: data.api_level, + } + } ++ ++ fn with_state( ++ &self, ++ operation: impl FnOnce(&NativeExternalSignerState) -> Result, ++ ) -> Result { ++ let state = self ++ .state ++ .read() ++ .map_err(|_| RlnError::internal("native external signer state is poisoned"))?; ++ let state = state ++ .as_ref() ++ .ok_or_else(|| RlnError::internal("native external signer is shut down"))?; ++ operation(state) ++ } ++ ++ /// Release the seed-bearing VLS node and its persistent store for every ++ /// outstanding `Arc` clone. Node shutdown must complete before this is ++ /// called so final channel-state persistence can still use the signer. ++ pub fn shutdown(&self) { ++ if let Ok(mut state) = self.state.write() { ++ *state = None; ++ } ++ } + } + + #[uniffi::export] +@@ -94,7 +121,9 @@ impl NativeExternalSigner { + tracing::error!(error = ?e, "native signer transport init failed"); + RlnError::internal(format!("native signer transport init failed: {e}")) + })?; +- Ok(Arc::new(Self { backend, transport })) ++ Ok(Arc::new(Self { ++ state: RwLock::new(Some(NativeExternalSignerState { backend, transport })), ++ })) + } + + /// Like [`Self::new`], but with a disk-backed VLS store under `storage_dir_path`, so a +@@ -133,34 +162,39 @@ impl NativeExternalSigner { + "native signer persistent transport init failed: {e}" + )) + })?; +- Ok(Arc::new(Self { backend, transport })) ++ Ok(Arc::new(Self { ++ state: RwLock::new(Some(NativeExternalSignerState { backend, transport })), ++ })) + } + + pub fn bootstrap(&self) -> Result { +- let bootstrap = match self.backend.call(SignerRequest::Bootstrap).map_err(|e| { +- tracing::error!(error = ?e, "native external signer bootstrap failed"); +- RlnError::internal(format!("native external signer bootstrap failed: {e}")) +- })? { +- signer_external::contract::SignerResponse::Bootstrap(data) => data, +- other => { +- tracing::error!(response = ?other, "native external signer returned non-bootstrap response"); +- return Err(RlnError::internal( +- "native external signer returned non-bootstrap response", +- )); +- } +- }; +- Ok(Self::map_bootstrap(bootstrap)) ++ self.with_state(|state| { ++ let bootstrap = match state.backend.call(SignerRequest::Bootstrap).map_err(|e| { ++ tracing::error!(error = ?e, "native external signer bootstrap failed"); ++ RlnError::internal(format!("native external signer bootstrap failed: {e}")) ++ })? { ++ signer_external::contract::SignerResponse::Bootstrap(data) => data, ++ other => { ++ tracing::error!(response = ?other, "native external signer returned non-bootstrap response"); ++ return Err(RlnError::internal( ++ "native external signer returned non-bootstrap response", ++ )); ++ } ++ }; ++ Ok(Self::map_bootstrap(bootstrap)) ++ }) + } + } + + impl ExternalSignerHost for NativeExternalSigner { + fn call(&self, request: Vec) -> Result, RlnError> { +- in_process_vls::handle_envelope(self.backend.as_ref(), &self.transport, &request).map_err( +- |e| { +- tracing::error!(error = ?e, "native external signer envelope failed"); +- RlnError::internal(format!("native external signer envelope failed: {e}")) +- }, +- ) ++ self.with_state(|state| { ++ in_process_vls::handle_envelope(state.backend.as_ref(), &state.transport, &request) ++ .map_err(|e| { ++ tracing::error!(error = ?e, "native external signer envelope failed"); ++ RlnError::internal(format!("native external signer envelope failed: {e}")) ++ }) ++ }) + } + } + +@@ -210,4 +244,29 @@ mod tests { + .expect("explicit permissive is allowed off-mainnet") + ); + } ++ ++ #[test] ++ fn shutdown_releases_persistent_store_with_outstanding_arc_clones() { ++ let dir = tempfile::tempdir().unwrap(); ++ let storage_dir = dir.path().join("vls-signer"); ++ let signer = NativeExternalSigner::new_with_storage( ++ "11".repeat(32), ++ "regtest".to_string(), ++ Some(true), ++ storage_dir.display().to_string(), ++ ) ++ .unwrap(); ++ let leaked_clone = Arc::clone(&signer); ++ ++ signer.shutdown(); ++ assert!(leaked_clone.bootstrap().is_err()); ++ ++ NativeExternalSigner::new_with_storage( ++ "11".repeat(32), ++ "regtest".to_string(), ++ Some(true), ++ storage_dir.display().to_string(), ++ ) ++ .expect("shutdown must release redb even while an Arc clone remains alive"); ++ } + } +diff --git a/src/uniffi_api/state.rs b/src/uniffi_api/state.rs +index 1afb443..7e096bd 100644 +--- a/src/uniffi_api/state.rs ++++ b/src/uniffi_api/state.rs +@@ -192,6 +192,7 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { + | APIError::InvalidEstimationBlocks + | APIError::InvalidFeeRate(_) + | APIError::InvalidInvoice(_) ++ | APIError::InvalidRgbContract(_) + | APIError::InvalidMediaDigest + | APIError::InvalidMnemonic(_) + | APIError::InvalidName(_) +@@ -203,6 +204,7 @@ pub(crate) fn map_api_error(err: APIError) -> RlnError { + | APIError::InvalidPeerInfo(_) + | APIError::InvalidPrecision(_) + | APIError::InvalidPubkey ++ | APIError::InvalidRgbConsignment(_) + | APIError::InvalidRecipientData(_) + | APIError::InvalidRecipientID + | APIError::InvalidRecipientNetwork +@@ -236,6 +238,7 @@ pub(super) fn map_app_error(err: AppError) -> RlnError { + let msg = err.to_string(); + stash_api_error_detail(msg.clone()); + match err { ++ AppError::NodeInstanceAlreadyActive(_) => RlnError::Conflict(msg), + AppError::UnavailablePort(_) | AppError::InvalidAuthenticationArgs => { + RlnError::InvalidRequest(msg) + } +diff --git a/src/uniffi_api/tests.rs b/src/uniffi_api/tests.rs +index 7860e38..c50ede3 100644 +--- a/src/uniffi_api/tests.rs ++++ b/src/uniffi_api/tests.rs +@@ -416,21 +416,27 @@ mod uniffi_smoke_tests { + amt_msat: Some(1000), + asset_amount: None, + asset_id: None, ++ carrier_msat: None, + payment_hash: payment_hash_hex.clone(), + payment_type: crate::sdk::PaymentType::InboundHodl, + status: crate::sdk::HtlcStatus::Succeeded, + created_at: 1, + updated_at: 2, ++ expires_at: None, + payee_pubkey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + .to_string(), + preimage: expected_preimage.clone(), + description: None, + description_hash: None, ++ fee_paid_msat: Some(25), ++ failure_code: Some("ROUTE_NOT_FOUND".to_string()), + }; + + let mapped = map_payment_data(data).expect("payment mapping should succeed"); + assert_eq!(mapped.payment_hash.0, [7u8; 32]); + assert_eq!(mapped.preimage, expected_preimage); ++ assert_eq!(mapped.fee_paid_msat, Some(25)); ++ assert_eq!(mapped.failure_code.as_deref(), Some("ROUTE_NOT_FOUND")); + assert!(matches!(mapped.payment_type, PaymentType::InboundHodl)); + } + +diff --git a/src/uniffi_api/types.rs b/src/uniffi_api/types.rs +index 2370c6c..190d23d 100644 +--- a/src/uniffi_api/types.rs ++++ b/src/uniffi_api/types.rs +@@ -10,6 +10,7 @@ pub type Bolt11Invoice = lightning_invoice::Bolt11Invoice; + pub struct RecipientId(pub String); + pub struct TransportEndpoint(pub String); + ++#[derive(Clone)] + pub struct SdkNode { + pub(crate) handle: NodeHandle, + } +@@ -95,6 +96,7 @@ pub struct NodeInfo { + pub struct NetworkInfo { + pub network: String, + pub height: u32, ++ pub block_hash: String, + } + + pub struct AddressInfo { +@@ -112,6 +114,24 @@ pub struct BtcBalanceInfo { + pub colored: BtcBalance, + } + ++#[derive(Clone, Copy)] ++pub enum WalletSyncMode { ++ Routine, ++ Recovery, ++} ++ ++pub struct WalletSyncKeychainResult { ++ pub succeeded: bool, ++ pub error_code: Option, ++ pub checkpoint: Option, ++} ++ ++pub struct WalletSyncResult { ++ pub mode: WalletSyncMode, ++ pub vanilla: WalletSyncKeychainResult, ++ pub colored: WalletSyncKeychainResult, ++} ++ + pub struct SignMessageResponse { + pub signed_message: String, + } +@@ -132,15 +152,19 @@ pub struct Payment { + pub amt_msat: Option, + pub asset_amount: Option, + pub asset_id: Option, ++ pub carrier_msat: Option, + pub payment_hash: PaymentHash, + pub payment_type: PaymentType, + pub status: HtlcStatus, + pub created_at: u64, + pub updated_at: u64, ++ pub expires_at: Option, + pub payee_pubkey: PublicKey, + pub preimage: Option, + pub description: Option, + pub description_hash: Option, ++ pub fee_paid_msat: Option, ++ pub failure_code: Option, + } + + pub enum PaymentType { +@@ -269,6 +293,18 @@ pub struct AssetMetadataInfo { + pub linked_to_asset_id: Option, + } + ++pub struct ImportRgbTransferConsignmentRequest { ++ pub consignment_base64: String, ++ pub offchain_txid: String, ++ pub expected_asset_id: Option, ++} ++ ++pub struct ImportRgbTransferConsignmentResponse { ++ pub asset_id: ContractId, ++ pub already_imported: bool, ++ pub metadata: AssetMetadataInfo, ++} ++ + pub struct AssetMediaResponse { + pub bytes_hex: String, + } +@@ -425,6 +461,11 @@ pub struct TransferTransportEndpoint { + pub used: bool, + } + ++pub struct RgbAssignmentInfo { ++ pub kind: String, ++ pub amount: Option, ++} ++ + pub struct Transfer { + pub idx: i32, + pub created_at: i64, +@@ -432,6 +473,8 @@ pub struct Transfer { + pub status: String, + pub requested_assignment: Option, + pub assignments: Vec, ++ pub requested_assignment_structured: Option, ++ pub assignments_structured: Vec, + pub kind: String, + pub txid: Option, + pub recipient_id: Option, +@@ -556,6 +599,7 @@ pub struct SdkSendPaymentRequest { + pub amt_msat: Option, + pub asset_id: Option, + pub asset_amount: Option, ++ pub max_total_routing_fee_msat: Option, + } + + pub struct SdkSendPaymentResponse { +@@ -563,6 +607,7 @@ pub struct SdkSendPaymentResponse { + pub payment_hash: Option, + pub payment_secret: Option, + pub status: HtlcStatus, ++ pub failure_code: Option, + } + + pub struct SdkRefreshTransfersRequest { +@@ -587,6 +632,16 @@ pub struct SdkCreateUtxosRequest { + pub skip_sync: bool, + } + ++pub struct SdkPreparedCreateUtxosResponse { ++ pub plan_id: Txid, ++ pub fee_sat: u64, ++ pub total_input_sat: u64, ++ pub total_output_sat: u64, ++ pub size_vbytes: u64, ++ pub target_count: u8, ++ pub output_size_sat: u32, ++} ++ + pub struct SdkIssueAssetNiaRequest { + pub amounts: Vec, + pub ticker: String, +@@ -660,6 +715,52 @@ pub struct SdkSendBtcResponse { + pub txid: Txid, + } + ++pub struct SdkPreparedSendResponse { ++ pub plan_id: Txid, ++ pub fee_sat: u64, ++ pub total_input_sat: u64, ++ pub total_output_sat: u64, ++ pub size_vbytes: u64, ++} ++ ++pub struct SdkPreparedRgbSendResponse { ++ pub plan_id: Txid, ++ pub batch_transfer_idx: i32, ++ pub fee_sat: u64, ++ pub total_input_sat: u64, ++ pub total_output_sat: u64, ++ pub size_vbytes: u64, ++} ++ ++pub struct SdkCommitPreparedSendRequest { ++ pub plan_id: Txid, ++} ++ ++pub struct SdkCancelBtcSendPlanRequest { ++ pub plan_id: Txid, ++} ++ ++pub struct SdkCancelBtcSendPlanResponse { ++ pub cancelled: bool, ++} ++ ++pub struct SdkPendingVanillaTransaction { ++ pub txid: Txid, ++ pub operation_type: String, ++} ++ ++pub struct SdkPendingRgbSendPlan { ++ pub plan_id: Txid, ++ pub batch_transfer_idx: i32, ++} ++ ++pub struct SdkAddressReceipt { ++ pub txid: Txid, ++ pub amount_sat: u64, ++ pub confirmations: u32, ++ pub block_height: Option, ++} ++ + pub struct SdkMakerInitRequest { + pub qty_from: u64, + pub qty_to: u64, +@@ -714,6 +815,17 @@ pub struct SdkRgbInvoiceResponse { + pub batch_transfer_idx: i32, + } + ++pub struct ImportRgbContractRequest { ++ pub contract_base64: String, ++ pub expected_asset_id: ContractId, ++} ++ ++pub struct ImportRgbContractResponse { ++ pub asset_id: ContractId, ++ pub already_imported: bool, ++ pub metadata: AssetMetadataInfo, ++} ++ + pub struct LnInvoiceResponse { + pub invoice: Bolt11Invoice, + } +diff --git a/src/utils.rs b/src/utils.rs +index 327f0f5..ccec7be 100644 +--- a/src/utils.rs ++++ b/src/utils.rs +@@ -166,6 +166,7 @@ impl StaticState { + + pub(crate) struct UnlockedAppState { + pub(crate) config: Arc, ++ pub(crate) address_indexer: Arc, + pub(crate) channel_manager: Arc, + pub(crate) gossip_source: Arc, + pub(crate) inbound_payments: Arc>, +diff --git a/src/vss_kv_store.rs b/src/vss_kv_store.rs +index b956bd1..e27d99a 100644 +--- a/src/vss_kv_store.rs ++++ b/src/vss_kv_store.rs +@@ -1,4 +1,7 @@ + use std::collections::HashMap; ++use std::fs; ++use std::io::Write; ++use std::path::Path; + use std::sync::Arc; + use std::time::Duration; + +@@ -85,6 +88,30 @@ const HEADER_LEN: usize = 1 + SALT_LEN + NONCE_LEN; + /// this key explicitly. + const FENCE_KEY: &str = "__rln_instance__"; + ++/// Local, non-replicated identity for the process owner of a VSS store. ++/// ++/// The value must survive ordinary process restarts, but must not follow the ++/// mnemonic to another installation. Keeping it beside the local node state ++/// gives each independently provisioned data directory a distinct writer ID. ++const VSS_WRITER_ID_FILE: &str = ".vss_writer_id"; ++ ++/// A competing process can observe the exclusively-created identity file ++/// between `open` and `sync_all`. Bound that publication window rather than ++/// ever replacing an identity whose durability is uncertain. ++const VSS_WRITER_ID_READ_ATTEMPTS: usize = 50; ++const VSS_WRITER_ID_READ_RETRY_DELAY: Duration = Duration::from_millis(10); ++ ++fn bitcoin_io_kind(kind: std::io::ErrorKind) -> io::ErrorKind { ++ match kind { ++ std::io::ErrorKind::NotFound => io::ErrorKind::NotFound, ++ std::io::ErrorKind::PermissionDenied => io::ErrorKind::PermissionDenied, ++ std::io::ErrorKind::AlreadyExists => io::ErrorKind::AlreadyExists, ++ std::io::ErrorKind::InvalidData => io::ErrorKind::InvalidData, ++ std::io::ErrorKind::TimedOut => io::ErrorKind::TimedOut, ++ _ => io::ErrorKind::Other, ++ } ++} ++ + /// How many writes between periodic fence re-checks. + /// + /// The startup fence acquire catches the common case (another instance is +@@ -102,14 +129,92 @@ pub struct VssKvStore { + client: VssClient, + store_id: String, + signing_key: SecretKey, +- /// Per-process identity used by [`Self::acquire_fence`] and the periodic +- /// fence re-check. ++ /// Per-local-installation identity used by [`Self::acquire_fence`] and the ++ /// periodic fence re-check. + instance_id: Uuid, + /// Counter incremented on every write; modulo [`FENCE_CHECK_INTERVAL`] is + /// used to schedule periodic fence re-checks. + write_counter: std::sync::atomic::AtomicU64, + } + ++fn read_writer_id(path: &Path) -> Result { ++ let raw = fs::read_to_string(path).map_err(|error| { ++ io::Error::new( ++ bitcoin_io_kind(error.kind()), ++ format!("failed to read local VSS writer identity: {error}"), ++ ) ++ })?; ++ Uuid::parse_str(raw.trim()).map_err(|error| { ++ io::Error::new( ++ io::ErrorKind::InvalidData, ++ format!("invalid local VSS writer identity: {error}"), ++ ) ++ }) ++} ++ ++fn read_competing_writer_id(path: &Path) -> Result { ++ for attempt in 0..VSS_WRITER_ID_READ_ATTEMPTS { ++ match read_writer_id(path) { ++ Ok(writer_id) => return Ok(writer_id), ++ Err(error) ++ if attempt + 1 < VSS_WRITER_ID_READ_ATTEMPTS ++ && matches!( ++ error.kind(), ++ io::ErrorKind::NotFound | io::ErrorKind::InvalidData ++ ) => ++ { ++ std::thread::sleep(VSS_WRITER_ID_READ_RETRY_DELAY); ++ } ++ Err(error) => return Err(error), ++ } ++ } ++ ++ unreachable!("the bounded writer identity read loop always returns") ++} ++ ++/// Returns the stable VSS writer identity for one local node data directory. ++/// ++/// The canonical file is opened with exclusive-create semantics, which is the ++/// portable no-clobber primitive available in Android application storage. ++/// Racing starters either create and durably sync one value or wait briefly ++/// for that value to finish publishing. A malformed existing value fails ++/// closed rather than silently taking ownership with a replacement identity. ++pub(crate) fn load_or_create_writer_id(storage_dir: &Path) -> Result { ++ fs::create_dir_all(storage_dir)?; ++ let path = storage_dir.join(VSS_WRITER_ID_FILE); ++ let candidate = Uuid::new_v4(); ++ ++ #[cfg(unix)] ++ let create_result = { ++ use std::os::unix::fs::OpenOptionsExt; ++ fs::OpenOptions::new() ++ .create_new(true) ++ .write(true) ++ .mode(0o600) ++ .open(&path) ++ }; ++ #[cfg(not(unix))] ++ let create_result = fs::OpenOptions::new() ++ .create_new(true) ++ .write(true) ++ .open(&path); ++ ++ match create_result { ++ Ok(mut file) => { ++ file.write_all(candidate.to_string().as_bytes())?; ++ file.sync_all()?; ++ Ok(candidate) ++ } ++ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { ++ read_competing_writer_id(&path) ++ } ++ Err(error) => Err(io::Error::new( ++ bitcoin_io_kind(error.kind()), ++ format!("failed to create local VSS writer identity: {error}"), ++ )), ++ } ++} ++ + impl VssKvStore { + /// Creates a new VssKvStore connected to the given VSS server. + /// +@@ -137,6 +242,22 @@ impl VssKvStore { + store_id: String, + signing_key: SecretKey, + retry: &crate::config::VssSection, ++ ) -> Result { ++ Self::new_with_retry_and_instance_id( ++ server_url, ++ store_id, ++ signing_key, ++ retry, ++ Uuid::new_v4(), ++ ) ++ } ++ ++ pub(crate) fn new_with_retry_and_instance_id( ++ server_url: String, ++ store_id: String, ++ signing_key: SecretKey, ++ retry: &crate::config::VssSection, ++ instance_id: Uuid, + ) -> Result { + let auth_provider = SigsAuthProvider::new(signing_key, HashMap::new()); + +@@ -151,7 +272,7 @@ impl VssKvStore { + client, + store_id, + signing_key, +- instance_id: Uuid::new_v4(), ++ instance_id, + write_counter: std::sync::atomic::AtomicU64::new(0), + }) + } +@@ -613,6 +734,78 @@ impl VssKvStore { + + Ok(all_items) + } ++ ++ /// Deletes this authenticated VSS store in one optimistic-concurrency ++ /// transaction, then verifies that no keys remain. ++ pub fn delete_all(&self) -> Result { ++ let mut keys = Vec::new(); ++ let mut page_token: Option = None; ++ let mut global_version: Option = None; ++ ++ loop { ++ let request = ListKeyVersionsRequest { ++ store_id: self.store_id.clone(), ++ key_prefix: None, ++ page_size: None, ++ page_token: page_token.clone(), ++ }; ++ let response = match self.block_on(self.client.list_key_versions(&request)) { ++ Ok(response) => response, ++ Err(VssError::NoSuchKeyError(_)) => return Ok(0), ++ Err(error) => { ++ return Err(vss_err_to_io( ++ error, ++ "VSS store enumeration failed during deletion".to_string(), ++ )); ++ } ++ }; ++ if page_token.is_none() { ++ global_version = response.global_version; ++ } ++ keys.extend(response.key_versions); ++ match response.next_page_token { ++ Some(token) if !token.is_empty() => page_token = Some(token), ++ _ => break, ++ } ++ } ++ ++ if keys.is_empty() { ++ return Ok(0); ++ } ++ let deleted = keys.len(); ++ let request = PutObjectRequest { ++ store_id: self.store_id.clone(), ++ global_version, ++ transaction_items: vec![], ++ delete_items: keys, ++ }; ++ self.block_on(self.client.put_object(&request)) ++ .map_err(|error| { ++ vss_err_to_io( ++ error, ++ "VSS store deletion failed or raced with another writer".to_string(), ++ ) ++ })?; ++ ++ let verification = ListKeyVersionsRequest { ++ store_id: self.store_id.clone(), ++ key_prefix: None, ++ page_size: Some(1), ++ page_token: None, ++ }; ++ match self.block_on(self.client.list_key_versions(&verification)) { ++ Ok(response) if response.key_versions.is_empty() => Ok(deleted), ++ Err(VssError::NoSuchKeyError(_)) => Ok(deleted), ++ Ok(_) => Err(io::Error::new( ++ io::ErrorKind::Other, ++ "VSS store deletion verification found remaining keys", ++ )), ++ Err(error) => Err(vss_err_to_io( ++ error, ++ "VSS store deletion verification failed".to_string(), ++ )), ++ } ++ } + } + + /// Encode a `(primary_namespace, secondary_namespace, key)` triple as a +@@ -913,3 +1106,72 @@ mod fence_release_guard_tests { + ); + } + } ++ ++#[cfg(test)] ++mod writer_id_tests { ++ use super::{load_or_create_writer_id, VSS_WRITER_ID_FILE}; ++ use std::sync::{Arc, Barrier}; ++ ++ #[test] ++ fn writer_id_survives_process_restart_for_same_local_state() { ++ let storage = tempfile::tempdir().expect("temporary storage"); ++ let first = load_or_create_writer_id(storage.path()).expect("create writer id"); ++ let restarted = load_or_create_writer_id(storage.path()).expect("read writer id"); ++ ++ assert_eq!(first, restarted); ++ } ++ ++ #[test] ++ fn independent_local_state_gets_a_distinct_writer_id() { ++ let first_storage = tempfile::tempdir().expect("first temporary storage"); ++ let second_storage = tempfile::tempdir().expect("second temporary storage"); ++ ++ let first = load_or_create_writer_id(first_storage.path()).expect("first writer id"); ++ let second = load_or_create_writer_id(second_storage.path()).expect("second writer id"); ++ ++ assert_ne!(first, second); ++ } ++ ++ #[test] ++ fn corrupt_writer_id_fails_closed() { ++ let storage = tempfile::tempdir().expect("temporary storage"); ++ std::fs::write(storage.path().join(VSS_WRITER_ID_FILE), "not-a-uuid") ++ .expect("write corrupt identity"); ++ ++ let error = load_or_create_writer_id(storage.path()).expect_err("corruption must fail"); ++ ++ assert_eq!(error.kind(), bitcoin::io::ErrorKind::InvalidData); ++ } ++ ++ #[test] ++ fn concurrent_starters_share_one_complete_writer_id() { ++ let storage = tempfile::tempdir().expect("temporary storage"); ++ let storage_path = Arc::new(storage.path().to_path_buf()); ++ let barrier = Arc::new(Barrier::new(16)); ++ let starters = (0..16) ++ .map(|_| { ++ let storage_path = Arc::clone(&storage_path); ++ let barrier = Arc::clone(&barrier); ++ std::thread::spawn(move || { ++ barrier.wait(); ++ load_or_create_writer_id(&storage_path).expect("load one writer id") ++ }) ++ }) ++ .collect::>(); ++ ++ let writer_ids = starters ++ .into_iter() ++ .map(|starter| starter.join().expect("starter thread")) ++ .collect::>(); ++ ++ assert!(writer_ids ++ .iter() ++ .all(|writer_id| *writer_id == writer_ids[0])); ++ assert_eq!( ++ std::fs::read_to_string(storage.path().join(VSS_WRITER_ID_FILE)) ++ .expect("read writer identity") ++ .len(), ++ 36, ++ ); ++ } ++} diff --git a/patches/c-ffi-utexo-patches-v0.9.0-beta.3.patch b/patches/c-ffi-utexo-patches-v0.9.0-beta.3.patch new file mode 100644 index 0000000..7b2fd60 --- /dev/null +++ b/patches/c-ffi-utexo-patches-v0.9.0-beta.3.patch @@ -0,0 +1,950 @@ +diff --git a/bindings/c-ffi/rln.h b/bindings/c-ffi/rln.h +index eb7e572..17acb5f 100644 +--- a/bindings/c-ffi/rln.h ++++ b/bindings/c-ffi/rln.h +@@ -237,6 +237,8 @@ struct CResultString rln_sign_message(const struct COpaqueStruct *node, const ch + + struct CResultString rln_sync(const struct COpaqueStruct *node); + ++struct CResultString rln_sync_wallet(const struct COpaqueStruct *node, const char *request_json); ++ + struct CResultString rln_taker(const struct COpaqueStruct *node, const char *request_json); + + struct CResultString rln_uniffi_healthcheck(void); +@@ -246,3 +248,6 @@ struct CResultString rln_uniffi_is_initialized(void); + struct CResultString rln_verify_message(const struct COpaqueStruct *node, + const char *message, + const char *signature); ++ ++struct CResultString rln_wallet_snapshot(const struct COpaqueStruct *node, ++ const char *request_json); +diff --git a/bindings/c-ffi/rln.hpp b/bindings/c-ffi/rln.hpp +index 64266ec..82c1bd9 100644 +--- a/bindings/c-ffi/rln.hpp ++++ b/bindings/c-ffi/rln.hpp +@@ -211,6 +211,8 @@ CResultString rln_sign_message(const COpaqueStruct *node, const char *message); + + CResultString rln_sync(const COpaqueStruct *node); + ++CResultString rln_sync_wallet(const COpaqueStruct *node, const char *request_json); ++ + CResultString rln_taker(const COpaqueStruct *node, const char *request_json); + + CResultString rln_uniffi_healthcheck(); +@@ -221,4 +223,6 @@ CResultString rln_verify_message(const COpaqueStruct *node, + const char *message, + const char *signature); + ++CResultString rln_wallet_snapshot(const COpaqueStruct *node, const char *request_json); ++ + } // extern "C" +diff --git a/bindings/c-ffi/src/api.rs b/bindings/c-ffi/src/api.rs +index edb35ed..464ee46 100644 +--- a/bindings/c-ffi/src/api.rs ++++ b/bindings/c-ffi/src/api.rs +@@ -3,9 +3,12 @@ + //! zero or more JSON request strings, do the conversion to UniFFI types, + //! dispatch to [`SdkNode`], and serialize the response back to JSON. + ++use std::collections::HashSet; + use std::ffi::c_char; + use std::str::FromStr; ++use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; ++use std::time::{SystemTime, UNIX_EPOCH}; + + use hex::DisplayHex; + use hex::FromHex; +@@ -16,6 +19,11 @@ use crate::json_types::*; + use crate::utils::{convert_optional_string, ptr_to_string, require_handle, require_signer, Error}; + use crate::COpaqueStruct; + ++const MAX_WALLET_SNAPSHOT_ASSETS: usize = 128; ++const MAX_WALLET_SNAPSHOT_CHANNELS: usize = 512; ++const MAX_WALLET_SNAPSHOT_ACTIVITY_ITEMS: usize = 5_000; ++static WALLET_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(0); ++ + // --------------------------------------------------------------------------- + // String-parse helpers + // --------------------------------------------------------------------------- +@@ -60,6 +68,38 @@ fn json(t: T) -> Result { + Ok(serde_json::to_string(&t)?) + } + ++fn unix_time_ms() -> Result { ++ let millis = SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .map_err(|_| Error::StringParse("system clock is before Unix epoch".to_string()))? ++ .as_millis(); ++ u64::try_from(millis) ++ .map_err(|_| Error::StringParse("system clock exceeds u64 milliseconds".to_string())) ++} ++ ++fn next_wallet_snapshot_sequence() -> Result { ++ WALLET_SNAPSHOT_SEQUENCE ++ .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { ++ current.checked_add(1) ++ }) ++ .map(|previous| previous + 1) ++ .map_err(|_| Error::StringParse("wallet snapshot sequence exhausted".to_string())) ++} ++ ++fn validate_snapshot_limit( ++ name: &str, ++ requested: u16, ++ maximum: usize, ++) -> Result { ++ let requested = usize::from(requested); ++ if requested == 0 || requested > maximum { ++ return Err(Error::StringParse(format!( ++ "{name} must be between 1 and {maximum}" ++ ))); ++ } ++ Ok(requested) ++} ++ + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- +@@ -610,6 +650,139 @@ pub(crate) fn sync(node: &COpaqueStruct) -> Result { + ok_void() + } + ++pub(crate) fn sync_wallet( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let request: JsonSyncWalletRequest = parse_req(request_json)?; ++ let response = node.sync_wallet(request.mode.into())?; ++ json(JsonWalletSyncResponse::from(response)) ++} ++ ++pub(crate) fn wallet_snapshot( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> Result { ++ let node = require_handle(node)?; ++ let request: JsonWalletSnapshotRequest = parse_req(request_json)?; ++ let max_assets = validate_snapshot_limit( ++ "max_assets", ++ request.max_assets, ++ MAX_WALLET_SNAPSHOT_ASSETS, ++ )?; ++ let max_channels = validate_snapshot_limit( ++ "max_channels", ++ request.max_channels, ++ MAX_WALLET_SNAPSHOT_CHANNELS, ++ )?; ++ let max_activity_items = validate_snapshot_limit( ++ "max_activity_items", ++ request.max_activity_items, ++ MAX_WALLET_SNAPSHOT_ACTIVITY_ITEMS, ++ )?; ++ if request.asset_ids.len() > max_assets { ++ return Err(Error::StringParse(format!( ++ "asset_ids contains {} entries, exceeding max_assets {max_assets}", ++ request.asset_ids.len() ++ ))); ++ } ++ let unique_asset_ids = request.asset_ids.iter().collect::>(); ++ if unique_asset_ids.len() != request.asset_ids.len() { ++ return Err(Error::StringParse( ++ "asset_ids must not contain duplicates".to_string(), ++ )); ++ } ++ for asset_id in &request.asset_ids { ++ parse_contract_id(asset_id)?; ++ } ++ ++ let started_at_ms = unix_time_ms()?; ++ let capture_sequence = next_wallet_snapshot_sequence()?; ++ let network_before = node.network_info()?; ++ let node_info = node.node_info()?; ++ let btc = node.btc_balance(true)?; ++ let listed_assets = node.list_assets(vec!["Nia".to_string()])?; ++ let assets = listed_assets.nia.unwrap_or_default(); ++ if assets.len() > max_assets { ++ return Err(Error::StringParse(format!( ++ "native NIA inventory contains {} entries, exceeding max_assets {max_assets}", ++ assets.len() ++ ))); ++ } ++ let known_asset_ids = assets ++ .iter() ++ .map(|asset| asset.asset_id.to_string()) ++ .collect::>(); ++ ++ let channels = node.list_channels()?; ++ if channels.len() > max_channels { ++ return Err(Error::StringParse(format!( ++ "native channel inventory contains {} entries, exceeding max_channels {max_channels}", ++ channels.len() ++ ))); ++ } ++ ++ let (transactions, payments, transfers) = if request.include_activity { ++ let transactions = node.list_transactions(true)?; ++ let payments = node.list_payments()?; ++ if transactions.len() > max_activity_items || payments.len() > max_activity_items { ++ return Err(Error::StringParse(format!( ++ "native activity exceeds max_activity_items {max_activity_items}" ++ ))); ++ } ++ ++ let mut transfer_count = 0usize; ++ let mut transfers = Vec::new(); ++ for asset_id in request ++ .asset_ids ++ .iter() ++ .filter(|asset_id| known_asset_ids.contains(*asset_id)) ++ { ++ let rows = node.list_transfers(parse_contract_id(asset_id)?)?; ++ transfer_count = transfer_count.checked_add(rows.len()).ok_or_else(|| { ++ Error::StringParse("native transfer count overflow".to_string()) ++ })?; ++ if transfer_count > max_activity_items { ++ return Err(Error::StringParse(format!( ++ "native transfers exceed max_activity_items {max_activity_items}" ++ ))); ++ } ++ transfers.push(JsonWalletSnapshotAssetTransfers { ++ asset_id: asset_id.clone(), ++ transfers: rows.into_iter().map(Into::into).collect(), ++ }); ++ } ++ ++ ( ++ Some(transactions.into_iter().map(Into::into).collect()), ++ Some(payments.into_iter().map(Into::into).collect()), ++ Some(transfers), ++ ) ++ } else { ++ (None, None, None) ++ }; ++ ++ let network_after = node.network_info()?; ++ let completed_at_ms = unix_time_ms()?; ++ json(JsonWalletSnapshotResponse { ++ contract_version: 1, ++ native_source: "rgb-lightning-node-v0.9.0-beta.3+utexo-wallet-v1", ++ capture_sequence: capture_sequence.to_string(), ++ started_at_ms: started_at_ms.to_string(), ++ completed_at_ms: completed_at_ms.to_string(), ++ network_before: network_before.into(), ++ network_after: network_after.into(), ++ node: node_info.into(), ++ btc: btc.into(), ++ assets: assets.into_iter().map(Into::into).collect(), ++ channels: channels.into_iter().map(Into::into).collect(), ++ transactions, ++ payments, ++ transfers, ++ }) ++} ++ + // --------------------------------------------------------------------------- + // Swaps / onion + // --------------------------------------------------------------------------- +@@ -804,3 +977,23 @@ pub(crate) fn sdk_node_unlock_with_attached_external_signer( + )?; + ok_void() + } ++ ++#[cfg(test)] ++mod wallet_snapshot_api_tests { ++ use super::*; ++ ++ #[test] ++ fn snapshot_limits_accept_only_positive_bounded_values() { ++ assert_eq!(validate_snapshot_limit("items", 1, 8).unwrap(), 1); ++ assert_eq!(validate_snapshot_limit("items", 8, 8).unwrap(), 8); ++ assert!(validate_snapshot_limit("items", 0, 8).is_err()); ++ assert!(validate_snapshot_limit("items", 9, 8).is_err()); ++ } ++ ++ #[test] ++ fn snapshot_sequence_is_monotonic() { ++ let first = next_wallet_snapshot_sequence().expect("first sequence"); ++ let second = next_wallet_snapshot_sequence().expect("second sequence"); ++ assert!(second > first); ++ } ++} +diff --git a/bindings/c-ffi/src/json_types.rs b/bindings/c-ffi/src/json_types.rs +index fc570e0..26bb161 100644 +--- a/bindings/c-ffi/src/json_types.rs ++++ b/bindings/c-ffi/src/json_types.rs +@@ -33,7 +33,7 @@ use rgb_lightning_node::{ + AssetRecipients, RgbRecipient, + SignMessageResponse, Swap, SwapList, SwapStatus, Token, TokenLight, Transaction, + TransactionType, Transfer, TransferTransportEndpoint, TransportEndpoint, Txid, Unspent, Utxo, +- VerifyMessageResponse, WitnessData, ++ VerifyMessageResponse, WalletSyncKeychainResult, WalletSyncMode, WalletSyncResult, WitnessData, + }; + use serde::{Deserialize, Serialize}; + +@@ -1532,7 +1532,7 @@ pub(crate) struct JsonNetworkInfo { + impl From for JsonNetworkInfo { + fn from(n: NetworkInfo) -> Self { + JsonNetworkInfo { +- network: n.network, ++ network: n.network.to_ascii_lowercase(), + height: n.height, + } + } +@@ -1581,6 +1581,452 @@ impl From for JsonBtcBalanceInfo { + } + } + ++// --------------------------------------------------------------------------- ++// Versioned wallet synchronization + exact wallet snapshot ++// --------------------------------------------------------------------------- ++ ++#[derive(Debug, Clone, Copy, Deserialize, Serialize)] ++#[serde(rename_all = "snake_case")] ++pub(crate) enum JsonWalletSyncMode { ++ Routine, ++ Recovery, ++} ++ ++impl From for WalletSyncMode { ++ fn from(mode: JsonWalletSyncMode) -> Self { ++ match mode { ++ JsonWalletSyncMode::Routine => WalletSyncMode::Routine, ++ JsonWalletSyncMode::Recovery => WalletSyncMode::Recovery, ++ } ++ } ++} ++ ++impl From for JsonWalletSyncMode { ++ fn from(mode: WalletSyncMode) -> Self { ++ match mode { ++ WalletSyncMode::Routine => JsonWalletSyncMode::Routine, ++ WalletSyncMode::Recovery => JsonWalletSyncMode::Recovery, ++ } ++ } ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub(crate) struct JsonSyncWalletRequest { ++ pub mode: JsonWalletSyncMode, ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSyncKeychainResult { ++ pub status: &'static str, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub error_code: Option, ++} ++ ++impl From for JsonWalletSyncKeychainResult { ++ fn from(result: WalletSyncKeychainResult) -> Self { ++ Self { ++ status: if result.succeeded { "succeeded" } else { "failed" }, ++ error_code: result.error_code, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSyncResponse { ++ pub contract_version: u16, ++ pub mode: JsonWalletSyncMode, ++ pub vanilla: JsonWalletSyncKeychainResult, ++ pub colored: JsonWalletSyncKeychainResult, ++} ++ ++impl From for JsonWalletSyncResponse { ++ fn from(result: WalletSyncResult) -> Self { ++ Self { ++ contract_version: 1, ++ mode: result.mode.into(), ++ vanilla: result.vanilla.into(), ++ colored: result.colored.into(), ++ } ++ } ++} ++ ++fn default_snapshot_max_assets() -> u16 { ++ 32 ++} ++ ++fn default_snapshot_max_channels() -> u16 { ++ 128 ++} ++ ++fn default_snapshot_max_activity_items() -> u16 { ++ 1_000 ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(deny_unknown_fields)] ++pub(crate) struct JsonWalletSnapshotRequest { ++ #[serde(default)] ++ pub asset_ids: Vec, ++ #[serde(default = "default_snapshot_max_assets")] ++ pub max_assets: u16, ++ #[serde(default = "default_snapshot_max_channels")] ++ pub max_channels: u16, ++ #[serde(default = "default_snapshot_max_activity_items")] ++ pub max_activity_items: u16, ++ #[serde(default)] ++ pub include_activity: bool, ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonExactBalance { ++ pub settled: String, ++ pub future: String, ++ pub spendable: String, ++} ++ ++impl From for JsonExactBalance { ++ fn from(balance: BtcBalance) -> Self { ++ Self { ++ settled: balance.settled.to_string(), ++ future: balance.future.to_string(), ++ spendable: balance.spendable.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonExactBtcBalanceInfo { ++ pub vanilla: JsonExactBalance, ++ pub colored: JsonExactBalance, ++} ++ ++impl From for JsonExactBtcBalanceInfo { ++ fn from(balance: BtcBalanceInfo) -> Self { ++ Self { ++ vanilla: balance.vanilla.into(), ++ colored: balance.colored.into(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonExactAssetBalance { ++ pub settled: String, ++ pub future: String, ++ pub spendable: String, ++ pub offchain_outbound: String, ++ pub offchain_inbound: String, ++} ++ ++impl From for JsonExactAssetBalance { ++ fn from(balance: AssetBalanceInfo) -> Self { ++ Self { ++ settled: balance.settled.to_string(), ++ future: balance.future.to_string(), ++ spendable: balance.spendable.to_string(), ++ offchain_outbound: balance.offchain_outbound.to_string(), ++ offchain_inbound: balance.offchain_inbound.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotAsset { ++ pub asset_id: String, ++ pub ticker: String, ++ pub name: String, ++ pub precision: u8, ++ pub balance: JsonExactAssetBalance, ++} ++ ++impl From for JsonWalletSnapshotAsset { ++ fn from(asset: AssetNia) -> Self { ++ Self { ++ asset_id: fmt_contract_id(&asset.asset_id), ++ ticker: asset.ticker, ++ name: asset.name, ++ precision: asset.precision, ++ balance: asset.balance.into(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotNode { ++ pub pubkey: String, ++ pub num_channels: String, ++ pub num_usable_channels: String, ++ pub claimable_onchain_sat: String, ++ pub eventual_close_fees_sat: String, ++ pub pending_outbound_payments_sat: String, ++ pub num_peers: String, ++ pub latest_rgs_snapshot_timestamp: Option, ++} ++ ++impl From for JsonWalletSnapshotNode { ++ fn from(node: NodeInfo) -> Self { ++ Self { ++ pubkey: fmt_pubkey(&node.pubkey), ++ num_channels: node.num_channels.to_string(), ++ num_usable_channels: node.num_usable_channels.to_string(), ++ claimable_onchain_sat: node.local_balance_sat.to_string(), ++ eventual_close_fees_sat: node.eventual_close_fees_sat.to_string(), ++ pending_outbound_payments_sat: node.pending_outbound_payments_sat.to_string(), ++ num_peers: node.num_peers.to_string(), ++ latest_rgs_snapshot_timestamp: node ++ .latest_rgs_snapshot_timestamp ++ .map(|value| value.to_string()), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotChannel { ++ pub channel_id: String, ++ pub peer_pubkey: String, ++ pub status: ChannelStatusStr, ++ pub ready: bool, ++ pub capacity_sat: String, ++ pub claimable_onchain_sat: String, ++ pub outbound_capacity_msat: String, ++ pub inbound_capacity_msat: String, ++ pub next_outbound_htlc_limit_msat: String, ++ pub next_outbound_htlc_minimum_msat: String, ++ pub is_usable: bool, ++ pub public: bool, ++ pub funding_txid: Option, ++ pub peer_alias: Option, ++ pub short_channel_id: Option, ++ pub asset_id: Option, ++ pub asset_local_amount: Option, ++ pub asset_remote_amount: Option, ++ pub virtual_open_mode: Option, ++} ++ ++impl From for JsonWalletSnapshotChannel { ++ fn from(channel: Channel) -> Self { ++ Self { ++ channel_id: fmt_channel_id(&channel.channel_id), ++ peer_pubkey: fmt_pubkey(&channel.peer_pubkey), ++ status: channel.status.into(), ++ ready: channel.ready, ++ capacity_sat: channel.capacity_sat.to_string(), ++ claimable_onchain_sat: channel.local_balance_sat.to_string(), ++ outbound_capacity_msat: channel.outbound_balance_msat.to_string(), ++ inbound_capacity_msat: channel.inbound_balance_msat.to_string(), ++ next_outbound_htlc_limit_msat: channel.next_outbound_htlc_limit_msat.to_string(), ++ next_outbound_htlc_minimum_msat: channel ++ .next_outbound_htlc_minimum_msat ++ .to_string(), ++ is_usable: channel.is_usable, ++ public: channel.public, ++ funding_txid: channel.funding_txid.as_ref().map(fmt_txid), ++ peer_alias: channel.peer_alias, ++ short_channel_id: channel.short_channel_id.map(|value| value.to_string()), ++ asset_id: channel.asset_id.as_ref().map(fmt_contract_id), ++ asset_local_amount: channel.asset_local_amount.map(|value| value.to_string()), ++ asset_remote_amount: channel.asset_remote_amount.map(|value| value.to_string()), ++ virtual_open_mode: channel.virtual_open_mode, ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotBlockTime { ++ pub height: u32, ++ pub timestamp: String, ++} ++ ++impl From for JsonWalletSnapshotBlockTime { ++ fn from(block: BlockTime) -> Self { ++ Self { ++ height: block.height, ++ timestamp: block.timestamp.to_string(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotTransaction { ++ pub transaction_type: TransactionTypeStr, ++ pub txid: String, ++ pub received: String, ++ pub sent: String, ++ pub fee: String, ++ pub confirmation_time: Option, ++} ++ ++impl From for JsonWalletSnapshotTransaction { ++ fn from(transaction: Transaction) -> Self { ++ Self { ++ transaction_type: transaction.transaction_type.into(), ++ txid: fmt_txid(&transaction.txid), ++ received: transaction.received.to_string(), ++ sent: transaction.sent.to_string(), ++ fee: transaction.fee.to_string(), ++ confirmation_time: transaction.confirmation_time.map(Into::into), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotPayment { ++ pub amt_msat: Option, ++ pub asset_amount: Option, ++ pub asset_id: Option, ++ pub payment_hash: String, ++ pub payment_type: PaymentTypeStr, ++ pub status: HtlcStatusStr, ++ pub created_at: String, ++ pub updated_at: String, ++ pub payee_pubkey: String, ++} ++ ++impl From for JsonWalletSnapshotPayment { ++ fn from(payment: Payment) -> Self { ++ Self { ++ amt_msat: payment.amt_msat.map(|value| value.to_string()), ++ asset_amount: payment.asset_amount.map(|value| value.to_string()), ++ asset_id: payment.asset_id.as_ref().map(fmt_contract_id), ++ payment_hash: fmt_payment_hash(&payment.payment_hash), ++ payment_type: payment.payment_type.into(), ++ status: payment.status.into(), ++ created_at: payment.created_at.to_string(), ++ updated_at: payment.updated_at.to_string(), ++ payee_pubkey: fmt_pubkey(&payment.payee_pubkey), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotTransfer { ++ pub idx: i32, ++ pub created_at: String, ++ pub updated_at: String, ++ pub status: String, ++ pub requested_assignment: Option, ++ pub assignments: Vec, ++ pub kind: String, ++ pub txid: Option, ++ pub recipient_id: Option, ++ pub receive_utxo: Option, ++ pub change_utxo: Option, ++ pub expiration: Option, ++ pub transport_endpoints: Vec, ++} ++ ++impl From for JsonWalletSnapshotTransfer { ++ fn from(transfer: Transfer) -> Self { ++ Self { ++ idx: transfer.idx, ++ created_at: transfer.created_at.to_string(), ++ updated_at: transfer.updated_at.to_string(), ++ status: transfer.status, ++ requested_assignment: transfer.requested_assignment, ++ assignments: transfer.assignments, ++ kind: transfer.kind, ++ txid: transfer.txid.as_ref().map(fmt_txid), ++ recipient_id: transfer.recipient_id, ++ receive_utxo: transfer.receive_utxo, ++ change_utxo: transfer.change_utxo, ++ expiration: transfer.expiration.map(|value| value.to_string()), ++ transport_endpoints: transfer ++ .transport_endpoints ++ .into_iter() ++ .map(Into::into) ++ .collect(), ++ } ++ } ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotAssetTransfers { ++ pub asset_id: String, ++ pub transfers: Vec, ++} ++ ++#[derive(Debug, Serialize)] ++pub(crate) struct JsonWalletSnapshotResponse { ++ pub contract_version: u16, ++ pub native_source: &'static str, ++ pub capture_sequence: String, ++ pub started_at_ms: String, ++ pub completed_at_ms: String, ++ pub network_before: JsonNetworkInfo, ++ pub network_after: JsonNetworkInfo, ++ pub node: JsonWalletSnapshotNode, ++ pub btc: JsonExactBtcBalanceInfo, ++ pub assets: Vec, ++ pub channels: Vec, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub transactions: Option>, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub payments: Option>, ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub transfers: Option>, ++} ++ ++#[cfg(test)] ++mod wallet_snapshot_contract_tests { ++ use super::*; ++ ++ #[test] ++ fn network_info_uses_canonical_lowercase_names() { ++ let value = JsonNetworkInfo::from(NetworkInfo { ++ network: "Regtest".to_string(), ++ height: 42, ++ }); ++ let json = serde_json::to_value(value).expect("serialize network info"); ++ ++ assert_eq!(json["network"], "regtest"); ++ assert_eq!(json["height"], 42); ++ } ++ ++ #[test] ++ fn sync_request_rejects_unknown_fields() { ++ let error = serde_json::from_str::( ++ r#"{"mode":"routine","typo":true}"#, ++ ) ++ .expect_err("unknown request fields must fail closed"); ++ ++ assert!(error.to_string().contains("unknown field")); ++ } ++ ++ #[test] ++ fn snapshot_request_has_bounded_documented_defaults() { ++ let request = serde_json::from_str::("{}") ++ .expect("empty snapshot request"); ++ ++ assert!(request.asset_ids.is_empty()); ++ assert_eq!(request.max_assets, 32); ++ assert_eq!(request.max_channels, 128); ++ assert_eq!(request.max_activity_items, 1_000); ++ assert!(!request.include_activity); ++ } ++ ++ #[test] ++ fn exact_btc_balance_serializes_every_amount_as_decimal_text() { ++ let value = JsonExactBtcBalanceInfo::from(BtcBalanceInfo { ++ vanilla: BtcBalance { ++ settled: u64::MAX, ++ future: 2, ++ spendable: 1, ++ }, ++ colored: BtcBalance { ++ settled: 3, ++ future: 4, ++ spendable: 5, ++ }, ++ }); ++ ++ let json = serde_json::to_value(value).expect("serialize exact balance"); ++ assert_eq!(json["vanilla"]["settled"], u64::MAX.to_string()); ++ assert_eq!(json["colored"]["spendable"], "5"); ++ assert!(json["vanilla"]["settled"].is_string()); ++ } ++} ++ + #[derive(Debug, Serialize)] + pub(crate) struct JsonSignMessageResponse { + pub signed_message: String, +diff --git a/bindings/c-ffi/src/lib.rs b/bindings/c-ffi/src/lib.rs +index 24c3371..8c9441d 100644 +--- a/bindings/c-ffi/src/lib.rs ++++ b/bindings/c-ffi/src/lib.rs +@@ -547,6 +547,28 @@ pub extern "C" fn rln_sync(node: &COpaqueStruct) -> CResultString { + ffi_call!("rln_sync", api::sync(node)) + } + ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_sync_wallet( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_sync_wallet", ++ api::sync_wallet(node, request_json) ++ ) ++} ++ ++#[unsafe(no_mangle)] ++pub extern "C" fn rln_wallet_snapshot( ++ node: &COpaqueStruct, ++ request_json: *const c_char, ++) -> CResultString { ++ ffi_call!( ++ "rln_wallet_snapshot", ++ api::wallet_snapshot(node, request_json) ++ ) ++} ++ + // --------------------------------------------------------------------------- + // Swaps / onion + // --------------------------------------------------------------------------- +diff --git a/src/sdk/mod.rs b/src/sdk/mod.rs +index ebdf050..7a45021 100644 +--- a/src/sdk/mod.rs ++++ b/src/sdk/mod.rs +@@ -3648,6 +3648,102 @@ pub(crate) async fn sync(state: Arc) -> Result<(), APIError> { + Ok(()) + } + ++#[derive(Clone, Copy, Debug, PartialEq, Eq)] ++pub(crate) enum WalletSyncModeData { ++ Routine, ++ Recovery, ++} ++ ++pub(crate) struct WalletSyncKeychainData { ++ pub succeeded: bool, ++ pub error_code: Option, ++} ++ ++pub(crate) struct WalletSyncData { ++ pub mode: WalletSyncModeData, ++ pub vanilla: WalletSyncKeychainData, ++ pub colored: WalletSyncKeychainData, ++} ++ ++fn sync_keychain( ++ unlocked_state: &crate::utils::UnlockedAppState, ++ keychain: rgb_lib::wallet::SyncKeychain, ++ strategy: rgb_lib::wallet::SyncStrategy, ++) -> WalletSyncKeychainData { ++ match unlocked_state.rgb_sync(rgb_lib::wallet::SyncOptions { keychain, strategy }) { ++ Ok(()) => WalletSyncKeychainData { ++ succeeded: true, ++ error_code: None, ++ }, ++ Err(_) => WalletSyncKeychainData { ++ succeeded: false, ++ error_code: Some("FAILED_BDK_SYNC".to_string()), ++ }, ++ } ++} ++ ++fn sync_strategy_for_mode(mode: WalletSyncModeData) -> rgb_lib::wallet::SyncStrategy { ++ match mode { ++ WalletSyncModeData::Routine => rgb_lib::wallet::SyncStrategy::FullSync, ++ WalletSyncModeData::Recovery => rgb_lib::wallet::SyncStrategy::FullScan, ++ } ++} ++ ++/// Synchronize both RGB-lib keychains using an explicit production mode. ++/// ++/// Routine refresh uses `FullSync`, which updates every script already known ++/// to the wallet. Recovery uses `FullScan`, which performs address discovery ++/// and must therefore be reserved for create/restore/migration boundaries. ++/// Both keychains are attempted so callers receive structured partial-failure ++/// evidence instead of losing the first successful result when the second ++/// keychain fails. ++pub(crate) async fn sync_wallet( ++ state: Arc, ++ mode: WalletSyncModeData, ++) -> Result { ++ let guard = check_unlocked(&state).await?; ++ let unlocked_state = guard.as_ref().unwrap(); ++ let strategy = sync_strategy_for_mode(mode); ++ ++ let vanilla = sync_keychain( ++ unlocked_state, ++ rgb_lib::wallet::SyncKeychain::Vanilla { lookback: 0 }, ++ strategy, ++ ); ++ let colored = sync_keychain( ++ unlocked_state, ++ rgb_lib::wallet::SyncKeychain::Colored, ++ strategy, ++ ); ++ ++ Ok(WalletSyncData { ++ mode, ++ vanilla, ++ colored, ++ }) ++} ++ ++#[cfg(test)] ++mod wallet_sync_mode_tests { ++ use super::*; ++ ++ #[test] ++ fn routine_syncs_every_revealed_script_on_both_keychains() { ++ assert_eq!( ++ sync_strategy_for_mode(WalletSyncModeData::Routine), ++ rgb_lib::wallet::SyncStrategy::FullSync ++ ); ++ } ++ ++ #[test] ++ fn recovery_performs_address_discovery_on_both_keychains() { ++ assert_eq!( ++ sync_strategy_for_mode(WalletSyncModeData::Recovery), ++ rgb_lib::wallet::SyncStrategy::FullScan ++ ); ++ } ++} ++ + pub(crate) async fn decode_ln_invoice( + state: Arc, + invoice: String, +diff --git a/src/uniffi_api/mod.rs b/src/uniffi_api/mod.rs +index e624ae1..5ffa321 100644 +--- a/src/uniffi_api/mod.rs ++++ b/src/uniffi_api/mod.rs +@@ -843,6 +843,30 @@ impl SdkNode { + Ok(()) + } + ++ pub fn sync_wallet(&self, mode: WalletSyncMode) -> Result { ++ let state = self.handle.app_state(); ++ let data = block_on_sdk(sdk::sync_wallet( ++ state, ++ match mode { ++ WalletSyncMode::Routine => sdk::WalletSyncModeData::Routine, ++ WalletSyncMode::Recovery => sdk::WalletSyncModeData::Recovery, ++ }, ++ ))?; ++ ++ let map_keychain = |result: sdk::WalletSyncKeychainData| WalletSyncKeychainResult { ++ succeeded: result.succeeded, ++ error_code: result.error_code, ++ }; ++ Ok(WalletSyncResult { ++ mode: match data.mode { ++ sdk::WalletSyncModeData::Routine => WalletSyncMode::Routine, ++ sdk::WalletSyncModeData::Recovery => WalletSyncMode::Recovery, ++ }, ++ vanilla: map_keychain(data.vanilla), ++ colored: map_keychain(data.colored), ++ }) ++ } ++ + pub fn node_info(&self) -> Result { + let state = self.handle.app_state(); + let data = block_on_sdk(sdk::node_info(state))?; +diff --git a/src/uniffi_api/types.rs b/src/uniffi_api/types.rs +index 3cd9097..880851b 100644 +--- a/src/uniffi_api/types.rs ++++ b/src/uniffi_api/types.rs +@@ -102,6 +102,23 @@ pub struct BtcBalanceInfo { + pub colored: BtcBalance, + } + ++#[derive(Clone, Copy)] ++pub enum WalletSyncMode { ++ Routine, ++ Recovery, ++} ++ ++pub struct WalletSyncKeychainResult { ++ pub succeeded: bool, ++ pub error_code: Option, ++} ++ ++pub struct WalletSyncResult { ++ pub mode: WalletSyncMode, ++ pub vanilla: WalletSyncKeychainResult, ++ pub colored: WalletSyncKeychainResult, ++} ++ + pub struct SignMessageResponse { + pub signed_message: String, + } diff --git a/rln.h b/rln.h index 7e44bcb..20197b7 100644 --- a/rln.h +++ b/rln.h @@ -26,8 +26,9 @@ typedef struct CResult { } CResult; /** - * Drop a `NativeExternalSigner` handle. Safe to call immediately after - * attach / init / unlock succeeds: RLN holds its own `Arc` clone. + * Shut down and drop a `NativeExternalSigner` handle. Call only after node + * shutdown has completed; shutdown invalidates every outstanding `Arc` clone + * so the seed-bearing backend and persistent VLS store are released. */ void free_native_external_signer(struct COpaqueStruct obj); @@ -44,9 +45,18 @@ struct CResultString rln_asset_metadata(const struct COpaqueStruct *node, const struct CResultString rln_btc_balance(const struct COpaqueStruct *node, bool skip_sync); +struct CResultString rln_cancel_btc_send_plan(const struct COpaqueStruct *node, + const char *request_json); + +struct CResultString rln_cancel_create_utxos_plan(const struct COpaqueStruct *node, + const char *request_json); + struct CResultString rln_cancel_hodl_invoice(const struct COpaqueStruct *node, const char *request_json); +struct CResultString rln_cancel_rgb_send_plan(const struct COpaqueStruct *node, + const char *request_json); + struct CResultString rln_check_indexer_url(const struct COpaqueStruct *node, const char *indexer_url); @@ -58,6 +68,15 @@ struct CResultString rln_claim_hodl_invoice(const struct COpaqueStruct *node, struct CResultString rln_close_channel(const struct COpaqueStruct *node, const char *request_json); +struct CResultString rln_commit_prepared_btc_send(const struct COpaqueStruct *node, + const char *request_json); + +struct CResultString rln_commit_prepared_create_utxos(const struct COpaqueStruct *node, + const char *request_json); + +struct CResultString rln_commit_prepared_rgb_send(const struct COpaqueStruct *node, + const char *request_json); + struct CResultString rln_connect_peer(const struct COpaqueStruct *node, const char *peer_pubkey_and_addr); @@ -92,6 +111,12 @@ struct CResultString rln_get_swap(const struct COpaqueStruct *node, const char *payment_hash, bool taker_flag); +struct CResultString rln_import_rgb_contract(const struct COpaqueStruct *node, + const char *request_json); + +struct CResultString rln_import_rgb_transfer_consignment(const struct COpaqueStruct *node, + const char *request_json); + struct CResultString rln_inflate(const struct COpaqueStruct *node, const char *request_json); struct CResultString rln_invoice_status(const struct COpaqueStruct *node, const char *invoice); @@ -110,6 +135,9 @@ struct CResultString rln_issue_asset_uda(const struct COpaqueStruct *node, struct CResultString rln_keysend(const struct COpaqueStruct *node, const char *request_json); +struct CResultString rln_list_address_receipts(const struct COpaqueStruct *node, + const char *address); + struct CResultString rln_list_assets(const struct COpaqueStruct *node, const char *filter_asset_schemas_json); @@ -119,6 +147,10 @@ struct CResultString rln_list_payments(const struct COpaqueStruct *node); struct CResultString rln_list_peers(const struct COpaqueStruct *node); +struct CResultString rln_list_pending_rgb_send_plans(const struct COpaqueStruct *node); + +struct CResultString rln_list_pending_vanilla_transactions(const struct COpaqueStruct *node); + struct CResultString rln_list_swaps(const struct COpaqueStruct *node); struct CResultString rln_list_transactions(const struct COpaqueStruct *node, @@ -143,6 +175,11 @@ struct CResult rln_native_external_signer_new(const char *seed_hex, const char *network, bool permissive_policy); +struct CResult rln_native_external_signer_new_with_storage(const char *seed_hex, + const char *network, + bool permissive_policy, + const char *storage_dir_path); + struct CResultString rln_network_info(const struct COpaqueStruct *node); struct CResultString rln_node_info(const struct COpaqueStruct *node); @@ -152,6 +189,15 @@ struct CResultString rln_open_channel(const struct COpaqueStruct *node, const ch struct CResultString rln_post_asset_media(const struct COpaqueStruct *node, const char *request_json); +struct CResultString rln_prepare_btc_send(const struct COpaqueStruct *node, + const char *request_json); + +struct CResultString rln_prepare_create_utxos(const struct COpaqueStruct *node, + const char *request_json); + +struct CResultString rln_prepare_rgb_send(const struct COpaqueStruct *node, + const char *request_json); + struct CResultString rln_refresh_transfers(const struct COpaqueStruct *node, const char *request_json); @@ -161,6 +207,9 @@ struct CResultString rln_rotate_address(const struct COpaqueStruct *node); struct CResultString rln_sdk_initialize(const char *request_json); +struct CResultString rln_sdk_node_adopt_native_operation(const struct COpaqueStruct *node, + const char *operation_id); + /** * APay receiver-side registration with an LSP. Argument is the LSP's * node_id as a hex string (compressed secp256k1). Returns JSON of @@ -174,6 +223,9 @@ struct CResultString rln_sdk_node_apay_new(const struct COpaqueStruct *node, struct CResultString rln_sdk_node_attach_native_external_signer(const struct COpaqueStruct *node, const struct COpaqueStruct *signer); +struct CResultString rln_sdk_node_cancel_native_operation(const struct COpaqueStruct *node, + const char *operation_id); + struct CResultString rln_sdk_node_detach_external_signer(const struct COpaqueStruct *node); struct CResultString rln_sdk_node_init(const struct COpaqueStruct *node, @@ -186,10 +238,17 @@ struct CResultString rln_sdk_node_init_with_external_signer(const struct COpaque struct CResultString rln_sdk_node_init_with_native_external_signer(const struct COpaqueStruct *node, const struct COpaqueStruct *signer); +struct CResultString rln_sdk_node_native_operation_status(const struct COpaqueStruct *node, + const char *operation_id); + struct CResult rln_sdk_node_new(const char *request_json); struct CResultString rln_sdk_node_shutdown(const struct COpaqueStruct *node); +struct CResultString rln_sdk_node_start_unlock_with_native_external_signer(const struct COpaqueStruct *node, + const struct COpaqueStruct *signer, + const char *request_json); + struct CResultString rln_sdk_node_unlock(const struct COpaqueStruct *node, const char *request_json); @@ -223,6 +282,14 @@ struct CResultString rln_sdk_node_vss_backup(const struct COpaqueStruct *node); struct CResultString rln_sdk_node_vss_clear_fence(const struct COpaqueStruct *node, const char *request_json); +/** + * Permanently delete every object in the authenticated VSS store. + * Request JSON: `{"password":"..."}`. The node must be locked. + * Returns `{"deleted_keys": u64}` after a verified empty re-list. + */ +struct CResultString rln_sdk_node_vss_delete_all(const struct COpaqueStruct *node, + const char *request_json); + struct CResultString rln_sdk_shutdown(void); struct CResultString rln_send_btc(const struct COpaqueStruct *node, const char *request_json); @@ -238,6 +305,8 @@ struct CResultString rln_sign_message(const struct COpaqueStruct *node, const ch struct CResultString rln_sync(const struct COpaqueStruct *node); +struct CResultString rln_sync_wallet(const struct COpaqueStruct *node, const char *request_json); + struct CResultString rln_taker(const struct COpaqueStruct *node, const char *request_json); struct CResultString rln_uniffi_healthcheck(void); @@ -247,3 +316,6 @@ struct CResultString rln_uniffi_is_initialized(void); struct CResultString rln_verify_message(const struct COpaqueStruct *node, const char *message, const char *signature); + +struct CResultString rln_wallet_snapshot(const struct COpaqueStruct *node, + const char *request_json); diff --git a/scripts/build-cffi.sh b/scripts/build-cffi.sh index a7f1d22..cdd2185 100755 --- a/scripts/build-cffi.sh +++ b/scripts/build-cffi.sh @@ -2,16 +2,19 @@ set -euo pipefail # ============================================================================ -# Build librlncffi.a for darwin-arm64 + iOS targets. +# Build librlncffi.a for Darwin, iOS, or Android targets. # # Mirrors rgb-lib-bare's build-ios.sh: uses `cargo rustc --crate-type # staticlib` against the bindings/c-ffi crate in rgb-lightning-node, drops # the resulting .a into lib//, copies the cbindgen header. # # Usage: -# bash scripts/build-cffi.sh # darwin host + iOS triple +# bash scripts/build-cffi.sh # darwin host + iOS + Android # bash scripts/build-cffi.sh darwin # darwin only (canary 1) # bash scripts/build-cffi.sh ios # iOS triple only +# bash scripts/build-cffi.sh android # Android arm64, armv7, and x64 +# bash scripts/build-cffi.sh android-arm64 +# bash scripts/build-cffi.sh ios-arm64-simulator # # Set CFFI_DIR to override the c-ffi source location. # ============================================================================ @@ -44,6 +47,9 @@ echo "Mode: $MODE" if grep -q 'rln-external-signer.git?branch=main#1efe6a61' "$CFFI_DIR/Cargo.lock" 2>/dev/null; then echo "--- Pinning signer-external -> 168faab (RLN v0.6.0-beta.1 pre-rename API) ---" ( cd "$CFFI_DIR" && cargo update -p signer-external --precise 168faab43779f944d1b7e9ed85b47d463cf44ab0 ) +elif grep -q 'rln-external-signer.git?branch=main#594d8c08' "$CFFI_DIR/Cargo.lock" 2>/dev/null; then + echo "--- Pinning signer-external -> 0fb005e (RLN signer-state API) ---" + ( cd "$CFFI_DIR" && cargo update -p signer-external --precise 0fb005ec4b927ddbe13e1646d247b5bb11e8ffed ) fi build_target() { @@ -61,28 +67,35 @@ build_target() { fi if [ -z "$RUST_TARGET" ]; then - cargo rustc --release --lib --crate-type staticlib 2>&1 | tail -3 + cargo rustc --release --lib --crate-type staticlib mkdir -p "$OUT_DIR/$DIR_NAME" cp target/release/librlncffi.a "$OUT_DIR/$DIR_NAME/" else - cargo rustc --release --target "$RUST_TARGET" --lib --crate-type staticlib 2>&1 | tail -3 + cargo rustc --release --target "$RUST_TARGET" --lib --crate-type staticlib mkdir -p "$OUT_DIR/$DIR_NAME" cp "target/$RUST_TARGET/release/librlncffi.a" "$OUT_DIR/$DIR_NAME/" fi - strip -S "$OUT_DIR/$DIR_NAME/librlncffi.a" 2>/dev/null || true - SIZE=$(ls -lh "$OUT_DIR/$DIR_NAME/librlncffi.a" | awk '{print $5}') echo "✅ $DIR_NAME: $SIZE" } if [ "$MODE" = "darwin" ] || [ "$MODE" = "all" ]; then - build_target "" "darwin-arm64" + case "$(uname -m)" in + arm64|aarch64) DARWIN_TARGET="darwin-arm64" ;; + x86_64) DARWIN_TARGET="darwin-x64" ;; + *) + echo "ERROR: unsupported Darwin host architecture: $(uname -m)" + exit 1 + ;; + esac + export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" + build_target "" "$DARWIN_TARGET" cp "$CFFI_DIR/rln.h" "$PKG_DIR/rln.h" echo "✅ Header copied" fi -if [ "$MODE" = "ios" ] || [ "$MODE" = "all" ]; then +if [ "$MODE" = "ios" ] || [ "$MODE" = "all" ] || [[ "$MODE" == ios-* ]]; then IOS_SDK=$(xcrun --sdk iphoneos --show-sdk-path) IOS_SIM_SDK=$(xcrun --sdk iphonesimulator --show-sdk-path) @@ -95,9 +108,15 @@ if [ "$MODE" = "ios" ] || [ "$MODE" = "all" ]; then # device the demo runs on. Override via env if you need older. IOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" - build_target "aarch64-apple-ios" "ios-arm64" "export SDKROOT='$IOS_SDK' IPHONEOS_DEPLOYMENT_TARGET='$IOS_DEPLOYMENT_TARGET'" - build_target "aarch64-apple-ios-sim" "ios-arm64-simulator" "export SDKROOT='$IOS_SIM_SDK' IPHONEOS_DEPLOYMENT_TARGET='$IOS_DEPLOYMENT_TARGET'" - build_target "x86_64-apple-ios" "ios-x64-simulator" "export SDKROOT='$IOS_SIM_SDK' IPHONEOS_DEPLOYMENT_TARGET='$IOS_DEPLOYMENT_TARGET'" + if [ "$MODE" = "ios" ] || [ "$MODE" = "all" ] || [ "$MODE" = "ios-arm64" ]; then + build_target "aarch64-apple-ios" "ios-arm64" "export SDKROOT='$IOS_SDK' IPHONEOS_DEPLOYMENT_TARGET='$IOS_DEPLOYMENT_TARGET'" + fi + if [ "$MODE" = "ios" ] || [ "$MODE" = "all" ] || [ "$MODE" = "ios-arm64-simulator" ]; then + build_target "aarch64-apple-ios-sim" "ios-arm64-simulator" "export SDKROOT='$IOS_SIM_SDK' IPHONEOS_DEPLOYMENT_TARGET='$IOS_DEPLOYMENT_TARGET'" + fi + if [ "$MODE" = "ios" ] || [ "$MODE" = "all" ] || [ "$MODE" = "ios-x64-simulator" ]; then + build_target "x86_64-apple-ios" "ios-x64-simulator" "export SDKROOT='$IOS_SIM_SDK' IPHONEOS_DEPLOYMENT_TARGET='$IOS_DEPLOYMENT_TARGET'" + fi fi # Android — uses cargo-ndk to set NDK linker / sysroot env vars. @@ -112,19 +131,27 @@ build_android_target() { echo "--- Building for $RUST_TARGET → $DIR_NAME (Android NDK ABI $NDK_ABI) ---" cd "$CFFI_DIR" - cargo ndk -t "$NDK_ABI" rustc --release --lib --crate-type staticlib 2>&1 | tail -3 + cargo ndk -t "$NDK_ABI" -P "${ANDROID_API_LEVEL:-29}" \ + rustc --release --lib --crate-type staticlib mkdir -p "$OUT_DIR/$DIR_NAME" cp "target/$RUST_TARGET/release/librlncffi.a" "$OUT_DIR/$DIR_NAME/" # Use llvm-strip from the NDK; macOS `strip` corrupts ELF archives with a # "truncated or malformed archive" error at ld.lld link time. - LLVM_STRIP="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-strip" + NDK_PREBUILT_ROOT="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" + NDK_HOST_COUNT=$(find "$NDK_PREBUILT_ROOT" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') + if [ "$NDK_HOST_COUNT" -ne 1 ]; then + echo "ERROR: expected one NDK host toolchain, found $NDK_HOST_COUNT" + exit 1 + fi + NDK_HOST_DIR=$(find "$NDK_PREBUILT_ROOT" -mindepth 1 -maxdepth 1 -type d) + LLVM_STRIP="$NDK_HOST_DIR/bin/llvm-strip" [ -x "$LLVM_STRIP" ] && "$LLVM_STRIP" --strip-debug "$OUT_DIR/$DIR_NAME/librlncffi.a" 2>/dev/null || true SIZE=$(ls -lh "$OUT_DIR/$DIR_NAME/librlncffi.a" | awk '{print $5}') echo "✅ $DIR_NAME: $SIZE" } -if [ "$MODE" = "android" ] || [ "$MODE" = "all" ]; then +if [ "$MODE" = "android" ] || [ "$MODE" = "all" ] || [[ "$MODE" == android-* ]]; then if [ -z "${ANDROID_NDK_HOME:-}" ]; then echo "ERROR: ANDROID_NDK_HOME not set" echo " e.g. export ANDROID_NDK_HOME=\$HOME/Library/Android/sdk/ndk/" @@ -135,9 +162,15 @@ if [ "$MODE" = "android" ] || [ "$MODE" = "all" ]; then exit 1 fi - build_android_target "arm64-v8a" "aarch64-linux-android" "android-arm64" - build_android_target "armeabi-v7a" "armv7-linux-androideabi" "android-arm" - build_android_target "x86_64" "x86_64-linux-android" "android-x64" + if [ "$MODE" = "android" ] || [ "$MODE" = "all" ] || [ "$MODE" = "android-arm64" ]; then + build_android_target "arm64-v8a" "aarch64-linux-android" "android-arm64" + fi + if [ "$MODE" = "android" ] || [ "$MODE" = "all" ] || [ "$MODE" = "android-arm" ]; then + build_android_target "armeabi-v7a" "armv7-linux-androideabi" "android-arm" + fi + if [ "$MODE" = "android" ] || [ "$MODE" = "all" ] || [ "$MODE" = "android-x64" ]; then + build_android_target "x86_64" "x86_64-linux-android" "android-x64" + fi fi echo "" diff --git a/scripts/build-prebuilds.sh b/scripts/build-prebuilds.sh index 6efc96e..2e7b700 100755 --- a/scripts/build-prebuilds.sh +++ b/scripts/build-prebuilds.sh @@ -19,6 +19,14 @@ cd "$PKG_DIR" ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/Library/Android/sdk/ndk/27.1.12297006}" ANDROID_TOOLCHAIN="$ANDROID_NDK_HOME/build/cmake/android.toolchain.cmake" +ANDROID_API_LEVEL="${ANDROID_API_LEVEL:-29}" +IOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" +CMAKE_BARE_DIR="$( + node "$SCRIPT_DIR/resolve-package-root.js" cmake-bare "$PKG_DIR" +)" +CMAKE_NPM_DIR="$( + node "$SCRIPT_DIR/resolve-package-root.js" cmake-npm "$PKG_DIR" +)" build_target() { local TARGET_NAME="$1" @@ -36,27 +44,35 @@ build_target() { mkdir -p "$BUILD_DIR" CMAKE_ARGS=( - -Dcmake-bare_DIR="$PKG_DIR/node_modules/cmake-bare" - -Dcmake-npm_DIR="$PKG_DIR/node_modules/cmake-npm" + -Dcmake-bare_DIR="$CMAKE_BARE_DIR" + -Dcmake-npm_DIR="$CMAKE_NPM_DIR" ) case "$TARGET_NAME" in ios-arm64) - CMAKE_ARGS+=(-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=iphoneos) ;; + CMAKE_ARGS+=(-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=iphoneos -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET") ;; ios-arm64-simulator) - CMAKE_ARGS+=(-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=iphonesimulator) ;; + CMAKE_ARGS+=(-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_SYSROOT=iphonesimulator -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET") ;; ios-x64-simulator) - CMAKE_ARGS+=(-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_SYSROOT=iphonesimulator) ;; + CMAKE_ARGS+=(-DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_SYSROOT=iphonesimulator -DCMAKE_OSX_DEPLOYMENT_TARGET="$IOS_DEPLOYMENT_TARGET") ;; darwin-arm64) - CMAKE_ARGS+=(-DCMAKE_OSX_ARCHITECTURES=arm64) ;; + CMAKE_ARGS+=( + -DCMAKE_OSX_ARCHITECTURES=arm64 + -DCMAKE_OSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" + ) ;; + darwin-x64) + CMAKE_ARGS+=( + -DCMAKE_OSX_ARCHITECTURES=x86_64 + -DCMAKE_OSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" + ) ;; android-arm64) - CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-24) ;; + CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM="android-$ANDROID_API_LEVEL") ;; android-arm) - CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=armeabi-v7a -DANDROID_PLATFORM=android-24) ;; + CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=armeabi-v7a -DANDROID_PLATFORM="android-$ANDROID_API_LEVEL") ;; android-x64) - CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=x86_64 -DANDROID_PLATFORM=android-24) ;; + CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=x86_64 -DANDROID_PLATFORM="android-$ANDROID_API_LEVEL") ;; android-ia32) - CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=x86 -DANDROID_PLATFORM=android-24) ;; + CMAKE_ARGS+=(-DCMAKE_TOOLCHAIN_FILE="$ANDROID_TOOLCHAIN" -DANDROID_ABI=x86 -DANDROID_PLATFORM="android-$ANDROID_API_LEVEL") ;; esac cmake -B "$BUILD_DIR" -S . "${CMAKE_ARGS[@]}" 2>&1 | tail -5 @@ -71,10 +87,24 @@ build_target() { fi mkdir -p "prebuilds/$TARGET_NAME" - cp "$BARE_FILE" "prebuilds/$TARGET_NAME/utexo__rgb-lightning-node-bare.bare" + OUTPUT_FILE="prebuilds/$TARGET_NAME/utexo__rgb-lightning-node-bare.bare" + cp "$BARE_FILE" "$OUTPUT_FILE" - SIZE=$(ls -lh "prebuilds/$TARGET_NAME/utexo__rgb-lightning-node-bare.bare" | awk '{print $5}') - echo " ✅ prebuilds/$TARGET_NAME/utexo__rgb-lightning-node-bare.bare ($SIZE)" + case "$TARGET_NAME" in + android-*) + NDK_PREBUILT_ROOT="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt" + NDK_HOST_COUNT=$(find "$NDK_PREBUILT_ROOT" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ') + if [ "$NDK_HOST_COUNT" -ne 1 ]; then + echo "ERROR: expected one NDK host toolchain, found $NDK_HOST_COUNT" + exit 1 + fi + NDK_HOST_DIR=$(find "$NDK_PREBUILT_ROOT" -mindepth 1 -maxdepth 1 -type d) + "$NDK_HOST_DIR/bin/llvm-strip" --strip-debug "$OUTPUT_FILE" + ;; + esac + + SIZE=$(ls -lh "$OUTPUT_FILE" | awk '{print $5}') + echo " ✅ $OUTPUT_FILE ($SIZE)" rm -rf "$BUILD_DIR" } @@ -82,7 +112,7 @@ build_target() { if [ $# -ge 1 ]; then build_target "$1" else - for target in darwin-arm64 ios-arm64 ios-arm64-simulator ios-x64-simulator \ + for target in darwin-arm64 darwin-x64 ios-arm64 ios-arm64-simulator ios-x64-simulator \ android-arm64 android-arm android-x64 android-ia32; do build_target "$target" done diff --git a/scripts/install-android-ndk.sh b/scripts/install-android-ndk.sh new file mode 100755 index 0000000..7815dda --- /dev/null +++ b/scripts/install-android-ndk.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +NDK_VERSION="$(node -p "require('${REPO_ROOT}/package.json').utexoNativeOverlay.androidNdkVersion")" +SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" + +if [[ -z "$SDK_ROOT" ]]; then + for candidate in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk"; do + if [[ -d "$candidate" ]]; then + SDK_ROOT="$candidate" + break + fi + done +fi + +if [[ -z "$SDK_ROOT" ]]; then + echo "ANDROID_SDK_ROOT or ANDROID_HOME must identify the Android SDK" >&2 + exit 1 +fi + +if command -v sdkmanager >/dev/null 2>&1; then + SDKMANAGER="$(command -v sdkmanager)" +else + SDKMANAGER="$(find "$SDK_ROOT/cmdline-tools" -type f -path '*/bin/sdkmanager' | sort | tail -n 1)" +fi + +if [[ -z "${SDKMANAGER:-}" || ! -x "$SDKMANAGER" ]]; then + echo "sdkmanager was not found under $SDK_ROOT" >&2 + exit 1 +fi + +NDK_ROOT="$SDK_ROOT/ndk/$NDK_VERSION" +SOURCE_PROPERTIES="$NDK_ROOT/source.properties" + +if [[ ! -f "$SOURCE_PROPERTIES" ]]; then + "$SDKMANAGER" --sdk_root="$SDK_ROOT" --install "ndk;$NDK_VERSION" >&2 +fi + +if [[ ! -f "$SOURCE_PROPERTIES" ]]; then + echo "Android NDK $NDK_VERSION was not installed at $NDK_ROOT" >&2 + exit 1 +fi + +INSTALLED_VERSION="$(sed -n 's/^Pkg\.Revision[[:space:]]*=[[:space:]]*//p' "$SOURCE_PROPERTIES" | head -n 1 | tr -d '\r')" +if [[ "$INSTALLED_VERSION" != "$NDK_VERSION" ]]; then + echo "Android NDK contract mismatch: expected $NDK_VERSION, found $INSTALLED_VERSION" >&2 + exit 1 +fi + +printf '%s\n' "$NDK_ROOT" diff --git a/scripts/install-native-artifacts.js b/scripts/install-native-artifacts.js new file mode 100644 index 0000000..43adb5c --- /dev/null +++ b/scripts/install-native-artifacts.js @@ -0,0 +1,55 @@ +'use strict' + +const path = require('node:path') +const { spawnSync } = require('node:child_process') +const { + ensureOverlayArtifacts, + nativeArtifactInstallMode, + readOverlayConfig, + resolveInstallTargets +} = require('./native-overlay') + +const packageRoot = path.resolve(__dirname, '..') + +function requestedPlatform (args) { + if (args.length === 0) return undefined + if (args.length !== 2 || args[0] !== '--platform') { + throw new Error( + 'usage: node scripts/install-native-artifacts.js [--platform android|ios|darwin|apple|all]' + ) + } + return args[1] +} + +try { + const installMode = nativeArtifactInstallMode(process.env) + if (installMode === 'js-only') { + console.log( + '[rgb-lightning-node-bare] Native artifact installation explicitly skipped for JS-only tooling.' + ) + } else { + const overlay = readOverlayConfig(packageRoot) + if (overlay) { + const targets = resolveInstallTargets( + overlay, + process.env, + process.platform, + requestedPlatform(process.argv.slice(2)) + ) + ensureOverlayArtifacts(packageRoot, overlay, targets, process.env) + } else { + const result = spawnSync('bash', [path.join(__dirname, 'download-libs.sh')], { + cwd: packageRoot, + env: process.env, + stdio: 'inherit' + }) + if (result.error) throw result.error + if (result.status !== 0) { + throw new Error(`release asset installer exited with status ${result.status}`) + } + } + } +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} diff --git a/scripts/native-overlay.js b/scripts/native-overlay.js new file mode 100644 index 0000000..d4756eb --- /dev/null +++ b/scripts/native-overlay.js @@ -0,0 +1,666 @@ +'use strict' + +const crypto = require('node:crypto') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawnSync } = require('node:child_process') + +const LIBRARY_SYMBOLS = Object.freeze([ + 'rln_cancel_btc_send_plan', + 'rln_cancel_create_utxos_plan', + 'rln_cancel_rgb_send_plan', + 'rln_commit_prepared_btc_send', + 'rln_commit_prepared_create_utxos', + 'rln_commit_prepared_rgb_send', + 'rln_import_rgb_transfer_consignment', + 'rln_import_rgb_contract', + 'rln_list_address_receipts', + 'rln_list_pending_rgb_send_plans', + 'rln_list_pending_vanilla_transactions', + 'rln_native_external_signer_new_with_storage', + 'rln_prepare_btc_send', + 'rln_prepare_create_utxos', + 'rln_prepare_rgb_send', + 'rln_send_payment', + 'rln_sdk_node_adopt_native_operation', + 'rln_sdk_node_cancel_native_operation', + 'rln_sdk_node_native_operation_status', + 'rln_sdk_node_start_unlock_with_native_external_signer', + 'rln_sdk_node_vss_delete_all', + 'rln_sync_wallet', + 'rln_wallet_snapshot' +]) + +const PREBUILD_SYMBOLS = Object.freeze([ + 'bare_register_module_v0', + ...LIBRARY_SYMBOLS +]) + +const TARGET_GROUPS = Object.freeze({ + darwin: Object.freeze(['darwin-arm64', 'darwin-x64']), + ios: Object.freeze([ + 'ios-arm64', + 'ios-arm64-simulator', + 'ios-x64-simulator' + ]), + android: Object.freeze([ + 'android-arm64', + 'android-arm', + 'android-x64' + ]) +}) + +const RUST_TARGETS = Object.freeze({ + 'darwin-arm64': 'aarch64-apple-darwin', + 'darwin-x64': 'x86_64-apple-darwin', + 'ios-arm64': 'aarch64-apple-ios', + 'ios-arm64-simulator': 'aarch64-apple-ios-sim', + 'ios-x64-simulator': 'x86_64-apple-ios', + 'android-arm64': 'aarch64-linux-android', + 'android-arm': 'armv7-linux-androideabi', + 'android-x64': 'x86_64-linux-android' +}) + +const SUPPORTED_TARGETS = Object.freeze(Object.values(TARGET_GROUPS).flat()) +const ARTIFACT_MANIFEST = '.utexo-native-overlay.json' +const ARTIFACT_MANIFEST_SCHEMA = 2 +const JS_ONLY_INSTALL_ENV = 'RLN_BARE_JS_ONLY_INSTALL' +const TARGETS_ENV = 'RLN_BARE_TARGETS' + +function fail (message) { + throw new Error(`[rgb-lightning-node-bare] ${message}`) +} + +function nativeArtifactInstallMode (environment = process.env) { + const requested = environment[JS_ONLY_INSTALL_ENV] + if (requested === undefined) return 'native' + if (requested !== '1') { + fail(`${JS_ONLY_INSTALL_ENV} accepts only the explicit value 1`) + } + return 'js-only' +} + +function targetsForGroup (config, group) { + const prefixes = group === 'apple' ? ['darwin-', 'ios-'] : [`${group}-`] + return config.targets.filter((target) => ( + prefixes.some((prefix) => target.startsWith(prefix)) + )) +} + +function validateRequestedTargets (config, targets) { + if (targets.length === 0) { + fail('native target selection did not match any configured targets') + } + const unique = [...new Set(targets)] + if ( + unique.length !== targets.length || + unique.some((target) => !config.targets.includes(target)) + ) { + fail('native target selection contains a duplicate or unconfigured target') + } + return Object.freeze(unique) +} + +function resolveInstallTargets ( + config, + environment = process.env, + hostPlatform = process.platform, + requestedPlatform +) { + if (environment[TARGETS_ENV] !== undefined) { + const rawTargets = environment[TARGETS_ENV] + .split(',') + .map((target) => target.trim()) + .filter(Boolean) + return validateRequestedTargets(config, rawTargets) + } + + const platform = requestedPlatform || environment.EAS_BUILD_PLATFORM + if (platform) { + if (platform === 'all') return Object.freeze([...config.targets]) + if (!['android', 'ios', 'darwin', 'apple'].includes(platform)) { + fail(`unsupported native platform selection: ${platform}`) + } + return validateRequestedTargets(config, targetsForGroup(config, platform)) + } + + if (hostPlatform === 'darwin') { + return validateRequestedTargets(config, targetsForGroup(config, 'apple')) + } + if (hostPlatform === 'linux' || hostPlatform === 'win32') { + return validateRequestedTargets(config, targetsForGroup(config, 'android')) + } + fail(`unsupported native build host: ${hostPlatform}`) +} + +function assertSupportedBuildHost (config, platform = process.platform, targets = config.targets) { + if ( + platform !== 'darwin' && + targets.some((target) => ( + target.startsWith('ios-') || target.startsWith('darwin-') + )) + ) { + fail( + 'building the selected Apple artifacts requires macOS; ' + + `use ${JS_ONLY_INSTALL_ENV}=1 only for tooling that will not load the native addon` + ) + } +} + +function sha256 (filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function overlayIdentity (config) { + return Object.freeze({ + schemaVersion: ARTIFACT_MANIFEST_SCHEMA, + repository: config.repository, + ref: config.ref, + commit: config.commit, + patchSha256: config.patchSha256, + rustToolchain: config.rustToolchain, + iosDeploymentTarget: config.iosDeploymentTarget, + androidNdkVersion: config.androidNdkVersion, + androidApiLevel: config.androidApiLevel, + cargoNdkVersion: config.cargoNdkVersion, + bindgenCliVersion: config.bindgenCliVersion, + targets: [...config.targets] + }) +} + +function run (command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + encoding: 'utf8', + maxBuffer: options.maxBuffer ?? 16 * 1024 * 1024, + stdio: options.capture ? 'pipe' : 'inherit' + }) + if (result.error) throw result.error + if (result.status !== 0) { + const detail = options.capture ? `: ${(result.stderr || result.stdout).trim()}` : '' + fail(`${command} exited with status ${result.status}${detail}`) + } + return result.stdout +} + +function runProbe (command, args, cwd, environment = process.env) { + return spawnSync(command, args, { + cwd, + env: environment, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + stdio: 'pipe' + }) +} + +function readOverlayConfig (packageRoot) { + const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')) + const config = packageJson.utexoNativeOverlay + if (config === undefined) return null + if (!config || typeof config !== 'object' || Array.isArray(config)) { + fail('utexoNativeOverlay must be an object') + } + + const fields = [ + 'repository', + 'ref', + 'commit', + 'patch', + 'patchSha256', + 'rustToolchain', + 'iosDeploymentTarget', + 'androidNdkVersion', + 'androidApiLevel', + 'cargoNdkVersion', + 'bindgenCliVersion', + 'targets' + ] + for (const field of fields) { + if (!Object.prototype.hasOwnProperty.call(config, field)) { + fail(`utexoNativeOverlay.${field} is required`) + } + } + if (!/^https:\/\/github\.com\/UTEXO-Protocol\/rgb-lightning-node\.git$/.test(config.repository)) { + fail('utexoNativeOverlay.repository is not approved') + } + if (!/^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$/.test(config.ref)) { + fail('utexoNativeOverlay.ref must be an exact beta tag') + } + if (!/^[0-9a-f]{40}$/.test(config.commit)) { + fail('utexoNativeOverlay.commit must be a full Git commit') + } + if (!/^patches\/[0-9A-Za-z._-]+\.patch$/.test(config.patch)) { + fail('utexoNativeOverlay.patch must be a package-local patch') + } + if (!/^[0-9a-f]{64}$/.test(config.patchSha256)) { + fail('utexoNativeOverlay.patchSha256 must be a SHA-256 digest') + } + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(config.rustToolchain)) { + fail('utexoNativeOverlay.rustToolchain must be an exact toolchain version') + } + if (!/^[0-9]+\.[0-9]+$/.test(config.iosDeploymentTarget)) { + fail('utexoNativeOverlay.iosDeploymentTarget must be an exact iOS version') + } + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(config.androidNdkVersion)) { + fail('utexoNativeOverlay.androidNdkVersion must be an exact SDK NDK revision') + } + if (!Number.isInteger(config.androidApiLevel) || config.androidApiLevel < 21) { + fail('utexoNativeOverlay.androidApiLevel must be an Android API integer of at least 21') + } + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(config.cargoNdkVersion)) { + fail('utexoNativeOverlay.cargoNdkVersion must be an exact version') + } + if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(config.bindgenCliVersion)) { + fail('utexoNativeOverlay.bindgenCliVersion must be an exact version') + } + if (!Array.isArray(config.targets) || config.targets.length === 0) { + fail('utexoNativeOverlay.targets must be a non-empty array') + } + const targets = [...new Set(config.targets)] + if ( + targets.length !== config.targets.length || + targets.some((target) => !SUPPORTED_TARGETS.includes(target)) + ) { + fail('utexoNativeOverlay.targets contains a duplicate or unsupported target') + } + + const patchPath = path.resolve(packageRoot, config.patch) + const patchRoot = `${path.resolve(packageRoot, 'patches')}${path.sep}` + if (!patchPath.startsWith(patchRoot) || !fs.existsSync(patchPath)) { + fail('utexoNativeOverlay.patch does not resolve to a package patch') + } + if (sha256(patchPath) !== config.patchSha256) { + fail('native overlay patch checksum does not match package metadata') + } + + return Object.freeze({ ...config, patchPath, targets: Object.freeze(targets) }) +} + +function artifactPaths (root, target) { + return Object.freeze({ + library: path.join(root, 'lib', target, 'librlncffi.a'), + prebuild: path.join( + root, + 'prebuilds', + target, + 'utexo__rgb-lightning-node-bare.bare' + ) + }) +} + +function validatedNmOutput (result) { + if (result.error) throw result.error + const diagnostics = (result.stderr || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + const knownArchiveDiagnostics = result.status === 1 && + diagnostics.length > 0 && + diagnostics.every((line) => ( + line.endsWith(': no symbols') || + /\/nm: error: .+: Unknown attribute kind \([0-9]+\) \(Producer: 'LLVM[^']+' Reader: 'LLVM[^']+'\)$/.test(line) + )) + if (result.status !== 0 && !knownArchiveDiagnostics) { + fail(`nm exited with status ${result.status}: ${diagnostics.join('; ')}`) + } + return result.stdout || '' +} + +function normalizedSymbols (output) { + return output + .split('\n') + .map((symbol) => symbol.trim().replace(/^_/, '')) + .filter(Boolean) + .join('\n') +} + +function ndkRevision (ndkRoot) { + const propertiesPath = path.join(ndkRoot, 'source.properties') + if (!fs.existsSync(propertiesPath)) return null + const properties = fs.readFileSync(propertiesPath, 'utf8') + return properties.match(/^Pkg\.Revision\s*=\s*(.+)$/m)?.[1]?.trim() ?? null +} + +function resolveAndroidNdk (config, environment = process.env) { + const explicit = environment.ANDROID_NDK_HOME || environment.ANDROID_NDK_ROOT + if (explicit) { + const resolved = path.resolve(explicit) + const revision = ndkRevision(resolved) + if (revision !== config.androidNdkVersion) { + fail( + `Android NDK ${config.androidNdkVersion} is required, but ${resolved} ` + + `contains ${revision || 'no valid NDK'}` + ) + } + return resolved + } + + const sdkRoots = [ + environment.ANDROID_HOME, + environment.ANDROID_SDK_ROOT, + path.join(os.homedir(), 'Library', 'Android', 'sdk'), + path.join(os.homedir(), 'Android', 'Sdk') + ].filter(Boolean) + for (const sdkRoot of [...new Set(sdkRoots)]) { + const candidate = path.resolve(sdkRoot, 'ndk', config.androidNdkVersion) + if (ndkRevision(candidate) === config.androidNdkVersion) return candidate + } + fail( + `Android NDK ${config.androidNdkVersion} is required; install it with sdkmanager ` + + `and set ANDROID_NDK_HOME` + ) +} + +function androidLlvmTool (ndkRoot, tool) { + const prebuiltRoot = path.join(ndkRoot, 'toolchains', 'llvm', 'prebuilt') + const hosts = fs.existsSync(prebuiltRoot) + ? fs.readdirSync(prebuiltRoot).filter((entry) => ( + fs.statSync(path.join(prebuiltRoot, entry)).isDirectory() + )) + : [] + if (hosts.length !== 1) { + fail(`expected exactly one Android NDK LLVM host toolchain, found ${hosts.length}`) + } + const executable = path.join(prebuiltRoot, hosts[0], 'bin', tool) + if (!fs.existsSync(executable)) { + fail(`Android NDK tool is missing: ${executable}`) + } + return executable +} + +function inspectSymbols (filePath, target, config) { + const isAndroid = target.startsWith('android-') + const command = isAndroid + ? androidLlvmTool(resolveAndroidNdk(config), 'llvm-nm') + : 'nm' + const args = isAndroid + ? ['-g', '--defined-only', '--just-symbol-name', filePath] + : ['-gjU', filePath] + const result = spawnSync(command, args, { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: 'pipe' + }) + return normalizedSymbols(validatedNmOutput(result)) +} + +function artifactManifestPath (root) { + return path.join(root, ARTIFACT_MANIFEST) +} + +function readManifest (root) { + const manifestPath = artifactManifestPath(root) + if (!fs.existsSync(manifestPath)) return null + try { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + } catch { + fail('native artifact overlay provenance is invalid') + } +} + +function manifestMatchesIdentity (manifest, config) { + if (!manifest) return false + return Object.entries(overlayIdentity(config)).every(([key, expected]) => ( + JSON.stringify(manifest[key]) === JSON.stringify(expected) + )) +} + +function writeArtifactManifest (root, config, targets = config.targets) { + const current = readManifest(root) + const artifacts = manifestMatchesIdentity(current, config) + ? { ...current.artifacts } + : {} + for (const target of targets) { + const paths = artifactPaths(root, target) + artifacts[target] = { + librarySha256: sha256(paths.library), + prebuildSha256: sha256(paths.prebuild) + } + } + const manifest = { + ...overlayIdentity(config), + artifacts + } + fs.writeFileSync( + artifactManifestPath(root), + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o600 } + ) +} + +function verifyArtifactManifest (root, config, targets) { + const manifest = readManifest(root) + if (!manifest) { + fail('native artifacts are missing overlay provenance') + } + const expectedIdentity = overlayIdentity(config) + for (const [key, expected] of Object.entries(expectedIdentity)) { + if (JSON.stringify(manifest[key]) !== JSON.stringify(expected)) { + fail(`native artifact overlay provenance does not match ${key}`) + } + } + for (const target of targets) { + const artifacts = artifactPaths(root, target) + const recorded = manifest.artifacts && manifest.artifacts[target] + if ( + !recorded || + recorded.librarySha256 !== sha256(artifacts.library) || + recorded.prebuildSha256 !== sha256(artifacts.prebuild) + ) { + fail(`native artifact hashes do not match overlay provenance for ${target}`) + } + } +} + +function verifyArtifacts (root, targets, symbolReader = inspectSymbols, config) { + for (const target of targets) { + const artifacts = artifactPaths(root, target) + for (const [kind, filePath] of Object.entries(artifacts)) { + if (!fs.existsSync(filePath) || fs.statSync(filePath).size === 0) { + fail(`missing ${kind} artifact for ${target}`) + } + const symbols = symbolReader(filePath, target, config) + const requiredSymbols = kind === 'library' ? LIBRARY_SYMBOLS : PREBUILD_SYMBOLS + for (const symbol of requiredSymbols) { + if (!symbols.includes(symbol)) { + fail(`${kind} artifact for ${target} is missing ${symbol}`) + } + } + } + } + if (config) verifyArtifactManifest(root, config, targets) +} + +function copyArtifacts (sourceRoot, packageRoot, config, targets) { + verifyArtifacts(sourceRoot, targets, inspectSymbols, config) + for (const target of targets) { + const source = artifactPaths(sourceRoot, target) + const destination = artifactPaths(packageRoot, target) + for (const kind of Object.keys(source)) { + fs.mkdirSync(path.dirname(destination[kind]), { recursive: true }) + fs.copyFileSync(source[kind], destination[kind]) + } + } + writeArtifactManifest(packageRoot, config, targets) +} + +function exactHead (sourceRoot) { + return run('git', ['-C', sourceRoot, 'rev-parse', 'HEAD'], { capture: true }).trim() +} + +function applyOverlay (sourceRoot, config) { + if (exactHead(sourceRoot) !== config.commit) { + fail(`native source must resolve to ${config.commit}`) + } + + const forward = runProbe('git', ['-C', sourceRoot, 'apply', '--check', config.patchPath]) + if (forward.status === 0) { + run('git', ['-C', sourceRoot, 'apply', config.patchPath]) + return + } + + const reverse = runProbe('git', ['-C', sourceRoot, 'apply', '--reverse', '--check', config.patchPath]) + if (reverse.status !== 0) { + fail('native source is neither pristine nor an exact application of the configured overlay') + } +} + +function cloneSource (config) { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'utexo-rln-source-')) + const sourceRoot = path.join(temporaryRoot, 'rgb-lightning-node') + run('git', [ + 'clone', + '--recurse-submodules', + '--shallow-submodules', + '--depth', '1', + '--branch', config.ref, + config.repository, + sourceRoot + ]) + return Object.freeze({ sourceRoot, temporaryRoot }) +} + +function ensureCargoTool (command, args, packageName, expectedVersion) { + const probe = runProbe(command, args) + const installedVersion = probe.status === 0 + ? `${probe.stdout} ${probe.stderr}`.match(/[0-9]+\.[0-9]+\.[0-9]+/)?.[0] + : null + if (installedVersion === expectedVersion) return + run('cargo', [ + 'install', + '--force', + '--locked', + '--version', expectedVersion, + packageName + ]) + const verification = runProbe(command, args) + const verifiedVersion = verification.status === 0 + ? `${verification.stdout} ${verification.stderr}`.match(/[0-9]+\.[0-9]+\.[0-9]+/)?.[0] + : null + if (verifiedVersion !== expectedVersion) { + fail(`${packageName} ${expectedVersion} could not be installed reproducibly`) + } +} + +function buildArtifacts (packageRoot, sourceRoot, config, targets) { + assertSupportedBuildHost(config, process.platform, targets) + const cffiDir = path.join(sourceRoot, 'bindings', 'c-ffi') + const environment = { + ...process.env, + CFFI_DIR: cffiDir, + IPHONEOS_DEPLOYMENT_TARGET: config.iosDeploymentTarget, + RUSTUP_TOOLCHAIN: config.rustToolchain + } + const scriptsRoot = path.join(packageRoot, 'scripts') + const hasAndroid = targets.some((target) => target.startsWith('android-')) + + run('rustup', ['toolchain', 'install', config.rustToolchain, '--profile', 'minimal']) + const rustTargets = targets.map((target) => RUST_TARGETS[target]) + for (const target of [...new Set(rustTargets)]) { + run('rustup', ['target', 'add', '--toolchain', config.rustToolchain, target]) + } + + if (hasAndroid) { + const ndkRoot = resolveAndroidNdk(config) + environment.ANDROID_NDK_HOME = ndkRoot + environment.ANDROID_NDK_ROOT = ndkRoot + environment.ANDROID_API_LEVEL = String(config.androidApiLevel) + environment.AWS_LC_SYS_CMAKE_BUILDER = '1' + ensureCargoTool( + 'cargo', + ['ndk', '--version'], + 'cargo-ndk', + config.cargoNdkVersion + ) + ensureCargoTool( + 'bindgen', + ['--version'], + 'bindgen-cli', + config.bindgenCliVersion + ) + } + + for (const target of targets) { + run('bash', [path.join(scriptsRoot, 'build-cffi.sh'), target], { + cwd: packageRoot, + env: environment + }) + } + for (const target of targets) { + run('bash', [path.join(scriptsRoot, 'build-prebuilds.sh'), target], { + cwd: packageRoot, + env: environment + }) + } + writeArtifactManifest(packageRoot, config, targets) +} + +function ensureOverlayArtifacts ( + packageRoot, + config, + targets = config.targets, + environment = process.env +) { + const requestedTargets = validateRequestedTargets(config, [...targets]) + try { + verifyArtifacts(packageRoot, requestedTargets, inspectSymbols, config) + console.log( + `[rgb-lightning-node-bare] Verified native overlay artifacts: ${requestedTargets.join(', ')}` + ) + return + } catch (error) { + console.log( + '[rgb-lightning-node-bare] Native artifacts require preparation: ' + + (error instanceof Error ? error.message : String(error)) + ) + } + + const artifactRoot = environment.RLN_BARE_ARTIFACTS_DIR + if (artifactRoot) { + copyArtifacts(path.resolve(artifactRoot), packageRoot, config, requestedTargets) + verifyArtifacts(packageRoot, requestedTargets, inspectSymbols, config) + console.log('[rgb-lightning-node-bare] Imported verified native overlay artifacts.') + return + } + + let temporaryRoot + let sourceRoot + if (environment.RLN_BARE_SOURCE_DIR) { + sourceRoot = path.resolve(environment.RLN_BARE_SOURCE_DIR) + } else { + const checkout = cloneSource(config) + sourceRoot = checkout.sourceRoot + temporaryRoot = checkout.temporaryRoot + } + + try { + applyOverlay(sourceRoot, config) + buildArtifacts(packageRoot, sourceRoot, config, requestedTargets) + verifyArtifacts(packageRoot, requestedTargets, inspectSymbols, config) + console.log('[rgb-lightning-node-bare] Built and verified native overlay artifacts.') + } finally { + if (temporaryRoot) fs.rmSync(temporaryRoot, { force: true, recursive: true }) + } +} + +module.exports = { + ARTIFACT_MANIFEST, + JS_ONLY_INSTALL_ENV, + LIBRARY_SYMBOLS, + PREBUILD_SYMBOLS, + SUPPORTED_TARGETS, + TARGETS_ENV, + assertSupportedBuildHost, + artifactPaths, + ensureOverlayArtifacts, + nativeArtifactInstallMode, + normalizedSymbols, + readOverlayConfig, + resolveAndroidNdk, + resolveInstallTargets, + validatedNmOutput, + writeArtifactManifest, + verifyArtifacts +} diff --git a/scripts/native-overlay.test.js b/scripts/native-overlay.test.js new file mode 100644 index 0000000..a13597c --- /dev/null +++ b/scripts/native-overlay.test.js @@ -0,0 +1,353 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') +const { + ARTIFACT_MANIFEST, + JS_ONLY_INSTALL_ENV, + LIBRARY_SYMBOLS, + PREBUILD_SYMBOLS, + assertSupportedBuildHost, + artifactPaths, + nativeArtifactInstallMode, + normalizedSymbols, + readOverlayConfig, + resolveAndroidNdk, + resolveInstallTargets, + validatedNmOutput, + writeArtifactManifest, + verifyArtifacts +} = require('./native-overlay') + +function fixtureRoot () { + return fs.mkdtempSync(path.join(os.tmpdir(), 'utexo-native-overlay-test-')) +} + +test('package overlay metadata is exact and checksum-pinned', () => { + const packageRoot = path.resolve(__dirname, '..') + const config = readOverlayConfig(packageRoot) + + assert.equal(config.commit, 'f30a5393268de67c6bb5a1c525bc790c5b11afa2') + assert.equal(config.patchSha256, 'a765ad577bb0e0a88cd15136074357babffd61e2c3dffde624017a2a3cc8983d') + assert.equal(config.rustToolchain, '1.88.0') + assert.equal(config.iosDeploymentTarget, '16.0') + assert.equal(config.androidNdkVersion, '27.1.12297006') + assert.equal(config.androidApiLevel, 29) + assert.equal(config.cargoNdkVersion, '4.1.2') + assert.equal(config.bindgenCliVersion, '0.72.1') + assert.deepEqual(config.targets, [ + 'ios-arm64', + 'ios-arm64-simulator', + 'ios-x64-simulator', + 'android-arm64', + 'android-arm', + 'android-x64' + ]) +}) + +test('overlay contains the complete native operation registry source', () => { + const packageRoot = path.resolve(__dirname, '..') + const config = readOverlayConfig(packageRoot) + const patch = fs.readFileSync(config.patchPath, 'utf8') + + assert.match( + patch, + /diff --git a\/bindings\/c-ffi\/src\/native_operations\.rs b\/bindings\/c-ffi\/src\/native_operations\.rs/ + ) + assert.match(patch, /new file mode 100644/) + assert.match(patch, /pub\(crate\) fn start_unlock\(/) + assert.match(patch, /pub\(crate\) fn status\(/) + assert.match(patch, /pub\(crate\) fn adopt\(/) + assert.match(patch, /pub\(crate\) fn cancel\(/) +}) + +test('overlay contains the hardened shared RGB import implementation', () => { + const config = readOverlayConfig(path.resolve(__dirname, '..')) + const patch = fs.readFileSync(config.patchPath, 'utf8') + + assert.match(patch, /diff --git a\/src\/rgb_import\.rs b\/src\/rgb_import\.rs/) + assert.match(patch, /MAX_RGB_IMPORT_BASE64_CHARACTERS/) + assert.match(patch, /MAX_RGB_IMPORT_BODY_BYTES/) + assert.match(patch, /RgbTxid::from_str/) + assert.match(patch, /let task = tokio::spawn/) + assert.match(patch, /save_new_asset\(consignment, offchain_txid\)\?;/) + assert.match(patch, /95332c41fd715939ac6e078ad859d474b1f6fa9b/) +}) + +test('overlay enforces deterministic node ownership and teardown', () => { + const packageRoot = path.resolve(__dirname, '..') + const config = readOverlayConfig(packageRoot) + const patch = fs.readFileSync(config.patchPath, 'utf8') + + assert.match(patch, /NodeInstanceAlreadyActive/) + assert.match(patch, /node_instance_lease_rejects_duplicate_storage_ownership/) + assert.match(patch, /prepared_rgb_utxos_are_isolated_from_existing_and_future_witness_invoices/) + assert.match(patch, /pub extern "C" fn free_sdk_node/) + assert.match(patch, /node\.shutdown\(\)/) + assert.match(patch, /load_or_create_writer_id/) + assert.match(patch, /vss_same_installation_reclaims_fence_after_restart/) + const joinTasks = patch.indexOf('for task in std::mem::take\(&mut handles.service_tasks\)') + const disconnectPeers = patch.indexOf('handles.peer_manager.disconnect_all_peers\(\)') + const waitForPersistence = patch.indexOf('BP_SHUTDOWN_FLUSH_TIMEOUT, &mut join_handle') + assert.ok(joinTasks >= 0, 'overlay must join aborted service tasks') + assert.ok(disconnectPeers > joinTasks, 'final peer disconnect must follow task quiescence') + assert.ok(waitForPersistence > disconnectPeers, 'persistence flush must follow final disconnect') +}) + +test('overlay preserves wallet discovery, RGB payment identity, and inbound channel semantics', () => { + const config = readOverlayConfig(path.resolve(__dirname, '..')) + const patch = fs.readFileSync(config.patchPath, 'utf8') + + assert.match(patch, /does not match the revealed wallet address/) + assert.match(patch, /payment_info_persists_rgb_identity_with_its_payment_status/) + assert.match(patch, /standard_inbound_channel_is_not_reclassified_when_virtual_support_is_enabled/) + assert.match(patch, /INVOICE_EXPIRED/) + assert.match(patch, /utexo-wallet-v3/) +}) + +test('Bare node handles shut down exactly once and are destroyed during teardown', () => { + const packageRoot = path.resolve(__dirname, '..') + const binding = fs.readFileSync(path.join(packageRoot, 'binding.cc'), 'utf8') + + assert.match(binding, /js_add_teardown_callback\(env, sdk_node_teardown, ref\)/) + assert.match(binding, /js_remove_teardown_callback\(env, sdk_node_teardown, ref\)/) + assert.match(binding, /shutdown_and_free_sdk_node\(ref\)/) + assert.match(binding, /bool shutdown_attempted;/) + assert.match(binding, /if \(!ref->shutdown_attempted\)/) + assert.match(binding, /ref->shutdown_attempted = true;/) + assert.match(binding, /ERR_RLN_NODE_CLOSED/) + assert.match(binding, /if \(node == NULL\) return make_undefined\(env\)/) +}) + +test('JS-only installation requires an explicit exact opt-out', () => { + assert.equal(nativeArtifactInstallMode({}), 'native') + assert.equal(nativeArtifactInstallMode({ [JS_ONLY_INSTALL_ENV]: '1' }), 'js-only') + assert.throws( + () => nativeArtifactInstallMode({ [JS_ONLY_INSTALL_ENV]: 'true' }), + /accepts only the explicit value 1/ + ) +}) + +test('Apple source builds fail clearly on unsupported hosts', () => { + assert.doesNotThrow(() => assertSupportedBuildHost({ targets: ['ios-arm64'] }, 'darwin')) + assert.throws( + () => assertSupportedBuildHost({ targets: ['ios-arm64'] }, 'linux'), + /requires macOS/ + ) + assert.throws( + () => assertSupportedBuildHost({ targets: ['darwin-arm64'] }, 'linux'), + /requires macOS/ + ) + assert.doesNotThrow(() => assertSupportedBuildHost( + { targets: ['android-arm64'] }, + 'linux' + )) +}) + +test('install target selection is platform scoped and explicit', () => { + const config = readOverlayConfig(path.resolve(__dirname, '..')) + + assert.deepEqual(resolveInstallTargets(config, {}, 'darwin'), [ + 'ios-arm64', + 'ios-arm64-simulator', + 'ios-x64-simulator' + ]) + assert.deepEqual(resolveInstallTargets(config, {}, 'darwin', 'android'), [ + 'android-arm64', + 'android-arm', + 'android-x64' + ]) + assert.deepEqual(resolveInstallTargets( + config, + { EAS_BUILD_PLATFORM: 'android' }, + 'darwin' + ), [ + 'android-arm64', + 'android-arm', + 'android-x64' + ]) + assert.deepEqual(resolveInstallTargets( + config, + { RLN_BARE_TARGETS: 'android-arm64,android-x64' }, + 'darwin', + 'ios' + ), [ + 'android-arm64', + 'android-x64' + ]) + assert.throws( + () => resolveInstallTargets( + config, + { RLN_BARE_TARGETS: 'android-ia32' }, + 'darwin' + ), + /unconfigured target/ + ) +}) + +test('symbol normalization makes Mach-O and ELF contracts equivalent', () => { + assert.equal( + normalizedSymbols('_rln_wallet_snapshot\nbare_register_module_v0\n'), + 'rln_wallet_snapshot\nbare_register_module_v0' + ) +}) + +test('artifact verification requires every contract symbol in every output', (context) => { + const root = fixtureRoot() + context.after(() => fs.rmSync(root, { force: true, recursive: true })) + const targets = ['ios-arm64-simulator'] + const artifacts = artifactPaths(root, targets[0]) + for (const filePath of Object.values(artifacts)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, 'fixture') + } + + assert.doesNotThrow(() => verifyArtifacts(root, targets, (filePath) => ( + filePath.endsWith('.a') ? LIBRARY_SYMBOLS : PREBUILD_SYMBOLS + ).join('\n'))) + assert.throws( + () => verifyArtifacts(root, targets, () => '_bare_register_module_v0'), + new RegExp(LIBRARY_SYMBOLS[0]) + ) +}) + +test('artifact verification rejects missing or empty outputs', (context) => { + const root = fixtureRoot() + context.after(() => fs.rmSync(root, { force: true, recursive: true })) + + assert.throws( + () => verifyArtifacts(root, ['ios-arm64'], () => PREBUILD_SYMBOLS.join('\n')), + /missing library artifact/ + ) +}) + +test('overlay provenance binds artifacts to the exact patch and hashes', (context) => { + const root = fixtureRoot() + context.after(() => fs.rmSync(root, { force: true, recursive: true })) + const config = readOverlayConfig(path.resolve(__dirname, '..')) + const target = config.targets[0] + const artifacts = artifactPaths(root, target) + for (const filePath of Object.values(artifacts)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, 'fixture') + } + const oneTargetConfig = { ...config, targets: [target] } + writeArtifactManifest(root, oneTargetConfig) + const symbols = (filePath) => ( + filePath.endsWith('.a') ? LIBRARY_SYMBOLS : PREBUILD_SYMBOLS + ).join('\n') + + assert.doesNotThrow(() => verifyArtifacts( + root, + oneTargetConfig.targets, + symbols, + oneTargetConfig + )) + + fs.appendFileSync(artifacts.library, 'tampered') + assert.throws( + () => verifyArtifacts(root, oneTargetConfig.targets, symbols, oneTargetConfig), + /artifact hashes do not match/ + ) + assert.ok(fs.existsSync(path.join(root, ARTIFACT_MANIFEST))) +}) + +test('overlay provenance can be extended by a second platform without losing hashes', (context) => { + const root = fixtureRoot() + context.after(() => fs.rmSync(root, { force: true, recursive: true })) + const config = readOverlayConfig(path.resolve(__dirname, '..')) + const iosTarget = 'ios-arm64' + const androidTarget = 'android-arm64' + for (const target of [iosTarget, androidTarget]) { + const artifacts = artifactPaths(root, target) + for (const filePath of Object.values(artifacts)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, `${target}-fixture`) + } + } + writeArtifactManifest(root, config, [iosTarget]) + const iosManifest = JSON.parse(fs.readFileSync(path.join(root, ARTIFACT_MANIFEST), 'utf8')) + writeArtifactManifest(root, config, [androidTarget]) + const combinedManifest = JSON.parse(fs.readFileSync(path.join(root, ARTIFACT_MANIFEST), 'utf8')) + + assert.deepEqual(combinedManifest.artifacts[iosTarget], iosManifest.artifacts[iosTarget]) + assert.ok(combinedManifest.artifacts[androidTarget]) +}) + +test('overlay provenance rejects a stale patch identity', (context) => { + const root = fixtureRoot() + context.after(() => fs.rmSync(root, { force: true, recursive: true })) + const config = readOverlayConfig(path.resolve(__dirname, '..')) + const target = config.targets[0] + const oneTargetConfig = { ...config, targets: [target] } + const artifacts = artifactPaths(root, target) + for (const filePath of Object.values(artifacts)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, 'fixture') + } + writeArtifactManifest(root, oneTargetConfig) + + assert.throws( + () => verifyArtifacts( + root, + oneTargetConfig.targets, + () => PREBUILD_SYMBOLS.join('\n'), + { ...oneTargetConfig, patchSha256: '0'.repeat(64) } + ), + /provenance does not match patchSha256/ + ) +}) + +test('nm accepts only the archive empty-member diagnostic on status one', () => { + assert.equal(validatedNmOutput({ + status: 1, + stdout: '_rln_sync_wallet\n', + stderr: 'archive.a:member.o: no symbols\n' + }), '_rln_sync_wallet\n') + + assert.throws(() => validatedNmOutput({ + status: 1, + stdout: '_rln_sync_wallet\n', + stderr: 'nm: archive is malformed\n' + }), /archive is malformed/) +}) + +test('nm tolerates only the known Rust producer and Apple reader mismatch', () => { + assert.equal(validatedNmOutput({ + status: 1, + stdout: '_rln_wallet_snapshot\n', + stderr: '/usr/bin/nm: error: archive.a(member.o): Unknown attribute kind (105) ' + + '(Producer: \'LLVM22.1.2-rust-1.95.0-stable\' ' + + 'Reader: \'LLVM APPLE_1_2100.1.1.101_0\')\n' + }), '_rln_wallet_snapshot\n') + + assert.throws(() => validatedNmOutput({ + status: 1, + stdout: '_rln_wallet_snapshot\n', + stderr: '/usr/bin/nm: error: archive.a(member.o): Unknown file format\n' + }), /Unknown file format/) +}) + +test('Android NDK resolution requires the exact configured revision', (context) => { + const root = fixtureRoot() + context.after(() => fs.rmSync(root, { force: true, recursive: true })) + const config = readOverlayConfig(path.resolve(__dirname, '..')) + fs.writeFileSync( + path.join(root, 'source.properties'), + `Pkg.Desc = Android NDK\nPkg.Revision = ${config.androidNdkVersion}\n` + ) + + assert.equal(resolveAndroidNdk(config, { ANDROID_NDK_HOME: root }), root) + + fs.writeFileSync( + path.join(root, 'source.properties'), + 'Pkg.Desc = Android NDK\nPkg.Revision = 27.0.0\n' + ) + assert.throws( + () => resolveAndroidNdk(config, { ANDROID_NDK_HOME: root }), + /Android NDK 27\.1\.12297006 is required/ + ) +}) diff --git a/scripts/resolve-package-root.js b/scripts/resolve-package-root.js new file mode 100644 index 0000000..d5480f3 --- /dev/null +++ b/scripts/resolve-package-root.js @@ -0,0 +1,24 @@ +'use strict' + +const path = require('node:path') + +function resolvePackageRoot (packageName, fromDirectory) { + if (typeof packageName !== 'string' || packageName.length === 0) { + throw new TypeError('packageName must be a non-empty string') + } + if (typeof fromDirectory !== 'string' || fromDirectory.length === 0) { + throw new TypeError('fromDirectory must be a non-empty string') + } + + const manifest = require.resolve(`${packageName}/package.json`, { + paths: [fromDirectory] + }) + return path.dirname(manifest) +} + +if (require.main === module) { + const [, , packageName, fromDirectory = process.cwd()] = process.argv + process.stdout.write(resolvePackageRoot(packageName, fromDirectory)) +} + +module.exports = { resolvePackageRoot } diff --git a/scripts/resolve-package-root.test.js b/scripts/resolve-package-root.test.js new file mode 100644 index 0000000..48e018a --- /dev/null +++ b/scripts/resolve-package-root.test.js @@ -0,0 +1,44 @@ +'use strict' + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const { resolvePackageRoot } = require('./resolve-package-root') + +test('resolves a dependency hoisted above the installed package', (t) => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'utexo-package-root-')) + t.after(() => fs.rmSync(workspace, { force: true, recursive: true })) + + const dependencyRoot = path.join( + workspace, + 'node_modules', + 'example-dependency' + ) + const installedPackageRoot = path.join( + workspace, + 'node_modules', + '@utexo', + 'rgb-lightning-node-bare' + ) + fs.mkdirSync(dependencyRoot, { recursive: true }) + fs.mkdirSync(installedPackageRoot, { recursive: true }) + fs.writeFileSync( + path.join(dependencyRoot, 'package.json'), + JSON.stringify({ name: 'example-dependency', version: '1.0.0' }) + ) + + assert.equal( + fs.realpathSync( + resolvePackageRoot('example-dependency', installedPackageRoot) + ), + fs.realpathSync(dependencyRoot) + ) +}) + +test('rejects invalid resolver inputs', () => { + assert.throws(() => resolvePackageRoot('', process.cwd()), TypeError) + assert.throws(() => resolvePackageRoot('cmake-bare', ''), TypeError) +}) diff --git a/test.js b/test.js index f6090e9..407d73e 100644 --- a/test.js +++ b/test.js @@ -57,13 +57,53 @@ try { 'rotateAddress', 'assetLinkCreate', 'listTransactions', - 'listTransactionsByTxid', 'listTransfers', + 'syncWallet', + 'walletSnapshot', + 'prepareBtcSend', + 'commitPreparedBtcSend', + 'cancelBtcSendPlan', + 'prepareCreateUtxos', + 'commitPreparedCreateUtxos', + 'cancelCreateUtxosPlan', + 'listPendingVanillaTransactions', + 'listAddressReceipts', + 'prepareRgbSend', + 'commitPreparedRgbSend', + 'cancelRgbSendPlan', + 'listPendingRgbSendPlans', + 'listTransactionsByTxid', 'listTransfersByTxid', + 'importRgbTransferConsignment', + 'importRgbContract', 'verifyMessage' ]) { if (typeof node[method] !== 'function') fail(`SdkNode.${method} is missing`) } + + let invalidSyncRequest + try { + node.syncWallet({ mode: 'routine', typo: true }) + } catch (error) { + invalidSyncRequest = error + } + if (!String(invalidSyncRequest && invalidSyncRequest.message + ? invalidSyncRequest.message + : invalidSyncRequest).includes('unknown field')) { + fail(`syncWallet accepted an unknown request field: ${invalidSyncRequest}`) + } + + let invalidSnapshotLimit + try { + node.walletSnapshot({ max_assets: 0 }) + } catch (error) { + invalidSnapshotLimit = error + } + if (!String(invalidSnapshotLimit && invalidSnapshotLimit.message + ? invalidSnapshotLimit.message + : invalidSnapshotLimit).includes('max_assets')) { + fail(`walletSnapshot accepted max_assets=0: ${invalidSnapshotLimit}`) + } console.log('✓ SdkNode created') } catch (e) { fail(`SdkNode.create threw: ${e.message}`) @@ -76,16 +116,40 @@ try { fail(`shutdown threw: ${e.message}`) } +let closedNodeError +try { + node.nativeOperationStatus('closed-node-canary') +} catch (error) { + closedNodeError = error +} +const closedNodeMessage = String(closedNodeError && closedNodeError.message + ? closedNodeError.message + : closedNodeError) +if ( + !closedNodeMessage.includes('node handle is unavailable') && + !closedNodeMessage.includes('node is already closed') +) { + fail(`closed SdkNode call did not fail safely: ${closedNodeError}`) +} +console.log('✓ closed SdkNode calls fail safely') + // ─── Step 3: external-signer boundary ───────────────────────────────────── // A throwaway 32-byte seed (all-zero is rejected by some VLS validators, so // use a deterministic non-zero pattern instead). const SEED_HEX = '01'.repeat(32) let signer +let signerDataDir try { - signer = NativeExternalSigner.create(SEED_HEX, 'regtest') - console.log('✓ NativeExternalSigner created') + signerDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rln-canary-vls-')) + signer = NativeExternalSigner.createWithStorage( + SEED_HEX, + 'regtest', + signerDataDir, + true + ) + console.log('✓ persistent NativeExternalSigner created') } catch (e) { - fail(`NativeExternalSigner.create threw: ${e.message}`) + fail(`NativeExternalSigner.createWithStorage threw: ${e.message}`) } let bootstrap @@ -167,4 +231,18 @@ try { fail(`signer canary shutdown threw: ${e.message}`) } +try { + signer.destroy() + const reopenedSigner = NativeExternalSigner.createWithStorage( + SEED_HEX, + 'regtest', + signerDataDir, + true + ) + reopenedSigner.destroy() + console.log('✓ explicit cleanup releases the persistent signer database') +} catch (e) { + fail(`persistent signer reopen after cleanup threw: ${e.message}`) +} + console.log('\n✅ Canary 1 PASSED — bare ↔ C-FFI ↔ tokio ↔ LDK boot + external signer OK')