Skip to content

Fix dependency install and cover the prover warm-up - #1

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

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

Conversation

@j0ntz

@j0ntz j0ntz commented Sep 8, 2026 •

Copy link
Copy Markdown

Description

Eight commits, in the order the module needed them.

Dependency install. The documented host flow in the README could not run:
both dependency-install paths failed with ERESOLVE.
@typescript-eslint/eslint-plugin@2.34.0 requires
@typescript-eslint/parser@^2 on eslint@^5 || ^6, while the manifest pinned
parser ^3.0.1 on eslint@^7. No combination of the three resolves, so nothing
downstream of install had ever been exercised. Moving the parser alone fixes it
in neither direction. The lint toolchain now matches the set
react-native-zano already ships: typescript-eslint 5 on eslint 8,
standard-kit 0.15.1, TypeScript 5.0.4. That brings a renamed rule
(simple-import-sort/sort to imports) and a lint-only tsconfig, since the
build tsconfig excludes the scripts directory.

Prover coverage. The smoke test warms the Halo 2 proving key and asserts the
readiness flag flips. That is the heaviest operation in the module and the one a
spend cannot skip. It takes about 1.4 seconds on an Apple Silicon host.

Funding. 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, which needs three pieces:

  • coreReceiveAddress exposes the wallet's transparent BIP-44 address, so a
    faucet or any sender has something to pay.
  • startCoreSync runs the Core SPV client the manager already holds but never
    started. 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 builds and broadcasts the L1 asset lock, waits for its
    InstantSend or ChainLock proof, then proves a Type 18 ShieldFromAssetLock
    transition paying the wallet's own Orchard address.

Signing the lock needs private keys the PlatformWallet deliberately does not
hold, since it is created external-signable. A seed-backed signer re-derives
them for the duration of one call, the same posture create_transfer already
uses for the Orchard spend authority. scripts/fund-testnet.ts drives the whole
path: it prints the L1 address, starts the Core sync, waits for the payment,
shields, and reports the resulting shielded balance.

DAPI port. 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. stop also dropped the manager while the shielded-sync task still
held a tokio timer, panicking that task; it now signals the loop and awaits
quiesce first.

protoc is an undocumented build prerequisite. dash-sdk pulls dapi-grpc,
which compiles protobuf definitions during the cargo build, and the README now
says so.

Pool exits and resume. Value could go into Orchard and move within it, but
never leave, and a shield that failed halfway could not be retried.
shieldFromAssetLock 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 into the lock, nothing credits the pool, and building a fresh
lock cannot recover them. Three entry points close that:

  • resumeShieldFromAssetLock finishes a shield against an outpoint whose lock
    is already on chain. This is what makes a retrying auto-shield host safe to
    write, since a crash mid-shield otherwise loses the funds.
  • trackedAssetLocks lists what there is to resume. Without it the resume call
    is unusable: the failing shield returns an error, not the lock it had just
    broadcast, so a host has no way to learn the outpoint.
  • unshield spends notes to a transparent Platform address, and
    platformReceiveAddress produces one to send to. It derives under DIP-17 from
    public key material, so no seed is touched.
  • shieldedWithdraw spends notes out to a Core L1 address, priced by a
    duffs-per-byte fee rate for the L1 transaction the network builds on the far
    side.

All four take the mnemonic as a call parameter rather than reusing the one
initialize was given. ClientSlot carried a mnemonic field that nothing on
the base branch ever read: its only spend path, create_transfer, already takes
the seed per call. Reading that field here would have made an alias string
sufficient to move funds, and an alias is not a secret, so the field is gone and
the four exits follow create_transfer. SeedSigner matters most: it signs any
path the asset-lock orchestrator asks for, so it reaches transparent balance
too.

For the same reason, quorum-discovered masternodes are pinned to https
whatever port they answer on. Deriving the scheme from the caller's port, as an
earlier commit on this branch did, would silently put real mainnet or testnet
gRPC in cleartext on a non-standard port, and a shielded wallet's request stream
is the metadata the pool exists to hide. The port-implies-scheme heuristic now
covers only the caller's own fallback host, as it did before.

Opening a wallet now loads from the persister before falling back to seed
creation. That restores nothing yet, and the reason is upstream:
SqlitePersister::load declares LOAD_UNIMPLEMENTED = ["ClientStartState::wallets"], rebuilds only platform_addresses, and logs
wallets_rehydrated = 0. It writes durable asset_locks rows and never reads
them back, so tracked locks cannot survive a restart and a resume reaches only
locks its own process built. Calling load first costs one query and is what
makes resume survive a restart the moment upstream lands rehydration.

React Native parity. The UniFFI side had drifted behind the N-API side: the
Core sync, funding, and pool-exit calls existed only for Node. Declaring them in
rust/src/dash.udl regenerates ios/dash.swift and the Kotlin bindings, and
the iOS and Android bridge modules pass them through, so React Native reaches
the same calls Node does. ios/dash.swift was stale on the branch and is
regenerated here. The module generates its own Swift and Kotlin from the UDL,
so it never depended on Dash shipping either SDK.

Both native artifacts build for the first time in this branch.
ios/libdashshielded.xcframework carries ios-arm64 and ios-arm64-simulator
slices at 32 MB each. Android now builds both ABIs edge-react-gui actually
ships, since its abiFilters and reactNativeArchitectures both name
armeabi-v7a alongside arm64-v8a and an arm64-only library left every 32-bit
device without one: 13 MB aarch64 and 11 MB ARM EABI5, both stripped. The
Orchard and Halo 2 stack compiles for 32-bit ARM, so that slice does not have to
be dropped. Every rust target has to be installed on the 1.97.1 toolchain
rust/rust-toolchain.toml pins rather than the default stable, or both builds
fail with E0463: can't find crate for std.

macOS prebuild signing. require() of the built addon died with SIGKILL,
no output and no error. A copied dylib keeps its original linker-signed
signature, which macOS rejects on load. The build script re-signs the
destination ad hoc after the copy.

Asana: https://app.asana.com/0/1215088146871429/1216611553286539

Test plan

Static, from a clean checkout on macOS arm64:

  • socket npm install resolves 638 packages where it previously exited ERESOLVE
  • socket npm run lint reports nothing
  • socket npm run prepare (rollup + tsc) passes
  • socket npm run build-native-host produces libdashshielded.dylib
  • socket npm run smoke-node prints smoke-node ok with a derived testnet
    address, the viewing key, and proverMs around 1430
  • ~/.cursor/skills/verify-repo.sh . --base origin/paul/dashShielded passes

End to end on live Dash testnet, with the wallet funded by 1 tDASH:

DASH_MNEMONIC="<phrase>" SHIELD_DUFFS=10000000 socket npm run fund-testnet
fund this L1 address: yffmKKHNbN9Sm7anAJpRg8mW7EJiD2n3DR
L1 height 1550616 confirmed 89999703 unconfirmed 0
shielding 10000000 duffs
shielded to tdash1zryn7dw2rhsjxv628awvgxfmw4jh0ytpdgz9rrl2ff38mclgdtm3p936uh2h8zg4gw3c08gqsancf in 4151 ms
shielded balance 9787148800 credits

The Core SPV client reaches testnet tip and sees the transparent balance, the
asset lock broadcasts and resolves its proof, the Type 18 transition proves and
is accepted, and the resulting note decrypts under the wallet's own viewing key.

A shielded transfer then spends that note:

status SYNCED available 9787148800 total 9787148800
proposal {"proposalId":"p-1","feeCredits":"162851200"}
TRANSFER_RESULT {"txid":"fd960262bb68bed1ba1a49bdba394de1a0209ad2e0fc93210a894c22140e291e",
                 "amountCredits":"1000000000","feeCredits":"162851200"} in 4225 ms

Both proofs run in roughly 4 seconds, warm prover excluded.

The per-call seed is what authorizes a spend now, not the alias. Calling
unshield on an open wallet with a wrong phrase is refused at invalid mnemonic: BIP-0039 mnemonic only supports 12/15/18/21/24 words before any note
is touched.

build-native-ios.ts pins IPHONEOS_DEPLOYMENT_TARGET to 15.6 and both slices
carry minos 15.6, matching what Edge targets. Dash's own packages/swift-sdk
defaults to 17.0 and declares iOS 18, but this module never uses that wrapper:
it generates its own Swift from the UDL, so that floor does not apply here.

No native artifact can run on hardware here, so their FFI surfaces are checked
statically instead: every symbol the generated bindings load is diffed against
what the library exports. The Kotlin bindings reference 103 symbols and both
Android ABIs export exactly those 103, zero missing. The Swift bindings
reference 49 and both iOS slices export 103, zero missing. That is what catches
a libraryOverride name mismatch or an ABI-specific codegen gap, which would
otherwise surface only as an UnsatisfiedLinkError on a device.

The macOS addon signing is verified by deleting prebuilds/darwin-arm64/,
rebuilding, and running the smoke test: codesign -dv reports an adhoc flag
and smoke-node exits 0 with proverMs 1464, where the unsigned copy was
killed at require().

Resume and the lock listing, driven in one process on testnet:

LOCKS_BEFORE []
L1 height 1550770 confirmed 79999406
SHIELD ok tdash1zryn7dw2rhsjxv628awvgxfmw4jh0ytpdgz9rrl2ff38mclgdtm3p936uh2h8zg4gw3c08gqsancf in 4466 ms
LOCKS_AFTER [{"txid":"ce79317247b018827f2cc0e8be41781b8641ac6d1258ae2d7f9663c47d18a9e8","vout":0,"status":"consumed"}]
RESUME_ERR ce79317247b018827f2cc0e8be41781b8641ac6d1258ae2d7f9663c47d18a9e8:0 Asset lock ce79317247b018827f2cc0e8be41781b8641ac6d1258ae2d7f9663c47d18a9e8:0 has already been consumed

Two things are proven there. LOCKS_BEFORE is empty on a store whose
asset_locks table holds a row, which is the upstream rehydration gap seen from
this side. And the resume of a lock this process built reports
AssetLockAlreadyConsumed, not AssetLockNotTracked, so the lookup finds the
lock and the resume path is correct. Every earlier attempt reported
AssetLockNotTracked only because it ran after a restart.

Not yet proven

  • shieldedWithdraw returns Shielded spend cannot use a Platform-recorded anchor: no recorded anchor covers the selected notes. Four drives bound it.
    A transfer and a withdraw in one process on one synced store: the transfer
    landed txid 4f7f10de7da3e818d4507e1ee63d5f84749803856e45997fa5dd17e39ddd329b
    in 4132 ms and the withdraw after it failed, which reads as withdraw-specific
    until the same pair with the order flipped fails on both. They share
    extract_spends_and_anchor, so it is neither operation. Amounts of 1000000,
    100000000, 1000000000 and 5000000000 credits fail identically, so it is not
    the size of the note selection. Five attempts over fifteen minutes at an
    unchanged balance all fail, so it is not the transient the message describes.
    The condition is that no checkpoint depth in this store has a root Platform
    recorded, and the probe bound MAX_ANCHOR_PROBE_DEPTH = 100 matches the
    retention configure_shielded opens the tree with, so the module is not
    misconfiguring it.
  • unshield reaches the same anchor wall. Driven against a real Platform
    address from platformReceiveAddress
    (tdash1krxraw6u2wczgp956t7mpn2pg05l2uzxk57hdvlx), it builds the spend and
    fails where the other two do, so all three shielded spends share one upstream
    condition.
  • Resuming a lock across a process restart cannot work until upstream populates
    ClientStartState::wallets. The call is correct; the state it needs is not
    restored.

The base is paul/dashShielded rather than master, since that branch carries
the module.

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.
@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 from 7197fcf to 6e2d986 Compare September 8, 2026 18:57
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.
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch 2 times, most recently from 05c0638 to b4eaaad Compare September 10, 2026 02:45
Comment thread rust/src/wallet.rs Outdated
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.
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch from b4eaaad to c8b71e8 Compare September 10, 2026 03:09
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.
@j0ntz
j0ntz force-pushed the jon/dash-shielded-install-and-smoke branch from c8b71e8 to 07ffdd4 Compare September 10, 2026 06:04
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