Skip to content

Fund, recover and exit the shielded pool from L1 - #1

Open
j0ntz wants to merge 12 commits into
paul/dashShieldedfrom
jon/dash-shielded-install-and-smoke
Open

j0ntz wants to merge 12 commits into
paul/dashShieldedfrom
jon/dash-shielded-install-and-smoke

Conversation

@j0ntz

@j0ntz j0ntz commented Sep 8, 2026 •

Copy link
Copy Markdown

Technical Design Document

dash-shielded-wallet.md

Description

Asana task

The base is paul/dashShielded, which carries the module. This branch makes its dependencies install, then adds the calls a wallet needs to fund a shielded balance from L1, recover a stranded lock and exit the pool, on the Node addon and on the React Native bindings. It also moves the dashpay/platform pin from v4.1.1 to v5.0.1, the first release with dashpay/platform#5352, and makes stop close the wallet store.

  • Dependency install: the lint toolchain pins could not resolve. They move to typescript-eslint 5 on eslint 8, standard-kit 0.15.1 and TypeScript 5.0.4. The smoke test now warms the prover and asserts readiness.
  • L1 funding: coreReceiveAddress, startCoreSync, coreBalance and shieldFromAssetLock build an asset lock from the wallet's own L1 balance and shield it. scripts/fund-testnet.ts drives the flow.
  • Recovery and exits: trackedAssetLocks and resumeShieldFromAssetLock finish a shield whose lock was already broadcast. unshield, platformReceiveAddress and shieldedWithdraw are the exit calls.
  • Seed handling: calls that move funds take the mnemonic as a parameter. The wallet holds public key material only, and the resident mnemonic field is gone.
  • Testnet DAPI: the port comes from the network. 0 of 30 nodes answered on 443 and 30 of 30 on 1443.
  • Shielded sync: startSync ran its immediate pass straight on the coordinator while the sync manager's loop ran its own first pass. Upstream reads the tree size before it takes the write lock. The overlapping passes each appended every commitment, and the wallet tree held twice Platform's count (9506 against 4753). No anchor matched, and later spends failed with no recorded anchor covers the selected notes. The immediate pass now goes through ShieldedSyncManager::sync_now, which refuses to overlap a pass in flight.
  • Withdrawal confirmation: rs-sdk-trusted-context-provider is built with its withdrawals-contract feature. Without it a withdrawal landed on chain and the call still returned an error, because the result could not be verified.
  • Platform pin: v5.0.1 needs Rust 1.98.1 and rust-dashcore d631aaca. From the 4.2 line on, a new process restores the wallet's Core state and its asset locks from the store, which v4.1.1 did not do. The 4.2 line also added a directory check that refused the store on Android; v5.0.1 trusts Android's system-owned /data and the app's own group, and the store opens in the app's files directory.
  • Stopping a wallet: on v5.0.1 the wallet manager and each wallet hold references to each other, and the store registers its path once per process. Dropping the manager left the store open, and opening the same wallet again in that process was refused with a SqlitePersister is already open. stop now shuts the manager down, removes each wallet, drops the manager and checks that nothing still holds the store. It returns an error when a sync worker did not shut down cleanly.
  • React Native: the UDL, the generated Swift and Kotlin, both bridges and src/react-native.ts cover the same surface as Node. The copied macOS addon is ad hoc signed, and Android builds and packages armeabi-v7a as well as arm64-v8a.

Physical Android phones at v5.0.1

edge-react-gui (develop at 982bc31ee) was built with this branch linked as a local dependency and a local test script that runs at app start. None of that is committed. The release arm64-v8a APK ran on a Google Pixel 10 and a Samsung Galaxy S26, both Android 16, rented from a device cloud. The script uses the public BIP-39 test vector on testnet, which holds nothing, because the APK went to a third party's phones. It warms the prover, opens the store, syncs, stops, opens the same wallet again and stops a second time.

Phone Prover warm-up Store open Sync stop Reopen Second stop
Pixel 10 2844 ms 249 ms SYNCED at 651816 74 ms 313 ms, same address 8 ms
Galaxy S26 1810 ms 211 ms SYNCED at 651816 22 ms 109 ms, same address 3 ms

The store opened under /data/user/0/co.edgesecure.app/files with no change to any directory. Before the stop change the same phones reported STOPPED and then refused the reopen (139 ms on the Pixel, 38 ms on the S26).

A spend run followed at the same code. The Node host sent 2,000,000,000 credits from the funded testnet wallet to the test vector's shielded address (37c9c516dfc3c2a621634f84e78603bd4a3167a55d8e427c41367ae45260adc5). A new APK, still carrying only the test vector, then ran on the Pixel 10:

Step Time Result
Sync 22159 ms SYNCED, 2,000,000,000 credits at 651835
Shielded transfer of 300,000,000 credits to the funded wallet 5418 ms c2b96646c78917716220e027e315d573689252f7d0bce7506018d130a9ebe88b
unshield of 300,000,000 credits to its own Platform address, once the change note was mined 5105 ms accepted
stop, reopen, second stop 61, 358, 8 ms STOPPED, same address, STOPPED

unshield returns the destination address rather than a transaction id. A host sync afterwards read 300,000,000 more credits on the funded wallet and 1,165,637,200 on the test vector: 1,585,860,000 change, less the 300,000,000 unshielded and a 120,222,800 fee.

A funding run followed. The Node host withdrew 4,000,000,000 credits from the funded wallet to the test vector's first Core address, and L1 transaction 1123be375c47b883d88c074bca37477e985c191a32056fd027a6f27d50aba04c paid it 3,999,810 duffs. Two more test-vector APKs then ran on the Pixel 10, each on a new store:

Step First run Second run
Sync SYNCED in 22706 ms at 651850 SYNCED in 27015 ms at 651854
shieldedWithdraw of 500,000,000 credits to its own Core address accepted, 5578 ms accepted, 5488 ms
Confirmed Core balance 3,999,810 duffs 2,499,357 duffs
shieldFromAssetLock 2,000,000 duffs, 5964 ms 1,000,000 duffs, 5633 ms
trackedAssetLocks its own lock, consumed its own lock, consumed, and the first run's lock as recovered_from_chain
resumeShieldFromAssetLock on its own lock refused: cannot be reused; Platform completion is unconfirmed refused, same text
Credits after the shield, stop not reached 2,384,397,200 at 651857, STOPPED in 96 ms

The refusal is upstream's AssetLockAlreadyConsumed, which v4.1.1 worded has already been consumed. The first script matched only the old wording and stopped at the resume; the second matched both. The first run's withdrawal reached L1 as asset-unlock transaction 21b1fc6965a236cc3ee8574133f02b7951eb26a56f7fb9ac3d580bf4613da52a, paying 499,810 duffs.

Release split APKs at v5.0.1, against Google Play's 100 MB limit per APK:

APK Size Room
app-arm64-v8a-release.apk 91,944,739 bytes 8.1 MB
app-armeabi-v7a-release.apk 87,795,675 bytes 12.2 MB

libdashshielded.so compresses to 8.8 MB in the arm64-v8a split and 7.9 MB in the armeabi-v7a one.

Earlier pins

On testnet from the Node addon at v4.2.0-beta.7, on a new store, each of these was accepted by Platform:

Call Time Transaction
shieldedWithdraw of 1,000,000,000 credits to L1 3278 ms 9a862caf4a7f0d008946b5f003e55eeda0cf628d5cb9a5514706f89adb522dad
unshield of 500,000,000 credits 3535 ms 4cc80a45c8e2945af5b5e8c6a7dd5b77efc9c485e66bfaf780da27930da094a3
Shielded transfer of 300,000,000 credits 3625 ms b440f3ded5238f38f08765ae916771aee9478f914e9ba0126f4e586ff88bcc48

With the sync fix alone on v4.1.1, four spends in a row were accepted, and the wallet tree size equaled Platform's notes count before each one. In a new process on v4.2.0-beta.7, the stranded lock c12094b3...52f8:0 was listed and resumed in 4.4 seconds. The same step on v4.1.1 listed no locks.

At v4.2.0-beta.7 the Edge app on an iOS 18.6 simulator and an Android 16 emulator sent a shielded transfer each (3514 ms and 4241 ms), after the directory check was satisfied by hand.

Not yet proven:

  • Any spend or shield on the Galaxy S26.
  • At v5.0.1, resuming a lock that was truly stranded, and resuming one rebuilt as recovered_from_chain after Platform consumed it.
  • Anything on an iOS device, and the armeabi-v7a and ios-arm64 libraries. Both are packaged, and nothing has loaded them.
  • After a reopen, a lock reads recovered_from_chain whether or not Platform has consumed it. Resuming a spent one returns an error and moves no value.
  • proposeTransfer quoted a fee of 114,140,000 credits, and each mined transfer cost 162,851,200.

Test evidence

5929e66
Release the wallet store when a wallet stops
2026-10-28

1. pixel10 probe pass

2. s26 probe pass

4. pixel10 spend pass

5. pixel10 fund pass

@j0ntz
j0ntz marked this pull request as ready for review September 8, 2026 18:57
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch 3 times, most recently from 05c0638 to b4eaaad Compare September 10, 2026 02:45
Comment thread rust/src/wallet.rs Resolved
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch 2 times, most recently from c8b71e8 to 07ffdd4 Compare September 10, 2026 06:04
@j0ntz j0ntz changed the title Fix dependency install and cover the prover warm-up Fund, recover and exit the shielded pool from L1 Oct 6, 2026
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch 3 times, most recently from 419dd62 to 32b05ae Compare October 7, 2026 07:48
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch 3 times, most recently from 8ccc97c to 2f1f5f6 Compare October 28, 2026 23:20
j0ntz added 12 commits October 28, 2026 16:42
The @typescript-eslint plugin/parser/eslint trio was inconsistent: plugin
2.34.0 wants parser ^2 and eslint ^5||^6, while the manifest pinned parser
^3.0.1 on eslint ^7. Both install paths failed with ERESOLVE, so the
documented build-and-smoke flow could not run at all.

Align the set to the one react-native-zano already uses (typescript-eslint
5.x on eslint 8, standard-kit 0.15.1, TypeScript 5.0.4), rename the renamed
simple-import-sort rule, add a lint-only tsconfig so the scripts directory
parses, and clear the findings the newer rules surface.
The Halo 2 proving key build is the heaviest operation in the module and the
one a spend cannot skip, so the smoke test now warms it and asserts the
readiness flag flips, reporting the elapsed time. Also record the protoc
build prerequisite, which the cargo step fails without.
The pool could only ever be empty: create_transfer spends notes, and nothing
in the module put notes there. Value reaches Orchard through Dash L1 only, so
this adds the three pieces that path needs.

coreReceiveAddress exposes the wallet's transparent BIP-44 address, which a
faucet or any sender can pay. startCoreSync runs the Core SPV client the
manager already holds but never started, since the Platform SDK connection
cannot see L1 and the transparent balance stays at zero without it; a start
height keeps a fresh wallet from scanning filters it can never match.
shieldFromAssetLock then builds and broadcasts the L1 lock, waits for its
InstantSend or ChainLock proof, and proves a Type 18 transition paying the
wallet's own Orchard address.

Signing the lock needs private keys the PlatformWallet deliberately does not
hold, so a seed-backed signer re-derives them for the call, matching what
create_transfer already does for the Orchard spend authority.

scripts/fund-testnet.ts drives the whole path so the shielded send has
something to spend.
Quorum-discovered nodes were dialled at https://<ip>:443, but testnet Platform
gRPC listens on 1443. Measured against the live quorum list: 0 of the 30
ENABLED, version-checked masternodes accept a connection on 443 and all 30
accept on 1443, so every DAPI call on testnet was aimed at a dead port. It
surfaced as a tonic tcp connect timeout out of shieldFromAssetLock, after the
L1 asset lock had already broadcast.

The port now comes from the network, with a caller-supplied port still winning
for devnets, and the scheme follows the port rather than assuming plain HTTP for
anything that is not 443.

Stopping a wallet also dropped the manager while the shielded-sync task still
held a tokio timer, panicking that task with 'A Tokio 1.x context was found, but
it is being shutdown'. stop now signals the loop and awaits quiesce first.
shield_from_asset_lock broadcasts the L1 lock first and proves the Type 18
transition second, so a failure between the two strands the locked value:
the duffs are spent, nothing credits the pool, and a fresh lock cannot
recover them. resume_shield_from_asset_lock finishes that stranded outpoint.
tracked_asset_locks lists what there is to resume, without which the resume
call is unusable: the failing shield returns an error, not the lock it had
just broadcast.

unshield and shielded_withdraw are the exits, to a Platform address and to
an L1 address respectively.

Resume reaches only locks this process built. Opening a wallet now loads from
the persister first, but that restores nothing yet for an upstream reason:
SqlitePersister::load declares LOAD_UNIMPLEMENTED = ["ClientStartState::wallets"]
and rebuilds only platform_addresses, so the asset_locks rows it writes are
never read back. Calling load first costs one query and is what makes resume
survive a restart once upstream lands rehydration.
The UniFFI side had drifted behind the N-API side: the Core sync, funding,
and pool-exit entry points existed only for Node. Declaring them in the UDL
and wrapping them in uniffi_api regenerates dash.swift and dash.kt, and the
iOS and Android bridge modules pass them through, so React Native reaches
the same seven calls Node does.
Both hosts get the same seven methods, so a caller written against the Node
Synchronizer moves to React Native unchanged.
A copied dylib keeps its original linker-signed signature, which macOS
rejects: require() of the prebuild died with SIGKILL and no output at all.
Re-signing the destination ad hoc makes it loadable.
edge-react-gui ships armeabi-v7a alongside arm64-v8a (its abiFilters and
reactNativeArchitectures both name the pair), so an arm64-only library left
every 32-bit device without one. The Orchard and Halo 2 stack compiles for
32-bit ARM, so the slice does not have to be dropped.
start_sync started the manager's sync loop, whose first act is a
pass, and then ran a second pass straight on the coordinator. Only
ShieldedSyncManager::sync_now holds the in-flight slot, so the two
passes overlapped. Upstream reads the tree size before it takes the
store's write lock, so both passes saw the old size and each appended
every new commitment.

On a new store that left 9,506 local leaves against Platform's 4,753.
The first spend still landed through the depth-1 checkpoint. After it,
no new commitment was appended, the change note's witness never
matched the tree root, and every later spend failed with 'no recorded
anchor covers the selected notes'.

Run the immediate pass through sync_now(true), waiting out a pass
already in flight. Verified on testnet: the local tree size and
depth-0 root equal Platform's notes count and most recent anchor
before each of five consecutive spends.
At v4.1.1 a new process restored nothing from the wallet store, so an
asset lock broadcast by an earlier process was not tracked and could
not be resumed. The 4.2 line restores the wallet, its address pool and
its tracked locks on open. Resuming the stranded testnet lock c12094b3
from a new process succeeded on v4.2.0-beta.7 and returned "is not
tracked by this wallet" on v4.1.1 against the same store history.

A lock rebuilt that way carries upstream's RecoveredFromChain status,
which the listing now names recovered_from_chain.

v5.0.1 is the first release with dashpay/platform#5352, which lets the
shielded store open under Android's app data directories. It needs
rust-dashcore d631aaca and Rust 1.98.1.

ShieldedSyncManager::quiesce now returns false when the in-flight pass
did not drain within its budget, and PlatformWalletManager::shutdown
returns a report of any worker still alive. stop now returns an error
when either says the close was not clean, instead of STOPPED.
The manager's wallet map and the wallets in it hold each other, so
dropping the manager after shutdown left the SqlitePersister alive.
v5's per-process open registry then refused every reopen of that alias
with "a SqlitePersister is already open". stop() now removes each
wallet from the manager before dropping it and fails if the store is
still referenced afterwards. The Node smoke test reopens the wallet
after stopping it.
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch from 2f1f5f6 to 5929e66 Compare October 28, 2026 23:43
@j0ntz

j0ntz commented Oct 28, 2026

Copy link
Copy Markdown
Author

bugbot run

@cursor

cursor Bot commented Oct 28, 2026

Copy link
Copy Markdown

Skipping Bugbot: Bugbot is disabled for this repository. Visit the Bugbot dashboard to update your settings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant