19 Commits
Author SHA1 Message Date
grenadeandClaude Fable 5.1 038f09774f chore(release): 0.4.0
ci / gate (push) Successful in 12m16s
release / package (44) (push) Canceled after 0s
release / publish (44) (push) Canceled after 0s
release / linux (push) Canceled after 2m56s
The NEAR Intents account (#71): one per installation, created by the
sponsor blackbeard-observer.near and controlled by one wallet's
ML-DSA-65 key derived from its phrase (NEP-649). The wallet still holds
no classical key. From the Accounts page: QTC in over the bridge from
any account, swaps inside Intents on a Ref pool or 1Click with the
sponsor's 30 bps fee, cash-out to USDC/USDT/ETH/BTC on Ethereum, Base,
Arbitrum, Solana, Bitcoin or NEAR, and QTC back to Quantus. The sponsor
relays every NEAR leg and pays its gas (#73, #74, #75, #76, #77, #81,
#82).

Also: the version in the sidebar links to its release (#70); the
wormhole scan is one paced pass for every open wallet, under the node's
rate limit; QTC maps to nep141:qtc.omft.near in the asset registry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-10 00:38:51 +03:00
grenadeandClaude Opus 5.5 4f3b446cc3 feat(near): cash out from the intents account to an address on another chain
ci / gate (push) Successful in 13m0s
The intents panel can now pay a token it holds out through 1Click (#77):
USDC on Ethereum, Base, Arbitrum, Solana or NEAR; USDT or ETH on
Ethereum; BTC.

- cashout.toml ships the destinations with their 1Click asset ids,
  checked against GET /v0/tokens today, and the address form each
  takes.
- check_address refuses a mistyped payout address before anything is
  priced or signed, because nothing refunds a payout to the wrong
  address:
  - EVM: 0x and 40 hex digits, with the EIP-55 checksum enforced when
    the address is mixed case;
  - Bitcoin: bech32 or base58;
  - Solana: base58 of 32 bytes;
  - NEAR: an account id.
- near_cashout takes a firm 1Click quote with recipientType
  DESTINATION_CHAIN, the fee as appFees, and refunds to the account. It
  checks the echoed recipient, its type, the refund, the assets and the
  amount against what was asked, funds it with one mt_transfer signed by
  the owning wallet's key, and has the sponsor relay it.
- The 1Click request builder now serves swaps and cash-outs: None pays
  the account itself, Some(address) pays out on the destination chain.

Checked live from the dev account:
- 10 wNEAR quoted at 46.38 USDC on Ethereum, Ethereum's withdrawal
  included, with a floor of 45.91;
- the same EIP-55 example address with its last character's case
  flipped was refused on its checksum.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 21:30:54 +03:00
grenadeandClaude Opus 5.5 814355c9f3 feat(near): swaps inside the intents account, on a Ref pool or 1Click
ci / gate (push) Canceled after 5m23s
The intents panel can now swap any token the account holds into QTC,
wNEAR or USDC (#76). Rust prices it both ways and takes the larger
output.

- Pool: best_pool lists Ref's DCL pools for the pair and skips any
  that are not running or whitelist other accounts (they refuse with
  E407). It quotes each on chain.
  - pool_swap_calls is one delegate action: first the fee's
    mt_transfer, ceil(amount × bps) in the input token to the sponsor's
    recipient; then the ft_withdraw to the pool, with Ref's Swap
    message, a floor (default 1% slippage), and the account as
    client_echo.
  - The fee is computed exactly as the sponsor's rule (observer#51)
    checks it, so the relay accepts it.
- 1Click: a quote inside Intents (recipient and refund are the account)
  carrying the fee as appFees. When it wins, a firm quote is taken, its
  echoed recipient, refund, assets and amount checked against what was
  asked, and it is funded by one relayed mt_transfer.
- near_swap prices again at confirm time, checks the balance and the
  sponsor's terms, signs with the owning wallet's key, and relays it.

Measured while building this: without a partner key, 1Click adds its
own 20 bps and passes on half of an app fee (30 asked, 15 recorded).
The Distribution Channel key is what makes the 1Click leg pay the full
fee.

Checked live with quotes from the dev account:
- 1 QTC → 24.75 wNEAR on the QTC/wNEAR pool, with a 0.003 QTC fee; no
  1Click quote;
- 10 wNEAR → 46.83 USDC on a pool, against 46.78 on 1Click;
- 10 wNEAR → 0.393 QTC.
Executing one needs a funded account, which the release test (#78's
route on a real wallet) will do.

Refs #76, #82

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 21:25:22 +03:00
grenadeandClaude Opus 5.5 57ac504b93 fix(build): commit the lock file that wallet-app's bs58 needs
ci / gate (push) Successful in 11m57s
52ac684 added bs58 to wallet-app and staged crates/ and ui/ but not the
root Cargo.lock, and the two commits after it did the same. CI builds
with --locked, so the gate has failed since then on "cannot update the
lock file". This is the missing line; cargo test --locked passes with
it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:55:28 +03:00
grenadeandClaude Opus 5.5 4b407de277 feat(near): one intents account per installation, whichever wallet's QTC moves
ci / gate (push) Canceled after 25s
The operator's call: a person has no use for several intents accounts,
so the wallet makes one and routes every swap through it. The sponsor
cannot tell one installation from another and does not need to; the
wallet simply never offers a second.

- Settings record intents_account: the owning wallet, the account id and
  its public key, none of it secret. settings_set keeps it Rust's to
  change, as it does the account numbers.
- near_intents replaces near_account:
  - recorded: it is read, with its owning wallet named and marked open
    or locked. Balances read either way; signing waits for the owner.
  - not recorded: an open phrase wallet's existing sponsored account
    with that wallet's key is adopted and recorded. That covers a
    restore, or a second machine.
  - otherwise the first phrase wallet's account is described, with the
    other phrase wallets offered as owner, and creation if the
    sponsor's terms are acceptable.
- near_create_account refuses when an account is recorded or any open
  wallet's account already exists.
- Deposits take QTC from any account; withdrawals pay any open wallet's
  account. Both use the recorded account, and a withdrawal is signed by
  the owner's key.
- The Accounts page shows one NEAR Intents panel, not one per wallet.

Checked in a dev run: with nothing recorded, the dev-phrase wallet's
existing bffbod6n6ey7jdk4j.blackbeard-observer.near was found, recorded
in settings.json and shown as the one active account, with nothing
created.

Refs #71, #74, #81

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:54:37 +03:00
grenadeandClaude Opus 5.5 5d5baf1c21 fix(wormhole): one paced zk-tree scan for every open wallet, under the node's rate limit
ci / gate (push) Failing after 1m33s
With three phrase wallets open, the Accounts page showed "RPC rate limit
exceeded (-32999)" on every wormhole panel and on the pending reversible
transfers.

The Quantus node limits each connection to 300 calls a minute
(--rpc-rate-limit 300 in lair/quantus). The wallet keeps one WebSocket
per chain, so everything it asks shares those five calls a second. Each
wallet's first wormhole scan reads mainnet's zk-tree 500 leaves a call,
about 950 calls, and the three scans ran at once at full speed.

- The tree is public and the same for every wallet, so one scan now
  matches the wormhole accounts of every open phrase wallet in a single
  pass. It starts from the lowest cursor among them and records each
  wallet's leaves and frontier.
- Scans run one at a time behind a lock; a wallet whose turn comes after
  another's scan finds itself caught up.
- Bulk pages are paced at three a second, leaving room for balance and
  transfer reads on the same connection.
- under_rate_limit retries a call refused with -32999, backing off 1 to
  16 seconds, rather than putting the node's refusal on screen. The scan
  pages, the nullifier bucket reads, the leaf count and the pending
  reversible transfers all go through it.

Checked from an empty cache: the first scan of 481,540 leaves took 396 s,
with no error shown and no backoff needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:38:20 +03:00
grenadeandClaude Opus 5.5 52ac684661 feat(near): QTC into the intents account and back out to Quantus
ci / gate (push) Failing after 1m35s
The intents panel now moves QTC across the PoA bridge (#75).

In: the bridge's permanent Quantus deposit address for the account,
checked as a Quantus address before it is shown. "Send QTC here" opens
Send with it filled in (Send takes ?to= now), so a deposit is an
ordinary transfer reviewed and signed like any other.

Out: a withdrawal to one of the wallet's own Quantus accounts. It is the
ft_withdraw #72 proved, qtc.omft.near to itself with the WITHDRAW_TO:
memo:
- qtc_withdraw_call in wallet-core builds it and refuses anything under
  the bridge's 0.01 QTC minimum.
- The command checks the Intents balance and the sponsor's terms, signs
  a delegate action with the phrase's NEAR key (nonce one past the key's,
  valid for about 120 blocks), and hands it to the sponsor, which relays
  it and pays the gas.
- The bridge keeps 0.002 QTC, and the panel says so.

NearRpc::final_block now returns the height with the hash, for the
delegate's expiry. QtcBridge is the new port and PoaBridge its adapter.

Checked in a dev run with the dev-phrase account:
- the deposit address appeared (qzkgR6o1…);
- Send opened with it filled in;
- a 0.05 QTC withdrawal was refused because the account holds no QTC.
A live test (ignored) confirms the bridge gives #72's account the
address it used on mainnet. A full round trip with real QTC is still to
do before #75 closes.

Refs #75

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:23:03 +03:00
grenadeandClaude Opus 5.5 384c79446b feat(near): the sponsor creates the intents account from the wallet
ci / gate (push) Failing after 1m27s
The Accounts page now offers "Create account" when the wallet's NEAR
Intents account does not exist yet. The sponsor (#81) creates it as
b<…>.blackbeard-observer.near with the wallet's ML-DSA-65 key, and pays
its NEAR fees from then on, so the panel no longer shows gas.

- NearSponsor is the port. HttpSponsor reads the terms, asks for an
  account and relays a signed delegate, keeping the sponsor's own reason
  when it refuses.
- The wallet acts only on terms it accepts: the sponsor is running, it
  names the account the wallet expects, and its fee is at most
  MAX_FEE_BPS (50). Otherwise the panel says why and offers nothing.
- The account the sponsor reports must be the one the wallet derived
  from its key, or the wallet refuses to use it.
- The sponsor answers once its transaction is in a final block, but the
  account is made by a receipt a block or two later. The command waits
  for it to be readable before reading the summary back.

Verified in a dev run with the dev-phrase fixture: Create account made
bffbod6n6ey7jdk4j.blackbeard-observer.near (0.0025 NEAR deposited, 182
bytes, the sponsor logged "created"). It read back as active once
refreshed, which is the race the wait now covers. The live tests
(ignored by default) read the terms and send a malformed key, which the
sponsor refuses without creating anything.

Closes #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 20:18:59 +03:00
grenadeandClaude Opus 5.5 f5c214e4bf feat(near): the handle digest of a public key, for the sponsor
ci / gate (push) Failing after 1m25s
The sponsor reads a key from a delegate action, where it has the public
bytes and no keypair, and must name the account from it exactly as
NearKey does. handle_digest is now a free function on the public key,
and NearKey's method calls it.

Refs #81

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:34:55 +03:00
grenadeandClaude Opus 5.5 913155d48e refactor(near): the sponsored account's name lives in wallet-near
ci / gate (push) Failing after 1m12s
The sponsor (#81) must name a wallet's account exactly as the wallet
does, or it creates one the wallet never looks at. sponsored_account and
its base32 move from wallet-core into wallet-near, which both depend on;
wallet-core re-exports it, so nothing that calls it changes.

Refs #81

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:33:18 +03:00
grenadeandClaude Opus 5.5 7ddab3ebda feat(near): wallet-near, shared with the sponsor, with NEP-366 delegate actions
ci / gate (push) Failing after 1m22s
The sponsor (#81) relays each wallet's NEAR legs as meta-transactions, so
it encodes, decodes and verifies the same bytes the wallet signs. Rather
than a second implementation in the observer, the NEP-649 key and the
transaction encoder move out of wallet-data into wallet-near: no I/O, no
dependency on wallet-core, a raw 64-byte seed in and its own error type.

New in it is delegate.rs:
- DelegateAction signs over SHA-256 of NEP-461's prefix (2^30 + 366)
  and its borsh, as nearcore 2.13's classic Delegate action (tag 8)
  wants.
- SignedDelegate::decode is the sponsor's side. It accepts only the
  shapes the wallet sends (function calls and transfers, from an
  ML-DSA-65 key) and refuses anything else, trailing bytes included.
- verify checks the signature under the empty context.
- A relayer wraps the result as Action::Delegate.

An ML-DSA-65 delegate action, relayed, was proven on mainnet once,
outside the repo: 2SVwJsa1seKQHvAJ2JCzaBvAq8cLfJSPJtppxkr9ahK4.

Refs #81, #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:31:41 +03:00
grenadeandClaude Opus 5.5 f4cc7a1605 feat(near): the intents account on the Accounts page
ci / gate (push) Failing after 1m25s
Every open wallet with a phrase now shows its NEAR Intents account. Its
NEP-649 key is derived from the seed on each look and dropped; only the
public side leaves Rust. The account's name is a function of that key,
b<base32 of the handle's first 10 bytes>.blackbeard-observer.near, so the
sponsor (#81) creates it there, a restored phrase finds it again with
nothing stored, and the leading b sorts it onto shard 8, which our nodes
serve. The panel says whether the account exists and whether this
wallet's key is on it, and shows its NEAR for gas and every token it
holds inside Intents (one mt_batch_balance_of over the registry's
Intents ids).

The logic is wallet_core::near::account_summary, written against the
NearRpc port and tested with a fake. The command is a shim, and the app
now holds the shard-routed client from #73 and near.toml, which a file
in the data directory can replace.

Corrected on the way: the asset registry said no post-quantum asset is
on Intents. QTC has been, as nep141:qtc.omft.near, since early October.
Quantus's native QTC now maps to it, the token-list fixture gains 1Click's
QTC entry, and the "unmapped asset" tests use Planck's token instead. The
readme no longer says the swap module waits for Quantus's MPC fork.

Verified in a dev run with the dev-phrase fixture: the panel showed
bffbod6n6ey7jdk4j.blackbeard-observer.near as not created yet.

Refs #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:23:13 +03:00
grenadeandClaude Opus 5.5 cde186b3fd feat(near): the intents account's key (NEP-649) and its transactions
ci / gate (push) Successful in 13m28s
NearKey is an ML-DSA-65 key derived from the wallet's phrase as NEP-649
specifies, so the same phrase gives the same NEAR key in any wallet that
follows it: SLIP-10 over HMAC-SHA512 keyed "ML-DSA-65 seed", hardened
levels only, the node secret as xi for FIPS 204 KeyGen_internal, default
path m/44'/397'/0'. The dilithium crate's Keypair::generate is exactly
KeyGen_internal; all 22 of NEP-649's published vectors derive to their
node secrets and public keys, one of them from the phrase through the
wallet's own seed. It signs under the empty context, which is what NEAR
verifies; a signature checked under Quantus's context fails, and the
test says so. Its handle (SHA3-256 under NEP-645's tag) matches the one
mainnet lists for #72's key.

near::tx encodes a TransactionV0 as nearcore 2.13 does, hashes it with
SHA-256 and signs the hash. Golden bytes pin the encoding. Signing with a
key the transaction does not name is refused.

Proven once on mainnet outside the test suite: a transaction signed and
encoded by this code, from #72's account, was accepted and reported
under the hash computed here. No test sends a transaction; nothing in
the suite can spend real money. The secrets audit now covers the NEAR
key's Debug output too.

Refs #74

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 13:00:20 +03:00
grenadeandClaude Opus 5.5 1c9467c38d feat(near): NEAR RPC routed by shard, with our nodes first
ci / gate (push) Canceled after 3m50s
The intents account (#71) lives on NEAR, where a node knows nothing of
shards it does not track. NearRpc is the port: every call names the
account it is about. HttpNearRpc works out that account's shard from the
layout and asks our node for it (s<shard>.near.blackbeard.observer),
then the public RPCs. The endpoints are in near.toml, which an operator
file in the data directory replaces.

Our nodes are trusted only while synced. A fresh node answers from the
height it has reached, so the shard-13 node still syncing today said
wrap.near does not exist. The adapter therefore checks each of our
names' /status at most once a minute and skips one that is syncing or
more than two minutes behind. A node that disowns its shard drops the
cached layout, which is how a reshard shows up. Nothing waits for a
transaction's outcome, which no single shard node can assemble; send
returns at INCLUDED_FINAL.

Two of #72's notes were wrong and are corrected here:
- view_access_key wants the full key (ml-dsa-65:<base58>); the
  ml-dsa-65-hash: handle that key listings show is refused as a parse
  error.
- A missing key comes back as a result carrying `error`, not as an RPC
  error. It is read as UNKNOWN_ACCESS_KEY.

Live tests (ignored by default) read accounts on shards 4, 7, 8 and 13,
find #72's ML-DSA key and not its deleted ed25519 one, and make a view
call. Shard 13 fell through to the public RPC past the syncing node.
The app wires it in with #74, the first thing that reads it.

Closes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 12:55:01 +03:00
grenadeandClaude Opus 5.5 b059a8d5d0 feat(ui): the running version in the sidebar, linked to its release
ci / gate (push) Successful in 13m48s
The sidebar now reads "wallet v0.3.0" under the mark, and the version
opens its release tag on git.lair.cafe in the system browser. The number
is the binary's own, from app_info, so a packaged build always names the
tag it was cut from. The opener's allow-list gains this repository's
URLs and nothing else on that host.

On a dev run with crystal_alice unlocked: the tagline rendered with the
version in mono, not uppercased; opening the tag URL resolved, and
git.lair.cafe/other/repo was refused by the opener.

Closes #70

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-09 10:47:05 +03:00
grenadeandClaude Opus 5 bfb016d76f chore(release): 0.3.0
ci / gate (push) Successful in 12m58s
release / linux (push) Successful in 14m33s
release / package (44) (push) Successful in 36s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 1m0s
Restoring a phrase finds the accounts it already uses (#69), and any open
wallet can be rescanned from the accounts page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:21 +03:00
grenadeandClaude Opus 5 fb42138651 feat: find the accounts a restored phrase already uses
ci / gate (push) Has been cancelled
Restoring a phrase now scans for the account numbers it already uses,
instead of opening account 1 and leaving the rest to be guessed at. Each
number is derived under both schemes, the chain is asked in one request
which of those addresses exist (a System::Account entry is there for an
account that has ever been funded), and the scan carries on while a batch
held any, stopping after a gap of 20 empty numbers, as the mobile
wallet's import does. Numbers found are opened and remembered like a
hand-added one, so they come back on the next unlock. The same scan is a
"Find accounts" button on every open wallet with a phrase, and it says
what it found. A wallet with no phrase, or a chain that is not connected,
offers no scan; a failure during restore is swallowed, since the wallet
is open either way and the button retries.

On the dev node: 7 DEV sent from the CLI to account 3 of the dev phrase,
then that phrase restored here. Without pressing anything the wallet
opened accounts 1, 2 and 3 under both schemes, account 3 showing the
7 DEV, after looking at 40 numbers; settings recorded 1 and 2. On a
freshly created phrase, Find accounts looked at 20 numbers, added
nothing, and said so.

Closes #69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-18 09:47:01 +03:00
grenadeandClaude Opus 5 70dc6ea650 chore(release): 0.2.1
ci / gate (push) Successful in 11m35s
release / linux (push) Successful in 14m34s
release / package (44) (push) Successful in 37s
release / publish (44) (push) Successful in 13s
deps / advisories (push) Successful in 55s
Nullifiers never leave the client (#68): the wormhole spent check reads
padded, shuffled buckets of UsedNullifiers instead of looking each
nullifier up by key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:01:08 +03:00
grenadeandClaude Opus 5 b454e4424f fix(wormhole): nullifiers never leave the client
ci / gate (push) Has started running
The spent check for wormhole deposits looked each nullifier up in
Wormhole::UsedNullifiers by key. That map is Blake2_128Concat, so the key
is blake2_128(n) ‖ n and every request handed the node the nullifier
itself. An exit publishes its nullifier on chain, so whoever runs the
node could match a later exit to the connection that asked, and that
connection also asked for the wallet's ordinary balances. The accounts
page ran it automatically for every open wallet with a phrase, so 0.1.0
and 0.2.0 sent unspent nullifiers to our mainnet endpoint.

spent_among now does what quantus/extension does: it reads whole buckets
of the map (keys sharing the first byte of blake2_128(n)) with
state_getKeysPaged, the buckets it needs padded with random others to at
least 16 and asked in shuffled order, and decides membership in Rust. The
node learns which buckets were read, never which entry mattered. The per-
key lookup is gone. Tests pin the rule: the plan covers ours, pads to 16
without repeats and does not lead with ours; no request carries a
nullifier and each is the map's root plus one byte. Against mainnet, a
real used nullifier read from a bucket hashes to that bucket and the
bucket read reports it spent and a random one unspent.

doc/threat-model.md gains the wormhole section: nullifiers and the
wallet's leaves are never named to a node, and spending (#46, #47) must
not request the Merkle path for the spent leaf alone.

Closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 21:00:32 +03:00
58 changed files with 5555 additions and 124 deletions
Generated
+28 -4
View File
@@ -6956,9 +6956,10 @@ dependencies = [
[[package]]
name = "wallet-app"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"anyhow",
"bs58",
"chrono",
"directories",
"getrandom 0.3.4",
@@ -6978,34 +6979,39 @@ dependencies = [
"wallet-core",
"wallet-data",
"wallet-entities",
"wallet-near",
"zeroize",
]
[[package]]
name = "wallet-core"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"async-trait",
"blake2",
"bs58",
"serde",
"serde_json",
"sha3",
"thiserror 2.0.18",
"tokio",
"toml 0.8.2",
"wallet-entities",
"wallet-near",
"zeroize",
]
[[package]]
name = "wallet-data"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"base64 0.22.1",
"bip39",
"blake2",
"bs58",
"chrono",
"directories",
"getrandom 0.3.4",
@@ -7027,12 +7033,13 @@ dependencies = [
"tracing-subscriber",
"wallet-core",
"wallet-entities",
"wallet-near",
"zeroize",
]
[[package]]
name = "wallet-entities"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"serde",
"serde_json",
@@ -7040,6 +7047,23 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "wallet-near"
version = "0.4.0"
dependencies = [
"bip39",
"bs58",
"getrandom 0.3.4",
"hex",
"hmac 0.12.1",
"qp-rusty-crystals-dilithium",
"serde_json",
"sha2 0.10.9",
"sha3",
"thiserror 2.0.18",
"zeroize",
]
[[package]]
name = "want"
version = "0.3.1"
+12 -4
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*"]
[workspace.package]
version = "0.2.0"
version = "0.4.0"
edition = "2024"
rust-version = "1.88"
license = "GPL-3.0-or-later"
@@ -11,9 +11,10 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
repository = "https://git.lair.cafe/blackbeard/wallet"
[workspace.dependencies]
wallet-entities = { path = "crates/wallet-entities", version = "=0.2.0" }
wallet-core = { path = "crates/wallet-core", version = "=0.2.0" }
wallet-data = { path = "crates/wallet-data", version = "=0.2.0" }
wallet-entities = { path = "crates/wallet-entities", version = "=0.4.0" }
wallet-core = { path = "crates/wallet-core", version = "=0.4.0" }
wallet-data = { path = "crates/wallet-data", version = "=0.4.0" }
wallet-near = { path = "crates/wallet-near", version = "=0.4.0" }
anyhow = "1"
async-trait = "0.1"
@@ -37,6 +38,13 @@ qp-poseidon-core = "3.1.0"
# SS58: blake2b-512 checksum and base58. Nothing else from sp-core is needed.
blake2 = { version = "0.10", default-features = false }
bs58 = "0.5"
# NEAR RPC carries transactions and view arguments as base64.
base64 = "0.22"
# NEAR (#74): NEP-649 is SLIP-10 over HMAC-SHA512; a transaction hash is
# SHA-256; an ML-DSA-65 key's on-chain handle is SHA3-256.
hmac = "0.12"
sha2 = "0.10"
sha3 = "0.10"
getrandom = "0.3"
# The quantus-cli keystore format: Argon2id (frozen m=19456,t=2,p=1) into
# AES-256-GCM. The versions are pinned to what the CLI itself uses.
+2
View File
@@ -21,6 +21,7 @@ tauri-build.workspace = true
wallet-entities.workspace = true
wallet-core.workspace = true
wallet-data.workspace = true
wallet-near.workspace = true
tauri.workspace = true
tauri-plugin-dialog.workspace = true
tauri-plugin-clipboard-manager.workspace = true
@@ -31,6 +32,7 @@ chrono.workspace = true
tokio.workspace = true
# Used by tauri::generate_context! for the plugins section of tauri.conf.json.
serde_json.workspace = true
bs58.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
+2 -2
View File
@@ -15,8 +15,8 @@ permissions = [
"dialog:allow-open",
# Copy an address from the receive screen. Write only: the app never reads what is on the clipboard.
"clipboard-manager:allow-write-text",
# Open an account's page on the public explorer in the system browser, from the history screen. Only these two hosts, and only URLs: never a file, never a path.
{ identifier = "opener:allow-open-url", allow = [{ url = "https://explorer.quantus.com/*" }, { url = "https://blackbeard.observer/*" }] },
# Open pages in the system browser: an account on the public explorer, from the history screen, and the release this build came from, from the version in the sidebar. Only these hosts (and only this repository on the forge), and only URLs: never a file, never a path.
{ identifier = "opener:allow-open-url", allow = [{ url = "https://explorer.quantus.com/*" }, { url = "https://blackbeard.observer/*" }, { url = "https://git.lair.cafe/blackbeard/wallet/*" }] },
# Check for, download and install a release signed with the key in tauri.conf.json; the manifest lives on our own host. The person is asked first, always.
"updater:default",
# Relaunch after an accepted update. Only relaunch: no exit, no restart of anything else.
File diff suppressed because it is too large Load Diff
+10
View File
@@ -59,6 +59,7 @@ pub fn run() {
commands::checkphrase,
commands::unlock,
commands::wallet_add_account,
commands::wallet_discover_accounts,
commands::lock,
commands::session_status,
commands::chains_list,
@@ -75,6 +76,15 @@ pub fn run() {
commands::settings_set,
commands::history_page,
commands::wormhole_summary,
commands::near_intents,
commands::near_create_account,
commands::near_qtc_deposit_address,
commands::near_withdraw_qtc,
commands::near_swap_quote,
commands::near_swap,
commands::near_cashout_destinations,
commands::near_cashout_quote,
commands::near_cashout,
swap::swap_assets,
swap::swap_preview,
swap::swap_order_create,
+31 -2
View File
@@ -10,11 +10,16 @@ use std::time::Duration;
use wallet_core::PreparedTransaction;
use wallet_core::assets::AssetRegistry;
use wallet_core::backup::BackupChallenge;
use wallet_core::ports::{FiatRamp, HistoryCache, HistorySource, OrderStore, ShieldedCache};
use wallet_core::near::NearNetwork;
use wallet_core::ports::{
FiatRamp, HistoryCache, HistorySource, NearRpc, NearSponsor, OrderStore, QtcBridge,
ShieldedCache,
};
use wallet_core::profiles::ProfileRegistry;
use wallet_core::session::Session;
use wallet_data::history::{ObserverHistory, SqliteHistoryCache};
use wallet_data::keystore::FileKeystore;
use wallet_data::near::{HttpNearRpc, HttpSponsor, PoaBridge};
use wallet_data::ramp::StubRamp;
use wallet_data::settings::SettingsStore;
use wallet_data::substrate::ChainManager;
@@ -51,6 +56,9 @@ pub struct AppState {
pub history_cache: Arc<dyn HistoryCache>,
/// The same SQLite file, for the zk-tree scan of the wormhole address.
pub shielded_cache: Arc<dyn ShieldedCache>,
/// One zk-tree scan at a time: a scan covers every open wallet, so a
/// second one waiting here finds its wallet already caught up.
pub zk_scan: tokio::sync::Mutex<()>,
/// Swap orders, in the same file, polled while open.
pub orders: Arc<dyn OrderStore>,
/// The swap provider: NEAR Intents' 1Click, through the asset registry.
@@ -58,6 +66,13 @@ pub struct AppState {
pub assets: AssetRegistry,
/// The fiat ramp: a stub until a provider is signed (#44).
pub ramp: Arc<dyn FiatRamp>,
/// NEAR, under the intents account (#71): RPC routed by shard (#73).
pub near: Arc<dyn NearRpc>,
/// The sponsor that creates the account and pays its gas (#81).
pub near_sponsor: Arc<dyn NearSponsor>,
/// The PoA bridge between Quantus and NEAR (#75).
pub qtc_bridge: Arc<dyn QtcBridge>,
pub near_network: NearNetwork,
}
impl AppState {
@@ -65,7 +80,7 @@ impl AppState {
/// `WALLET_KEYSTORE_DIR` says otherwise (useful for a dev run against a
/// throwaway directory, or for pointing at `~/.quantus/wallets`).
/// Everything lives under one app-data directory: `<data>/wallets/`,
/// `<data>/settings.json`, `<data>/profiles.toml`. `WALLET_DATA_DIR`
/// `<data>/settings.json`, `<data>/profiles.toml`, `<data>/near.toml`. `WALLET_DATA_DIR`
/// relocates the whole thing (a dev run against a throwaway directory);
/// `WALLET_KEYSTORE_DIR` relocates only the wallets (pointing at
/// `~/.quantus/wallets` shares them with the CLI in place).
@@ -97,6 +112,15 @@ impl AppState {
profiles = profiles.with_override(&text)?;
tracing::info!(path = %override_path.display(), "applied profile overrides");
}
// NEAR endpoints and sponsor: shipped, or `<data>/near.toml` in their
// place.
let near_path = data_dir.join("near.toml");
let near_network = if near_path.exists() {
tracing::info!(path = %near_path.display(), "NEAR settings from the data directory");
NearNetwork::from_toml(&std::fs::read_to_string(&near_path)?)?
} else {
NearNetwork::shipped()?
};
let assets = AssetRegistry::shipped().map_err(|e| anyhow::anyhow!("assets.toml: {e}"))?;
let cache = Arc::new(
SqliteHistoryCache::open(&data_dir.join("history.sqlite"))
@@ -114,10 +138,15 @@ impl AppState {
history_source: Arc::new(ObserverHistory::new()),
history_cache: cache.clone(),
shielded_cache: cache.clone(),
zk_scan: tokio::sync::Mutex::new(()),
orders: cache,
swap: Arc::new(OneClick::new(assets.clone())),
assets,
ramp: Arc::new(StubRamp::new()),
near: Arc::new(HttpNearRpc::new(near_network.clone())),
near_sponsor: Arc::new(HttpSponsor::new(&near_network.sponsor_api)),
qtc_bridge: Arc::new(PoaBridge::new(&near_network.bridge_rpc)),
near_network,
})
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "blackbeard-wallet",
"version": "0.2.0",
"version": "0.4.0",
"identifier": "cafe.lair.blackbeard.wallet",
"build": {
"frontendDist": "../../ui/dist",
+3
View File
@@ -10,6 +10,7 @@ repository.workspace = true
[dependencies]
wallet-entities.workspace = true
wallet-near.workspace = true
async-trait.workspace = true
thiserror.workspace = true
zeroize.workspace = true
@@ -17,6 +18,8 @@ blake2.workspace = true
bs58.workspace = true
serde.workspace = true
toml.workspace = true
serde_json.workspace = true
sha3.workspace = true
tokio.workspace = true
[dev-dependencies]
+6 -8
View File
@@ -181,10 +181,8 @@ mod tests {
})
.unwrap();
assert_eq!((qtc.info.symbol.as_str(), qtc.info.decimals), ("QTC", 12));
assert!(
qtc.intents.is_none(),
"no post-quantum asset is on Intents yet"
);
// Bridged by the PoA bridge since October 2026 (#71).
assert_eq!(qtc.intents.as_deref(), Some("nep141:qtc.omft.near"));
}
#[test]
@@ -214,13 +212,13 @@ mod tests {
#[test]
fn an_unmapped_asset_is_refused_by_name() {
let r = AssetRegistry::shipped().unwrap();
let qtc = AssetId {
chain: ChainId("quantus".into()),
let plk = AssetId {
chain: ChainId("planck".into()),
kind: AssetKind::Native,
};
assert!(matches!(
r.intents_id(&qtc),
Err(SwapError::UnsupportedPair(ref a)) if a == "quantus/native"
r.intents_id(&plk),
Err(SwapError::UnsupportedPair(ref a)) if a == "planck/native"
));
let unknown = AssetId {
chain: ChainId("near".into()),
+3 -2
View File
@@ -8,8 +8,8 @@
# wallet only ever passes value through
# kind "native", or "nep141" with a contract, or "pallet" with an id
# intents the NEAR Intents asset id from GET /v0/tokens, when it has one.
# Post-quantum chains have none yet; that is the whole reason the
# Quantus leg of a swap is its own child (#42).
# QTC has one since early October 2026: the PoA bridge mints it
# on NEAR as qtc.omft.near (#71).
# ramp a fiat ramp provider's symbol code, when one is chosen (#44)
# ---- what the wallet holds ---------------------------------------------------
@@ -19,6 +19,7 @@ chain = "quantus"
kind = "native"
symbol = "QTC"
decimals = 12
intents = "nep141:qtc.omft.near"
[[asset]]
chain = "planck"
+203
View File
@@ -0,0 +1,203 @@
//! Cash-out destinations (#77): where the intents account can pay out
//! through 1Click, and the address each takes. An address is checked here,
//! by the form its chain uses, before anything is signed; a typo in a
//! payout address is the one mistake no refund covers.
use serde::Deserialize;
use sha3::{Digest, Keccak256};
const SHIPPED: &str = include_str!("cashout.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AddressForm {
Evm,
Bitcoin,
Solana,
Near,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Destination {
/// 1Click's asset id.
pub id: String,
pub chain: String,
pub symbol: String,
pub decimals: u8,
pub address: AddressForm,
}
#[derive(Debug, Deserialize)]
struct File {
destination: Vec<Destination>,
}
pub fn destinations() -> Vec<Destination> {
toml::from_str::<File>(SHIPPED)
.expect("cashout.toml is valid; a test parses it")
.destination
}
pub fn destination(id: &str) -> Option<Destination> {
destinations().into_iter().find(|d| d.id == id)
}
/// Whether `addr` is an address of the destination's form, with a reason
/// when it is not.
pub fn check_address(form: AddressForm, addr: &str) -> Result<(), String> {
let a = addr.trim();
match form {
AddressForm::Evm => {
let hex = a
.strip_prefix("0x")
.filter(|h| h.len() == 40 && h.chars().all(|c| c.is_ascii_hexdigit()))
.ok_or_else(|| {
format!("{a:?} is not an Ethereum-style address (0x and 40 hex digits)")
})?;
let mixed = hex.chars().any(|c| c.is_ascii_uppercase())
&& hex.chars().any(|c| c.is_ascii_lowercase());
if mixed && !eip55_ok(hex) {
return Err(format!(
"{a} fails its EIP-55 checksum: a character is mistyped"
));
}
Ok(())
}
AddressForm::Bitcoin => {
let lower = a.to_ascii_lowercase();
let bech32 = lower.starts_with("bc1")
&& (14..=74).contains(&a.len())
&& lower[3..]
.chars()
.all(|c| "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c))
&& (a == lower || a == a.to_ascii_uppercase());
let legacy = (a.starts_with('1') || a.starts_with('3'))
&& (26..=35).contains(&a.len())
&& bs58::decode(a).into_vec().is_ok_and(|v| v.len() == 25);
if bech32 || legacy {
Ok(())
} else {
Err(format!("{a:?} is not a Bitcoin address"))
}
}
AddressForm::Solana => match bs58::decode(a).into_vec() {
Ok(v) if v.len() == 32 => Ok(()),
_ => Err(format!("{a:?} is not a Solana address")),
},
AddressForm::Near => {
let implicit = a.len() == 64 && a.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'));
let named = (2..=64).contains(&a.len())
&& a.split('.').all(|part| {
!part.is_empty()
&& part
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_'))
&& !part.starts_with(['-', '_'])
&& !part.ends_with(['-', '_'])
});
if implicit || named {
Ok(())
} else {
Err(format!("{a:?} is not a NEAR account"))
}
}
}
}
fn eip55_ok(hex: &str) -> bool {
let hash = Keccak256::digest(hex.to_ascii_lowercase().as_bytes());
hex.chars().enumerate().all(|(i, c)| {
if !c.is_ascii_alphabetic() {
return true;
}
let nibble = (hash[i / 2] >> if i % 2 == 0 { 4 } else { 0 }) & 0xf;
c.is_ascii_uppercase() == (nibble >= 8)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shipped_destinations_parse_and_are_distinct() {
let d = destinations();
assert!(d.len() >= 8);
let mut ids: Vec<_> = d.iter().map(|x| &x.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), d.len());
assert_eq!(
destination("nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near")
.unwrap()
.symbol,
"USDC"
);
}
#[test]
fn evm_addresses_are_checked_with_eip55() {
// EIP-55's own examples.
for ok in [
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed",
"0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359",
] {
check_address(AddressForm::Evm, ok).unwrap();
}
check_address(
AddressForm::Evm,
"0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed",
)
.unwrap();
assert!(
check_address(
AddressForm::Evm,
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAeD"
)
.is_err()
);
assert!(
check_address(AddressForm::Evm, "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeA").is_err()
);
assert!(
check_address(AddressForm::Evm, "5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed").is_err()
);
}
#[test]
fn bitcoin_solana_and_near_addresses_are_checked_by_form() {
check_address(
AddressForm::Bitcoin,
"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
)
.unwrap();
check_address(AddressForm::Bitcoin, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa").unwrap();
assert!(
check_address(
AddressForm::Bitcoin,
"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdO"
)
.is_err()
);
assert!(
check_address(
AddressForm::Bitcoin,
"0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"
)
.is_err()
);
check_address(
AddressForm::Solana,
"9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
)
.unwrap();
assert!(check_address(AddressForm::Solana, "9WzDXwBbmkg8ZTbNMqUxvQ").is_err());
check_address(AddressForm::Near, "bbspike72.near").unwrap();
check_address(
AddressForm::Near,
"42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978",
)
.unwrap();
assert!(check_address(AddressForm::Near, "Bad.Near").is_err());
}
}
+63
View File
@@ -0,0 +1,63 @@
# Where the intents account can pay out (#77): a 1Click withdrawal to an
# address on another chain. Each destination names its 1Click asset id (from
# GET /v0/tokens, checked 2026-10-09) and the address form it takes, which the
# wallet checks before anything is signed; 1Click checks it again.
#
# address evm (0x + 40 hex, EIP-55 checked when mixed case), bitcoin,
# solana (base58 of 32 bytes), near (a NEAR account id)
[[destination]]
id = "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near"
chain = "Ethereum"
symbol = "USDC"
decimals = 6
address = "evm"
[[destination]]
id = "nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near"
chain = "Ethereum"
symbol = "USDT"
decimals = 6
address = "evm"
[[destination]]
id = "nep141:eth.omft.near"
chain = "Ethereum"
symbol = "ETH"
decimals = 18
address = "evm"
[[destination]]
id = "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near"
chain = "Base"
symbol = "USDC"
decimals = 6
address = "evm"
[[destination]]
id = "nep141:arb-0xaf88d065e77c8cc2239327c5edb3a432268e5831.omft.near"
chain = "Arbitrum"
symbol = "USDC"
decimals = 6
address = "evm"
[[destination]]
id = "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near"
chain = "Solana"
symbol = "USDC"
decimals = 6
address = "solana"
[[destination]]
id = "nep141:btc.omft.near"
chain = "Bitcoin"
symbol = "BTC"
decimals = 8
address = "bitcoin"
[[destination]]
id = "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1"
chain = "NEAR"
symbol = "USDC"
decimals = 6
address = "near"
+2
View File
@@ -13,6 +13,8 @@
pub mod assets;
pub mod backup;
pub mod cashout;
pub mod near;
pub mod orders;
pub mod paths;
pub mod ports;
+746
View File
@@ -0,0 +1,746 @@
//! NEAR: the chain under the wallet's intents account (#71). Which endpoint
//! serves which account, worked out from the shard layout. Pure: the
//! adapter in `wallet-data` does the HTTP.
use serde::Deserialize;
use wallet_entities::{Amount, IntentsBalance, NearAccountSummary};
pub use wallet_near::sponsored_account;
use crate::assets::AssetRegistry;
use crate::ports::{NearError, NearRpc};
const SHIPPED: &str = include_str!("near.toml");
#[derive(Debug, thiserror::Error)]
pub enum NearConfigError {
#[error("near.toml: {0}")]
Parse(String),
#[error("near.toml: {0}")]
Invalid(String),
#[error("shard layout: {0}")]
Layout(String),
}
/// Where the wallet sends NEAR RPC: our per-shard names, then public
/// fallbacks.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct NearNetwork {
/// `{shard}` is replaced by the shard id.
pub shard_url: String,
/// The shards our nodes track. Any other shard goes to `fallback` only.
pub served_shards: Vec<u64>,
pub fallback: Vec<String>,
/// The account that creates wallets' accounts as its sub-accounts (#81).
pub sponsor: String,
/// The sponsor's API (`…/sponsor`, `…/accounts`, `…/relay`).
pub sponsor_api: String,
/// The PoA bridge's JSON-RPC (#75).
pub bridge_rpc: String,
}
impl NearNetwork {
pub fn shipped() -> Result<Self, NearConfigError> {
Self::from_toml(SHIPPED)
}
pub fn from_toml(text: &str) -> Result<Self, NearConfigError> {
let n: Self = toml::from_str(text).map_err(|e| NearConfigError::Parse(e.to_string()))?;
if !n.shard_url.contains("{shard}") {
return Err(NearConfigError::Invalid(
"shard_url has no {shard} placeholder".into(),
));
}
if n.served_shards.is_empty() && n.fallback.is_empty() {
return Err(NearConfigError::Invalid("no endpoint at all".into()));
}
Ok(n)
}
/// The endpoints to try, in order, for anything about an account on
/// `shard`.
pub fn endpoints_for(&self, shard: u64) -> Vec<String> {
let mut out = vec![];
if self.served_shards.contains(&shard) {
out.push(self.shard_url.replace("{shard}", &shard.to_string()));
}
out.extend(self.fallback.iter().cloned());
out
}
/// Endpoints for questions about no account in particular (the layout,
/// a recent block hash): every served shard, then the fallbacks.
pub fn any_endpoints(&self) -> Vec<String> {
let mut out: Vec<String> = self
.served_shards
.iter()
.map(|s| self.shard_url.replace("{shard}", &s.to_string()))
.collect();
out.extend(self.fallback.iter().cloned());
out
}
}
/// The highest swap fee the wallet accepts from a sponsor (#82): the
/// official Quantus wallet charges 0.5%, and this one charges no more.
pub const MAX_FEE_BPS: u32 = 50;
/// Terms the wallet will act on: enabled, naming the account the wallet
/// expects, and a fee within [`MAX_FEE_BPS`]. Anything else is refused by
/// name rather than used.
pub fn usable_terms(
terms: &crate::ports::SponsorTerms,
expected_sponsor: &str,
) -> Result<(u32, String), String> {
if !terms.enabled {
return Err("the sponsor is not running".into());
}
if terms.account.as_deref() != Some(expected_sponsor) {
return Err(format!(
"the sponsor says it is {:?}, not {expected_sponsor}",
terms.account
));
}
let fee = terms.fee_bps.ok_or("the sponsor publishes no fee")?;
if fee > MAX_FEE_BPS {
return Err(format!(
"the sponsor's fee is {fee} bps, above the {MAX_FEE_BPS} this wallet accepts"
));
}
let recipient = terms
.fee_recipient
.clone()
.ok_or("the sponsor names no fee recipient")?;
Ok((fee, recipient))
}
/// QTC on NEAR, as the PoA bridge mints it.
pub const QTC_TOKEN: &str = "qtc.omft.near";
/// QTC has 12 decimals on both sides of the bridge.
pub const QTC_DECIMALS: u8 = 12;
/// The bridge's smallest deposit or withdrawal: 0.01 QTC.
pub const QTC_BRIDGE_MIN: u128 = 10_000_000_000;
/// What the bridge keeps from a withdrawal back to Quantus: 0.002 QTC.
pub const QTC_WITHDRAW_FEE: u128 = 2_000_000_000;
/// The call that takes `amount` of QTC out of Intents and back to the
/// Quantus address `to`: an `ft_withdraw` of `qtc.omft.near` to itself with
/// the bridge's `WITHDRAW_TO:` memo, as #72 proved on mainnet. Refused
/// below the bridge's minimum; `to` must already be checked as a Quantus
/// address.
pub fn qtc_withdraw_call(to: &str, amount: u128) -> Result<wallet_near::tx::Action, String> {
if amount < QTC_BRIDGE_MIN {
return Err(format!(
"the bridge takes at least 0.01 QTC; {amount} base units ({}.{:012} QTC) is less",
amount / 10u128.pow(u32::from(QTC_DECIMALS)),
amount % 10u128.pow(u32::from(QTC_DECIMALS))
));
}
Ok(wallet_near::tx::Action::call(
"ft_withdraw",
&serde_json::json!({
"token": QTC_TOKEN,
"receiver_id": QTC_TOKEN,
"amount": amount.to_string(),
"memo": format!("WITHDRAW_TO:{to}"),
}),
100 * wallet_near::tx::TGAS,
1,
))
}
/// Ref's DCL contract: QTC's liquidity on NEAR (#76).
pub const DCL: &str = "dclv2.ref-labs.near";
/// The swap fee on `amount`, rounded up: `ceil(amount × bps / 10000)`.
/// The sponsor computes it the same way and refuses a pool swap that pays
/// less (#82).
pub fn fee_on(amount: u128, bps: u32) -> u128 {
amount.saturating_mul(u128::from(bps)).div_ceil(10_000)
}
/// The best Ref DCL pool for swapping `amount` of `token_in` into
/// `token_out` (contract ids), quoted on chain: `(pool_id, output)`.
/// Pools that are not running, or whitelist other accounts (they refuse
/// anyone else with E407), are skipped.
pub async fn best_pool(
rpc: &dyn NearRpc,
account: &str,
token_in: &str,
token_out: &str,
amount: u128,
) -> Result<Option<(String, u128)>, NearError> {
let mut pools = vec![];
let mut from = 0u64;
loop {
let page = rpc
.view(
DCL,
"list_pools",
&serde_json::json!({ "from_index": from, "limit": 1000 }),
)
.await?;
let page = page.as_array().cloned().unwrap_or_default();
let n = page.len();
pools.extend(page.into_iter().filter(|p| {
let (x, y) = (p["token_x"].as_str(), p["token_y"].as_str());
let pair = (x == Some(token_in) && y == Some(token_out))
|| (x == Some(token_out) && y == Some(token_in));
let open = p["whitelist"]
.as_array()
.is_none_or(|w| w.is_empty() || w.iter().any(|a| a.as_str() == Some(account)));
pair && open && p["state"] == "Running"
}));
if n < 1000 {
break;
}
from += 1000;
}
let mut best: Option<(String, u128)> = None;
for p in pools {
let Some(id) = p["pool_id"].as_str() else {
continue;
};
let q = rpc
.view(
DCL,
"quote",
&serde_json::json!({ "pool_ids": [id], "input_token": token_in, "output_token": token_out,
"input_amount": amount.to_string(), "tag": null }),
)
.await?;
let out: u128 = q["amount"]
.as_str()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
if out > 0 && best.as_ref().is_none_or(|(_, b)| out > *b) {
best = Some((id.to_owned(), out));
}
}
Ok(best)
}
/// A pool swap from the intents account (#76), as one delegate action's
/// calls: first the fee's `mt_transfer` to the sponsor's recipient, then
/// the `ft_withdraw` to the pool with Ref's `Swap` message, whose output is
/// paid back into the account (`client_echo`). `swap_amount` is what goes
/// to the pool, after the fee; `min_out` is the floor below which the pool
/// refuses and refunds. Both calls are to `intents.near` in one action, so
/// the fee is never taken without the withdrawal; a pool that then refuses
/// the swap refunds the input, but not the fee.
#[allow(clippy::too_many_arguments)]
pub fn pool_swap_calls(
account: &str,
token_in: &str,
token_out: &str,
pool_id: &str,
swap_amount: u128,
fee: u128,
fee_recipient: &str,
min_out: u128,
) -> Vec<wallet_near::tx::Action> {
use wallet_near::tx::{Action, TGAS};
let mut calls = vec![];
if fee > 0 {
calls.push(Action::call(
"mt_transfer",
&serde_json::json!({ "receiver_id": fee_recipient, "token_id": format!("nep141:{token_in}"),
"amount": fee.to_string() }),
30 * TGAS,
1,
));
}
let swap = serde_json::json!({ "Swap": {
"pool_ids": [pool_id],
"output_token": token_out,
"min_output_amount": min_out.to_string(),
"client_echo": serde_json::json!({ "receiver_id": account }).to_string(),
}});
calls.push(Action::call(
"ft_withdraw",
&serde_json::json!({ "token": token_in, "receiver_id": DCL, "amount": swap_amount.to_string(),
"msg": swap.to_string() }),
// #72 measured 250 Tgas as enough for a DCL swap, and the sponsor
// caps ft_withdraw there.
200 * TGAS,
1,
));
calls
}
/// One transfer inside Intents, from the account to `to` (a 1Click deposit
/// account, #76/#77).
pub fn intents_transfer_call(to: &str, token: &str, amount: u128) -> wallet_near::tx::Action {
wallet_near::tx::Action::call(
"mt_transfer",
&serde_json::json!({ "receiver_id": to, "token_id": format!("nep141:{token}"),
"amount": amount.to_string() }),
30 * wallet_near::tx::TGAS,
1,
)
}
/// The NEAR Intents verifier contract.
pub const INTENTS: &str = "intents.near";
/// The public side of a wallet's NEAR key, which is all a summary needs.
pub struct NearIdentity {
pub wallet: String,
/// `ml-dsa-65:<base58>`.
pub public_key: String,
/// The sponsored account of that key ([`sponsored_account`]).
pub account_id: String,
pub derivation_path: String,
}
/// Read the wallet's intents account: whether it exists, whether its key is
/// this wallet's, its gas, and every token the asset registry maps to
/// Intents that it holds. One view call covers the tokens.
pub async fn account_summary(
rpc: &dyn NearRpc,
assets: &AssetRegistry,
id: NearIdentity,
) -> Result<NearAccountSummary, NearError> {
let account_id = id.account_id.clone();
let account = rpc.account(&account_id).await?;
let mut summary = NearAccountSummary {
wallet: id.wallet,
owner_open: false,
candidates: vec![],
account_id: account_id.clone(),
public_key: id.public_key.clone(),
derivation_path: id.derivation_path,
exists: account.is_some(),
key_on_account: false,
gas: account.map(|a| a.balance),
can_create: false,
sponsor_note: None,
intents: vec![],
};
if !summary.exists {
return Ok(summary);
}
summary.key_on_account = rpc
.access_key_nonce(&account_id, &id.public_key)
.await?
.is_some();
// Every Intents id the registry knows, once each (QTC appears under
// both the Quantus chain and NEAR's).
let mut tokens: Vec<(String, String, u8)> = vec![];
for e in assets.iter() {
if let Some(t) = &e.intents
&& t.starts_with("nep141:")
&& !tokens.iter().any(|(x, ..)| x == t)
{
tokens.push((t.clone(), e.info.symbol.clone(), e.info.decimals));
}
}
let ids: Vec<&str> = tokens.iter().map(|(t, ..)| t.as_str()).collect();
let raw = rpc
.view(
INTENTS,
"mt_batch_balance_of",
&serde_json::json!({ "account_id": account_id, "token_ids": ids }),
)
.await?;
let raw: Vec<String> = serde_json::from_value(raw)
.map_err(|e| NearError::BadAnswer(format!("mt_batch_balance_of: {e}")))?;
if raw.len() != tokens.len() {
return Err(NearError::BadAnswer(format!(
"mt_batch_balance_of: {} balances for {} tokens",
raw.len(),
tokens.len()
)));
}
for ((token, symbol, decimals), r) in tokens.into_iter().zip(raw) {
let n: u128 = r
.parse()
.map_err(|_| NearError::BadAnswer(format!("balance {r:?} of {token}")))?;
if n > 0 {
summary.intents.push(IntentsBalance {
token,
symbol,
amount: Amount::new(n, decimals),
});
}
}
Ok(summary)
}
/// The boundary-account shard layout (V1 to V3 all share this shape).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShardLayout {
boundary_accounts: Vec<String>,
shard_ids: Vec<u64>,
}
impl ShardLayout {
pub fn new(
boundary_accounts: Vec<String>,
shard_ids: Vec<u64>,
) -> Result<Self, NearConfigError> {
if shard_ids.len() != boundary_accounts.len() + 1 {
return Err(NearConfigError::Layout(format!(
"{} boundaries need {} shard ids, got {}",
boundary_accounts.len(),
boundary_accounts.len() + 1,
shard_ids.len()
)));
}
if boundary_accounts.windows(2).any(|w| w[0] >= w[1]) {
return Err(NearConfigError::Layout("boundaries are not sorted".into()));
}
Ok(Self {
boundary_accounts,
shard_ids,
})
}
/// From `EXPERIMENTAL_protocol_config`'s `shard_layout`, which is an
/// object with one key, the layout's version (`V2`, `V3`).
pub fn from_protocol_config(shard_layout: &serde_json::Value) -> Result<Self, NearConfigError> {
let inner = shard_layout
.as_object()
.and_then(|o| o.values().next())
.ok_or_else(|| NearConfigError::Layout("no shard_layout object".into()))?;
let boundaries: Vec<String> = serde_json::from_value(inner["boundary_accounts"].clone())
.map_err(|e| NearConfigError::Layout(format!("boundary_accounts: {e}")))?;
let ids: Vec<u64> = serde_json::from_value(inner["shard_ids"].clone())
.map_err(|e| NearConfigError::Layout(format!("shard_ids: {e}")))?;
Self::new(boundaries, ids)
}
/// The shard an account lives on: one past every boundary that sorts at
/// or before it, as nearcore's `account_id_to_shard_id` does. Account
/// ids compare as bytes.
pub fn shard_of(&self, account: &str) -> u64 {
let i = self
.boundary_accounts
.partition_point(|b| b.as_str() <= account);
self.shard_ids[i]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Mainnet's layout V3 as of 2026-10-09 (protocol 86).
fn mainnet() -> ShardLayout {
ShardLayout::from_protocol_config(&serde_json::json!({ "V3": {
"boundary_accounts": ["650", "aurora", "aurora-0", "earn.kaiching", "game.hot.tg",
"game.hot.tg-0", "kkuuue2akv_1630967379.near", "tge-lockup.sweat", "wallet.ka"],
"shard_ids": [10, 11, 1, 8, 9, 6, 7, 4, 12, 13],
}}))
.unwrap()
}
#[test]
fn accounts_land_on_the_shards_the_nodes_answer_for() {
// Each of these was answered by its shard's node, and refused with
// UNAVAILABLE_SHARD by the others, on 2026-10-09.
let l = mainnet();
for (account, shard) in [
("intents.near", 7),
("wrap.near", 13),
("dclv2.ref-labs.near", 8),
("bbspike72.near", 8),
("qtc.omft.near", 4),
("near", 4),
("v2.ref-finance.near", 12),
(
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1",
10,
),
(
"42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978",
10,
),
] {
assert_eq!(l.shard_of(account), shard, "{account}");
}
}
#[test]
fn a_boundary_account_belongs_to_the_shard_after_it() {
let l = mainnet();
assert_eq!(l.shard_of("aurora"), 1);
assert_eq!(l.shard_of("auror"), 11);
assert_eq!(l.shard_of("0"), 10);
assert_eq!(l.shard_of("zzz.near"), 13);
}
#[test]
fn a_layout_that_does_not_add_up_is_refused() {
assert!(ShardLayout::new(vec!["a".into()], vec![1]).is_err());
assert!(ShardLayout::new(vec!["b".into(), "a".into()], vec![1, 2, 3]).is_err());
assert!(ShardLayout::from_protocol_config(&serde_json::json!({})).is_err());
}
#[test]
fn served_shards_go_to_our_name_first_and_others_straight_to_fallback() {
let n = NearNetwork::shipped().unwrap();
let ours = n.endpoints_for(7);
assert_eq!(ours[0], "https://s7.near.blackbeard.observer");
assert_eq!(&ours[1..], &n.fallback[..]);
assert_eq!(n.endpoints_for(9), n.fallback);
assert!(n.any_endpoints().len() == n.served_shards.len() + n.fallback.len());
}
struct Fake {
exists: bool,
key: &'static str,
balances: Vec<&'static str>,
}
#[async_trait::async_trait]
impl NearRpc for Fake {
async fn account(&self, _: &str) -> Result<Option<crate::ports::NearAccount>, NearError> {
Ok(self.exists.then(|| crate::ports::NearAccount {
balance: Amount::new(5, 24),
locked: Amount::new(0, 24),
storage_usage: 1,
}))
}
async fn access_key_nonce(&self, _: &str, key: &str) -> Result<Option<u64>, NearError> {
Ok((key == self.key).then_some(7))
}
async fn view(
&self,
c: &str,
m: &str,
args: &serde_json::Value,
) -> Result<serde_json::Value, NearError> {
assert_eq!((c, m), (INTENTS, "mt_batch_balance_of"));
let n = args["token_ids"].as_array().unwrap().len();
let mut b = self.balances.clone();
b.resize(n, "0");
Ok(serde_json::json!(b))
}
async fn final_block(&self) -> Result<([u8; 32], u64), NearError> {
unreachable!()
}
async fn send(&self, _: &str, _: &[u8]) -> Result<[u8; 32], NearError> {
unreachable!()
}
}
fn id(key: &str) -> NearIdentity {
NearIdentity {
wallet: "w".into(),
public_key: key.into(),
account_id: sponsored_account(&[0; 32], "blackbeard-observer.near"),
derivation_path: "m/44'/397'/0'".into(),
}
}
#[tokio::test]
async fn an_account_not_yet_created_says_so_and_reads_nothing_else() {
let net = NearNetwork::shipped().unwrap();
let s = account_summary(
&Fake {
exists: false,
key: "k",
balances: vec![],
},
&AssetRegistry::shipped().unwrap(),
id("k"),
)
.await
.unwrap();
assert!(!s.exists && !s.key_on_account && s.gas.is_none() && s.intents.is_empty());
assert_eq!(s.account_id, sponsored_account(&[0; 32], &net.sponsor));
}
#[tokio::test]
async fn an_existing_account_lists_its_nonzero_intents_tokens_once_each() {
let assets = AssetRegistry::shipped().unwrap();
let s = account_summary(
&Fake {
exists: true,
key: "k",
balances: vec!["0", "1500000", "0"],
},
&assets,
id("k"),
)
.await
.unwrap();
assert!(s.exists && s.key_on_account);
assert_eq!(s.gas, Some(Amount::new(5, 24)));
assert_eq!(s.intents.len(), 1);
let qtc_ids = assets
.iter()
.filter(|e| e.intents.as_deref() == Some("nep141:qtc.omft.near"))
.count();
assert!(qtc_ids >= 1);
}
#[tokio::test]
async fn an_account_whose_key_is_not_ours_is_marked() {
let s = account_summary(
&Fake {
exists: true,
key: "someone-else",
balances: vec![],
},
&AssetRegistry::shipped().unwrap(),
id("k"),
)
.await
.unwrap();
assert!(s.exists && !s.key_on_account);
}
#[test]
fn a_pool_swap_pays_its_fee_first_in_the_input_token() {
let calls = pool_swap_calls(
"bx.blackbeard-observer.near",
"qtc.omft.near",
"wrap.near",
"qtc.omft.near|wrap.near|10000",
9_970,
30,
"blackbeard-observer.near",
100,
);
assert_eq!(calls.len(), 2);
let wallet_near::tx::Action::FunctionCall { method, args, .. } = &calls[0] else {
panic!()
};
let a: serde_json::Value = serde_json::from_slice(args).unwrap();
assert_eq!(
(
method.as_str(),
a["receiver_id"].as_str(),
a["token_id"].as_str(),
a["amount"].as_str()
),
(
"mt_transfer",
Some("blackbeard-observer.near"),
Some("nep141:qtc.omft.near"),
Some("30")
)
);
let wallet_near::tx::Action::FunctionCall {
method, args, gas, ..
} = &calls[1]
else {
panic!()
};
let a: serde_json::Value = serde_json::from_slice(args).unwrap();
assert_eq!(
(
method.as_str(),
a["receiver_id"].as_str(),
a["amount"].as_str()
),
("ft_withdraw", Some(DCL), Some("9970"))
);
assert!(
*gas <= 250 * wallet_near::tx::TGAS,
"within the sponsor's cap"
);
let msg: serde_json::Value = serde_json::from_str(a["msg"].as_str().unwrap()).unwrap();
assert_eq!(msg["Swap"]["min_output_amount"], "100");
assert!(
msg["Swap"]["client_echo"]
.as_str()
.unwrap()
.contains("bx.blackbeard-observer.near")
);
// The sponsor's rule: the fee covers ceil(swap_amount × bps).
assert!(30 >= fee_on(9_970, 30));
}
#[test]
fn the_fee_rounds_up_as_the_sponsor_does() {
assert_eq!(fee_on(10_000, 30), 30);
assert_eq!(fee_on(1, 30), 1);
assert_eq!(fee_on(0, 30), 0);
}
#[test]
fn a_qtc_withdrawal_is_the_call_72_proved() {
let call = qtc_withdraw_call(
"qzoqjLjG3TEN9Abj71d1Ruq3Di71VctBq8pNwN2dHkh7fs1ZC",
35_000_000_000,
)
.unwrap();
let wallet_near::tx::Action::FunctionCall {
method,
args,
gas,
deposit,
} = call
else {
panic!("a function call");
};
assert_eq!(
(method.as_str(), gas, deposit),
("ft_withdraw", 100 * wallet_near::tx::TGAS, 1)
);
let a: serde_json::Value = serde_json::from_slice(&args).unwrap();
assert_eq!(a["token"], "qtc.omft.near");
assert_eq!(a["receiver_id"], "qtc.omft.near");
assert_eq!(a["amount"], "35000000000");
assert_eq!(
a["memo"],
"WITHDRAW_TO:qzoqjLjG3TEN9Abj71d1Ruq3Di71VctBq8pNwN2dHkh7fs1ZC"
);
assert!(qtc_withdraw_call("qz", QTC_BRIDGE_MIN - 1).is_err());
}
#[test]
fn terms_outside_what_the_wallet_accepts_are_refused() {
use crate::ports::SponsorTerms;
let ok = SponsorTerms {
enabled: true,
account: Some("blackbeard-observer.near".into()),
fee_bps: Some(30),
fee_recipient: Some("blackbeard-observer.near".into()),
};
assert_eq!(usable_terms(&ok, "blackbeard-observer.near").unwrap().0, 30);
let greedy = SponsorTerms {
fee_bps: Some(51),
..ok.clone()
};
assert!(usable_terms(&greedy, "blackbeard-observer.near").is_err());
let off = SponsorTerms {
enabled: false,
..ok.clone()
};
assert!(usable_terms(&off, "blackbeard-observer.near").is_err());
let impostor = SponsorTerms {
account: Some("evil.near".into()),
..ok
};
assert!(usable_terms(&impostor, "blackbeard-observer.near").is_err());
}
#[test]
fn a_sponsored_account_is_named_from_its_key_and_lands_on_shard_8() {
let n = NearNetwork::shipped().unwrap();
let name = sponsored_account(&[0xff; 32], &n.sponsor);
assert_eq!(name, "b7777777777777777.blackbeard-observer.near");
assert!(name.len() <= 64, "a NEAR account id is at most 64 bytes");
for digest in [[0u8; 32], [0xff; 32], [0x5a; 32]] {
assert_eq!(
mainnet().shard_of(&sponsored_account(&digest, &n.sponsor)),
8
);
}
}
#[test]
fn a_shard_url_without_its_placeholder_is_refused() {
assert!(
NearNetwork::from_toml(
"shard_url = \"https://x\"\nserved_shards = []\nfallback = [\"https://y\"]\nsponsor = \"s.near\"\nsponsor_api = \"https://z\"\nbridge_rpc = \"https://w\""
)
.is_err()
);
}
}
+26
View File
@@ -0,0 +1,26 @@
# NEAR mainnet, as the wallet reaches it. NEAR is not a network the person
# switches to: it is the chain under the intents account (#71), so it has
# this file rather than a chain profile.
#
# Our nodes are public one name per shard they track (#73): a NEAR node
# answers UNAVAILABLE_SHARD for any account on a shard it does not track, so
# the wallet works out an account's shard from the layout and asks that
# shard's name. A shard we do not serve, or a name that is down, falls back
# to the public RPCs, in order.
#
# The sponsor creates each wallet's account as a sub-account of its own,
# named from the wallet's key (#81), and publishes its terms on
# blackbeard.observer. Until protocol 87's PQ implicit accounts, a NEAR
# account must be created by an existing one.
#
# An operator file `<data>/near.toml` of the same shape replaces this one.
shard_url = "https://s{shard}.near.blackbeard.observer"
served_shards = [4, 7, 8, 10, 12, 13]
fallback = ["https://rpc.mainnet.fastnear.com", "https://rpc.mainnet.near.org"]
sponsor = "blackbeard-observer.near"
# Its API: GET terms, POST accounts, POST relay.
sponsor_api = "https://blackbeard.observer/v1/near"
# The PoA bridge that carries QTC between Quantus and NEAR (#75): one
# permanent Quantus deposit address per NEAR account.
bridge_rpc = "https://bridge.chaindefuser.com/rpc"
+87
View File
@@ -328,6 +328,93 @@ pub enum SwapError {
Other(String),
}
/// NEAR, the chain under the intents account (#71). Every call names the
/// account it is about, so the adapter can send it to a node that tracks
/// that account's shard: a NEAR node knows nothing of other shards. No
/// call waits for a transaction's outcome, which no single shard node can
/// assemble; the wallet judges each step by the state it leaves.
#[async_trait]
pub trait NearRpc: Send + Sync {
/// The account, or `None` if it does not exist.
async fn account(&self, account: &str) -> Result<Option<NearAccount>, NearError>;
/// The nonce of one access key, or `None` if the account has no such
/// key. The key is named in full (`ml-dsa-65:<base58>` for ML-DSA-65):
/// a node refuses the `ml-dsa-65-hash:` handle that key listings show,
/// although the handle is all that state keeps.
async fn access_key_nonce(&self, account: &str, key: &str) -> Result<Option<u64>, NearError>;
/// A view call on `contract`, with JSON arguments and a JSON answer.
async fn view(
&self,
contract: &str,
method: &str,
args: &serde_json::Value,
) -> Result<serde_json::Value, NearError>;
/// A recent final block's hash, which a transaction must name, and its
/// height, from which a delegate action's expiry is set.
async fn final_block(&self) -> Result<([u8; 32], u64), NearError>;
/// Submit a signed transaction from `signer`, returning once it is in a
/// final block (or refused). Returns its hash.
async fn send(&self, signer: &str, signed_tx: &[u8]) -> Result<[u8; 32], NearError>;
}
/// The sponsor (#81): creates the wallet's NEAR account and relays its
/// legs as NEP-366 meta-transactions, paying the gas. Its terms are read
/// before anything is asked of it.
#[async_trait]
pub trait NearSponsor: Send + Sync {
async fn terms(&self) -> Result<SponsorTerms, NearError>;
/// Create (or find) the account for an ML-DSA-65 public key
/// (`ml-dsa-65:<base58>`). Returns its name.
async fn create_account(&self, public_key: &str) -> Result<String, NearError>;
/// Relay a signed delegate action; returns the sponsor's transaction
/// hash once it is in a final block.
async fn relay(&self, signed_delegate: &[u8]) -> Result<[u8; 32], NearError>;
}
/// The PoA bridge that carries QTC between Quantus and NEAR (#75). Each
/// NEAR account has one permanent Quantus deposit address; QTC sent there
/// is credited to the account inside Intents as `qtc.omft.near`.
#[async_trait]
pub trait QtcBridge: Send + Sync {
async fn deposit_address(&self, near_account: &str) -> Result<String, NearError>;
}
/// What the sponsor publishes about itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SponsorTerms {
pub enabled: bool,
pub account: Option<String>,
/// The swap fee in basis points of a pool swap's input.
pub fee_bps: Option<u32>,
pub fee_recipient: Option<String>,
}
/// What the wallet reads of a NEAR account: its native balance, for gas.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NearAccount {
pub balance: Amount,
pub locked: Amount,
pub storage_usage: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum NearError {
#[error("no NEAR endpoint answered: {0}")]
Unreachable(String),
/// The node answered with an error; `name` is NEAR's (`UNKNOWN_ACCOUNT`,
/// `INVALID_TRANSACTION`, …) and `detail` the rest of it.
#[error("NEAR refused it ({name}): {detail}")]
Rpc { name: String, detail: String },
#[error("unexpected answer from NEAR: {0}")]
BadAnswer(String),
}
/// A fiat on/off ramp. The wallet builds a session through the relay on our
/// infrastructure (which holds the provider secrets) and opens it in the
/// system browser; it never embeds a provider's page in the wallet's webview.
+7
View File
@@ -349,5 +349,12 @@
"symbol": "SOL",
"contractAddress": "0x0d829a23c3a3760e2fed60ca24d83cee8943f7c61859608ae0726fdb2e4f2784",
"coingeckoId": "solana"
},
{
"assetId": "nep141:qtc.omft.near",
"decimals": 12,
"blockchain": "qtc",
"symbol": "QTC",
"coingeckoId": "custom:qtc"
}
]
+4 -1
View File
@@ -14,6 +14,7 @@ wallet-core.workspace = true
thiserror.workspace = true
zeroize.workspace = true
getrandom.workspace = true
blake2.workspace = true
qp-rusty-crystals-dilithium.workspace = true
qp-rusty-crystals-hdwallet.workspace = true
qp-poseidon-core.workspace = true
@@ -33,10 +34,12 @@ reqwest.workspace = true
rusqlite.workspace = true
parity-scale-codec.workspace = true
hex.workspace = true
base64.workspace = true
bs58.workspace = true
tokio.workspace = true
async-trait.workspace = true
[dev-dependencies]
wallet-near.workspace = true
tracing-subscriber.workspace = true
tokio.workspace = true
blake2.workspace = true
+1
View File
@@ -8,6 +8,7 @@ pub mod history;
pub mod keys;
pub mod keystore;
pub mod mnemonic;
pub mod near;
pub mod ramp;
pub mod settings;
pub mod substrate;
+72
View File
@@ -0,0 +1,72 @@
//! The PoA bridge's JSON-RPC (#75): the permanent Quantus deposit address
//! of a NEAR account. The address is checked as a Quantus address before
//! anyone is told to send QTC to it.
use serde_json::json;
use wallet_core::ports::{NearError, QtcBridge};
pub struct PoaBridge {
client: reqwest::Client,
url: String,
}
impl PoaBridge {
pub fn new(url: &str) -> Self {
let client = reqwest::Client::builder()
.user_agent(concat!("blackbeard-wallet/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(20))
.build()
.expect("a TLS client can be built");
Self {
client,
url: url.to_string(),
}
}
}
#[async_trait::async_trait]
impl QtcBridge for PoaBridge {
async fn deposit_address(&self, near_account: &str) -> Result<String, NearError> {
let v: serde_json::Value = self
.client
.post(&self.url)
.json(
&json!({ "jsonrpc": "2.0", "id": 1, "method": "deposit_address",
"params": [{ "account_id": near_account, "chain": "qtc:mainnet" }] }),
)
.send()
.await
.map_err(|e| NearError::Unreachable(format!("bridge: {e}")))?
.json()
.await
.map_err(|e| NearError::BadAnswer(format!("bridge: {e}")))?;
let addr = v["result"]["address"].as_str().ok_or_else(|| {
NearError::BadAnswer(format!(
"bridge: no address in {}",
v.to_string().chars().take(200).collect::<String>()
))
})?;
match wallet_core::ss58::decode(addr) {
Ok((189, _)) => Ok(addr.to_string()),
_ => Err(NearError::BadAnswer(format!(
"bridge: {addr:?} is not a Quantus address; not using it"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "reaches bridge.chaindefuser.com (reads an address only)"]
async fn the_bridge_gives_72s_account_the_address_it_used() {
let net = wallet_core::near::NearNetwork::shipped().unwrap();
let addr = PoaBridge::new(&net.bridge_rpc)
.deposit_address("bbspike72.near")
.await
.unwrap();
assert_eq!(addr, "qzm76XyesfmAeNsDjpUfcxPwdrVKuYFXtX2XMgyaDXqTVkqfn");
}
}
+9
View File
@@ -0,0 +1,9 @@
//! NEAR, the chain under the intents account (#71): RPC routed by shard.
pub mod bridge;
pub mod rpc;
pub mod sponsor;
pub use bridge::PoaBridge;
pub use rpc::{HttpNearRpc, NEAR_DECIMALS};
pub use sponsor::HttpSponsor;
+381
View File
@@ -0,0 +1,381 @@
//! NEAR JSON-RPC over HTTPS, routed by shard (#73).
//!
//! Each call goes first to our node for the shard of the account it is
//! about (`s<shard>.near.blackbeard.observer`), then to the public
//! fallbacks. A node that answers `UNAVAILABLE_SHARD`, times out, throttles
//! or cannot be reached is skipped for the next endpoint; any other error is
//! NEAR's answer and is returned. The shard layout is read once from the
//! first endpoint that gives it, and read again when one of our own names
//! disowns a shard it should serve, which is what a reshard looks like.
//!
//! Our own names are asked only while their node is synced. A node still
//! syncing answers from whatever height it has reached, so a fresh one says
//! `wrap.near` does not exist, from genesis; its `/status` is checked (at
//! most once a minute) and a syncing or stale node is skipped like a down
//! one. The public fallbacks are taken as synced.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use base64::Engine;
use serde_json::{Value, json};
use tokio::sync::{Mutex, RwLock};
use wallet_core::near::{NearNetwork, ShardLayout};
use wallet_core::ports::{NearAccount, NearError, NearRpc};
use wallet_entities::Amount;
/// NEAR's native token has 24 decimals (yoctoNEAR).
pub const NEAR_DECIMALS: u8 = 24;
/// Errors that say "ask someone else", not "the answer is no".
const SKIP: &[&str] = &[
"UNAVAILABLE_SHARD",
"TIMEOUT_ERROR",
"NO_SYNCED_BLOCKS",
"NOT_SYNCED_YET",
];
/// How long a node's health is believed, and how old its head may be.
const HEALTH_TTL: Duration = Duration::from_secs(60);
const STALE_HEAD: chrono::TimeDelta = chrono::TimeDelta::seconds(120);
pub struct HttpNearRpc {
client: reqwest::Client,
net: NearNetwork,
layout: RwLock<Option<ShardLayout>>,
health: Mutex<HashMap<String, (Instant, bool)>>,
}
impl HttpNearRpc {
pub fn new(net: NearNetwork) -> Self {
let client = reqwest::Client::builder()
.user_agent(concat!("blackbeard-wallet/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("a TLS client can be built");
Self {
client,
net,
layout: RwLock::new(None),
health: Mutex::new(HashMap::new()),
}
}
/// Whether one of our nodes is synced and current. Public fallbacks are
/// not asked.
async fn healthy(&self, url: &str) -> bool {
if self.net.fallback.iter().any(|f| f == url) {
return true;
}
if let Some((at, ok)) = self.health.lock().await.get(url)
&& at.elapsed() < HEALTH_TTL
{
return *ok;
}
let ok = match self.client.get(format!("{url}/status")).send().await {
Ok(r) => match r.json::<Value>().await {
Ok(v) => {
let sync = &v["sync_info"];
let fresh = sync["latest_block_time"]
.as_str()
.and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
.is_some_and(|t| chrono::Utc::now().signed_duration_since(t) < STALE_HEAD);
sync["syncing"] == Value::Bool(false) && fresh
}
Err(_) => false,
},
Err(_) => false,
};
if !ok {
tracing::debug!(url, "NEAR node is syncing, stale or down; skipping it");
}
self.health
.lock()
.await
.insert(url.to_string(), (Instant::now(), ok));
ok
}
async fn layout(&self) -> Result<ShardLayout, NearError> {
if let Some(l) = self.layout.read().await.as_ref() {
return Ok(l.clone());
}
let cfg = self
.call(
&self.net.any_endpoints(),
"EXPERIMENTAL_protocol_config",
json!({ "finality": "final" }),
)
.await?;
let l = ShardLayout::from_protocol_config(&cfg["shard_layout"])
.map_err(|e| NearError::BadAnswer(e.to_string()))?;
*self.layout.write().await = Some(l.clone());
Ok(l)
}
async fn endpoints_for(&self, account: &str) -> Result<Vec<String>, NearError> {
Ok(self
.net
.endpoints_for(self.layout().await?.shard_of(account)))
}
/// A call about `account`, sent to its shard's endpoints.
async fn call_for(
&self,
account: &str,
method: &str,
params: Value,
) -> Result<Value, NearError> {
let urls = self.endpoints_for(account).await?;
self.call(&urls, method, params).await
}
/// Try each endpoint in turn; the first real answer wins.
async fn call(&self, urls: &[String], method: &str, params: Value) -> Result<Value, NearError> {
let body = json!({ "jsonrpc": "2.0", "id": "wallet", "method": method, "params": params });
let mut skipped = vec![];
for url in urls {
if !self.healthy(url).await {
skipped.push(format!("{url}: not synced"));
continue;
}
let resp = match self.client.post(url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
skipped.push(format!("{url}: {e}"));
continue;
}
};
let status = resp.status();
if status.as_u16() == 429 || status.is_server_error() && status.as_u16() != 500 {
skipped.push(format!("{url}: HTTP {status}"));
continue;
}
let v: Value = match resp.json().await {
Ok(v) => v,
Err(e) => {
skipped.push(format!("{url}: HTTP {status}, {e}"));
continue;
}
};
if let Some(err) = v.get("error") {
let name = err["cause"]["name"]
.as_str()
.or_else(|| err["name"].as_str())
.unwrap_or("UNKNOWN")
.to_string();
if SKIP.contains(&name.as_str()) {
if name == "UNAVAILABLE_SHARD" && !self.net.fallback.contains(url) {
// One of ours disowned its shard: the layout moved.
*self.layout.write().await = None;
}
skipped.push(format!("{url}: {name}"));
continue;
}
return Err(NearError::Rpc {
name,
detail: err["data"].to_string().chars().take(400).collect(),
});
}
// Some refusals arrive as a result carrying `error`: a view call
// that panicked, and (on 2.13) a missing access key.
if let Some(e) = v["result"].get("error").and_then(Value::as_str) {
let name = if e.starts_with("access key") && e.contains("does not exist") {
"UNKNOWN_ACCESS_KEY"
} else {
"VIEW_ERROR"
};
return Err(NearError::Rpc {
name: name.into(),
detail: e.chars().take(400).collect(),
});
}
return v
.get("result")
.cloned()
.ok_or_else(|| NearError::BadAnswer(format!("{url}: no result")));
}
Err(NearError::Unreachable(skipped.join("; ")))
}
}
fn yocto(v: &Value) -> Result<u128, NearError> {
v.as_str()
.and_then(|s| s.parse().ok())
.ok_or_else(|| NearError::BadAnswer(format!("not a yoctoNEAR amount: {v}")))
}
fn hash32(b58: &str) -> Result<[u8; 32], NearError> {
let v = bs58::decode(b58)
.into_vec()
.map_err(|e| NearError::BadAnswer(format!("hash {b58}: {e}")))?;
v.try_into()
.map_err(|_| NearError::BadAnswer(format!("hash {b58} is not 32 bytes")))
}
#[async_trait::async_trait]
impl NearRpc for HttpNearRpc {
async fn account(&self, account: &str) -> Result<Option<NearAccount>, NearError> {
let r = self
.call_for(
account,
"query",
json!({ "request_type": "view_account", "finality": "final", "account_id": account }),
)
.await;
match r {
Ok(v) => Ok(Some(NearAccount {
balance: Amount::new(yocto(&v["amount"])?, NEAR_DECIMALS),
locked: Amount::new(yocto(&v["locked"])?, NEAR_DECIMALS),
storage_usage: v["storage_usage"].as_u64().unwrap_or(0),
})),
Err(NearError::Rpc { name, .. }) if name == "UNKNOWN_ACCOUNT" => Ok(None),
Err(e) => Err(e),
}
}
async fn access_key_nonce(&self, account: &str, key: &str) -> Result<Option<u64>, NearError> {
let r = self
.call_for(
account,
"query",
json!({ "request_type": "view_access_key", "finality": "final",
"account_id": account, "public_key": key }),
)
.await;
match r {
Ok(v) => v["nonce"]
.as_u64()
.map(Some)
.ok_or_else(|| NearError::BadAnswer(format!("access key without a nonce: {v}"))),
Err(NearError::Rpc { name, .. })
if name == "UNKNOWN_ACCESS_KEY" || name == "UNKNOWN_ACCOUNT" =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
async fn view(&self, contract: &str, method: &str, args: &Value) -> Result<Value, NearError> {
let args = base64::engine::general_purpose::STANDARD.encode(args.to_string());
let v = self
.call_for(
contract,
"query",
json!({ "request_type": "call_function", "finality": "final",
"account_id": contract, "method_name": method, "args_base64": args }),
)
.await?;
let bytes: Vec<u8> = serde_json::from_value(v["result"].clone())
.map_err(|e| NearError::BadAnswer(format!("{contract}.{method}: {e}")))?;
serde_json::from_slice(&bytes).map_err(|e| {
NearError::BadAnswer(format!("{contract}.{method} returned non-JSON: {e}"))
})
}
async fn final_block(&self) -> Result<([u8; 32], u64), NearError> {
let v = self
.call(
&self.net.any_endpoints(),
"block",
json!({ "finality": "final" }),
)
.await?;
let hash = hash32(
v["header"]["hash"]
.as_str()
.ok_or_else(|| NearError::BadAnswer("block without a hash".into()))?,
)?;
let height = v["header"]["height"]
.as_u64()
.ok_or_else(|| NearError::BadAnswer("block without a height".into()))?;
Ok((hash, height))
}
async fn send(&self, signer: &str, signed_tx: &[u8]) -> Result<[u8; 32], NearError> {
let tx = base64::engine::general_purpose::STANDARD.encode(signed_tx);
let v = self
.call_for(
signer,
"send_tx",
json!({ "signed_tx_base64": tx, "wait_until": "INCLUDED_FINAL" }),
)
.await?;
hash32(
v["transaction"]["hash"]
.as_str()
.ok_or_else(|| NearError::BadAnswer(format!("send_tx without a hash: {v}")))?,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rpc() -> HttpNearRpc {
HttpNearRpc::new(NearNetwork::shipped().unwrap())
}
#[tokio::test]
#[ignore = "reaches s<shard>.near.blackbeard.observer and public NEAR RPCs"]
async fn reads_accounts_on_several_shards() {
let r = rpc();
for a in [
"intents.near",
"dclv2.ref-labs.near",
"qtc.omft.near",
"wrap.near",
] {
let acct = r
.account(a)
.await
.unwrap()
.unwrap_or_else(|| panic!("{a} exists"));
assert_eq!(acct.balance.decimals, NEAR_DECIMALS);
}
assert!(
r.account("no-such-account-blackbeard.near")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "reaches s<shard>.near.blackbeard.observer and public NEAR RPCs"]
async fn an_ml_dsa_key_is_found_by_its_full_key() {
// #72's account and its only key, named in full: the node refuses
// the `ml-dsa-65-hash:` handle a key listing shows.
let key = include_str!("../../tests/fixtures/near/bbspike72-ml-dsa-65.pub").trim();
let n = rpc().access_key_nonce("bbspike72.near", key).await.unwrap();
assert!(n.unwrap() > 0);
assert_eq!(
rpc()
.access_key_nonce(
"bbspike72.near",
"ed25519:5VvDUBUnynzbgqdKWUw3idMA23h3gw5N5pmZr7QM6jvf"
)
.await
.unwrap(),
None,
"the ed25519 key was deleted in #72"
);
}
#[tokio::test]
#[ignore = "reaches s<shard>.near.blackbeard.observer and public NEAR RPCs"]
async fn a_view_call_answers_json() {
let v = rpc()
.view(
"intents.near",
"mt_batch_balance_of",
&json!({ "account_id": "bbspike72.near", "token_ids": ["nep141:wrap.near"] }),
)
.await
.unwrap();
assert!(v.as_array().is_some_and(|a| a.len() == 1));
assert!(rpc().final_block().await.unwrap().1 > 219_000_000);
}
}
+145
View File
@@ -0,0 +1,145 @@
//! The sponsor's API on blackbeard.observer (#81): its terms, account
//! creation, and relaying signed delegate actions. Errors keep the
//! sponsor's own words, which say why a request was refused.
use base64::Engine;
use serde::Deserialize;
use serde_json::json;
use wallet_core::ports::{NearError, NearSponsor, SponsorTerms};
pub struct HttpSponsor {
client: reqwest::Client,
base: String,
}
impl HttpSponsor {
pub fn new(base: &str) -> Self {
let client = reqwest::Client::builder()
.user_agent(concat!("blackbeard-wallet/", env!("CARGO_PKG_VERSION")))
// A relay returns once its transaction is in a final block.
.timeout(std::time::Duration::from_secs(60))
.build()
.expect("a TLS client can be built");
Self {
client,
base: base.trim_end_matches('/').to_string(),
}
}
async fn answer<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
) -> Result<T, NearError> {
let status = resp.status();
if status.is_success() {
return resp
.json()
.await
.map_err(|e| NearError::BadAnswer(format!("sponsor: {e}")));
}
#[derive(Deserialize)]
struct ApiError {
code: String,
message: String,
}
match resp.json::<ApiError>().await {
Ok(e) => Err(NearError::Rpc {
name: format!("sponsor {}", e.code),
detail: e.message,
}),
Err(_) => Err(NearError::Unreachable(format!("sponsor: HTTP {status}"))),
}
}
}
#[derive(Deserialize)]
struct Terms {
enabled: bool,
account: Option<String>,
fee_bps: Option<u32>,
fee_recipient: Option<String>,
}
#[async_trait::async_trait]
impl NearSponsor for HttpSponsor {
async fn terms(&self) -> Result<SponsorTerms, NearError> {
let resp = self
.client
.get(format!("{}/sponsor", self.base))
.send()
.await
.map_err(|e| NearError::Unreachable(format!("sponsor: {e}")))?;
let t: Terms = Self::answer(resp).await?;
Ok(SponsorTerms {
enabled: t.enabled,
account: t.account,
fee_bps: t.fee_bps,
fee_recipient: t.fee_recipient,
})
}
async fn create_account(&self, public_key: &str) -> Result<String, NearError> {
#[derive(Deserialize)]
struct Created {
account: String,
}
let resp = self
.client
.post(format!("{}/accounts", self.base))
.json(&json!({ "public_key": public_key }))
.send()
.await
.map_err(|e| NearError::Unreachable(format!("sponsor: {e}")))?;
Ok(Self::answer::<Created>(resp).await?.account)
}
async fn relay(&self, signed_delegate: &[u8]) -> Result<[u8; 32], NearError> {
#[derive(Deserialize)]
struct Relayed {
tx: String,
}
let resp = self
.client
.post(format!("{}/relay", self.base))
.json(&json!({
"signed_delegate": base64::engine::general_purpose::STANDARD.encode(signed_delegate)
}))
.send()
.await
.map_err(|e| NearError::Unreachable(format!("sponsor: {e}")))?;
let tx = Self::answer::<Relayed>(resp).await?.tx;
bs58::decode(&tx)
.into_vec()
.ok()
.and_then(|v| v.try_into().ok())
.ok_or_else(|| {
NearError::BadAnswer(format!("sponsor: {tx:?} is not a transaction hash"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
#[ignore = "reaches blackbeard.observer (reads the terms only)"]
async fn the_live_sponsor_publishes_terms_the_wallet_accepts() {
let net = wallet_core::near::NearNetwork::shipped().unwrap();
let t = HttpSponsor::new(&net.sponsor_api).terms().await.unwrap();
wallet_core::near::usable_terms(&t, &net.sponsor).unwrap();
}
#[tokio::test]
#[ignore = "reaches blackbeard.observer (a refused request; creates nothing)"]
async fn a_malformed_key_is_refused_in_the_sponsors_words() {
let net = wallet_core::near::NearNetwork::shipped().unwrap();
let e = HttpSponsor::new(&net.sponsor_api)
.create_account("ml-dsa-65:notakey")
.await
.unwrap_err();
assert!(
matches!(e, NearError::Rpc { ref name, .. } if name == "sponsor refused"),
"{e}"
);
}
}
+5
View File
@@ -85,6 +85,11 @@ mod tests {
account_names: [("qz1".to_string(), "savings".to_string())].into(),
preview_swaps: true,
wallet_accounts: [("w".to_string(), vec![1, 2])].into(),
intents_account: Some(wallet_entities::IntentsAccountRecord {
wallet: "w".into(),
account_id: "bx.blackbeard-observer.near".into(),
public_key: "ml-dsa-65:x".into(),
}),
};
store.save(&changed).unwrap();
assert_eq!(store.load().unwrap(), changed);
@@ -100,6 +100,42 @@ pub async fn storage_at_best(
Ok((conn.client.storage().at(best_hash), at_block))
}
/// Which of `accounts` exist on chain, in one request: a `System::Account`
/// entry is present for an account that has ever been funded, and absent
/// for one that has not (on Substrate an empty account is not an account
/// with zero, it is no account at all). Used to find the account numbers a
/// restored phrase already uses.
pub async fn which_exist(
conn: &ChainConnection,
accounts: &[[u8; 32]],
) -> Result<Vec<bool>, ChainError> {
if accounts.is_empty() {
return Ok(Vec::new());
}
let keys: Vec<Vec<u8>> = accounts
.iter()
.map(|a| {
let addr = subxt::dynamic::storage("System", "Account", vec![Value::from_bytes(*a)]);
conn.client
.storage()
.address_bytes(&addr)
.map_err(|e| ChainError::Other(e.to_string()))
})
.collect::<Result<_, _>>()?;
let sets = conn
.rpc
.state_query_storage_at(keys.iter().map(Vec::as_slice), None)
.await
.map_err(rpc_err)?;
let present: std::collections::HashSet<Vec<u8>> = sets
.iter()
.flat_map(|s| s.changes.iter())
.filter(|(_, v)| v.is_some())
.map(|(k, _)| k.0.clone())
.collect();
Ok(keys.iter().map(|k| present.contains(k)).collect())
}
pub async fn account_info(
conn: &ChainConnection,
account: [u8; 32],
+288 -48
View File
@@ -6,8 +6,20 @@
//! asset_id, amount}`, in the clear. Nothing on chain indexes leaves by
//! recipient, so the wallet scans the map once from index 0 to
//! `LeafCount`, keeps what pays its address, and afterwards scans only what
//! was appended. A leaf is spent when its nullifier, which only the holder
//! of the secret can compute, is in `Wormhole::UsedNullifiers`.
//! was appended. Reading every index is also what keeps the node from
//! learning which leaves are the wallet's.
//!
//! A leaf is spent when its nullifier, which only the holder of the secret
//! can compute, is in `Wormhole::UsedNullifiers`. **A nullifier never leaves
//! the client** (blackbeard/wallet #68). The map is `Blake2_128Concat`, so a
//! key is `blake2_128(n) ‖ n`: looking one up hands the node the nullifier,
//! and an exit publishes its nullifier on chain, so the node could name the
//! exit and the wallet behind it. Instead the wallet reads whole buckets of
//! the map (keys sharing the first byte of `blake2_128(n)`), pads the buckets
//! it needs with random others to at least [`MIN_NULLIFIER_BUCKETS`], asks
//! in shuffled order, and checks membership here. The node learns which
//! buckets were read, never which entry mattered. The same rule as
//! quantus/extension's `NULLIFIER_BUCKETS_MIN`.
use subxt::dynamic;
use subxt::ext::scale_value::{self, At, Value};
@@ -29,6 +41,40 @@ pub struct Leaf {
/// (Identity hasher over a u64), so a page is one `state_queryStorageAt`.
pub const LEAF_PAGE: u64 = 500;
/// The pause between bulk pages. A Quantus node limits each connection
/// (ours: `--rpc-rate-limit 300`, five calls a second), and the wallet
/// keeps one connection per chain, so a scan at full speed starves the
/// balance and transfer reads sharing it. Three pages a second leaves them
/// room. A first scan of mainnet's tree is about 950 pages.
pub const PAGE_PACE: std::time::Duration = std::time::Duration::from_millis(330);
/// Retry `call` while the node says it is over its rate limit (JSON-RPC
/// error -32999), backing off 1, 2, 4, 8 and 16 seconds; any other error,
/// or a sixth refusal, is returned.
pub async fn under_rate_limit<T, F, Fut>(mut call: F) -> Result<T, ChainError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, ChainError>>,
{
let mut wait = std::time::Duration::from_secs(1);
for _ in 0..5 {
match call().await {
Err(e) if is_rate_limited(&e) => {
tracing::debug!(?wait, "node rate limit; backing off");
tokio::time::sleep(wait).await;
wait *= 2;
}
other => return other,
}
}
call().await
}
fn is_rate_limited(e: &ChainError) -> bool {
let t = e.to_string();
t.contains("-32999") || t.to_ascii_lowercase().contains("rate limit")
}
/// `ZkTree::LeafCount` at the best block.
pub async fn leaf_count(conn: &ChainConnection) -> Result<u64, ChainError> {
let addr = dynamic::storage("ZkTree", "LeafCount", Vec::<Value>::new());
@@ -48,16 +94,22 @@ pub async fn leaf_count(conn: &ChainConnection) -> Result<u64, ChainError> {
.ok_or_else(|| ChainError::Other("LeafCount is not a number".into()))
}
/// The leaves in `[from, to)` that pay `account`, read in pages.
/// The leaves in `[from, to)` that pay any of `accounts`, read in pages,
/// each tagged with the index of the account it pays. One pass serves every
/// open wallet: the tree is public and the same for all of them, so reading
/// it once per wallet only multiplies the calls.
pub async fn leaves_paying(
conn: &ChainConnection,
account: &[u8; 32],
accounts: &[[u8; 32]],
from: u64,
to: u64,
) -> Result<Vec<Leaf>, ChainError> {
) -> Result<Vec<(usize, Leaf)>, ChainError> {
let mut out = Vec::new();
let mut start = from;
while start < to {
if start > from {
tokio::time::sleep(PAGE_PACE).await;
}
let end = (start + LEAF_PAGE).min(to);
let keys: Vec<Vec<u8>> = (start..end)
.map(|i| {
@@ -68,11 +120,13 @@ pub async fn leaves_paying(
.map_err(|e| ChainError::Other(e.to_string()))
})
.collect::<Result<_, _>>()?;
let sets = conn
.rpc
.state_query_storage_at(keys.iter().map(Vec::as_slice), None)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?;
let sets = under_rate_limit(|| async {
conn.rpc
.state_query_storage_at(keys.iter().map(Vec::as_slice), None)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))
})
.await?;
let leaf_ty = leaf_type_id(conn)?;
for set in sets {
for (key, data) in set.changes {
@@ -89,9 +143,9 @@ pub async fn leaves_paying(
.at("to")
.and_then(as_account_bytes)
.ok_or_else(|| ChainError::Other(format!("leaf {index}: no recipient")))?;
if &to != account {
let Some(which) = accounts.iter().position(|a| *a == to) else {
continue;
}
};
let transfer_count = value
.at("transfer_count")
.and_then(|v| v.as_u128())
@@ -101,47 +155,135 @@ pub async fn leaves_paying(
.at("amount")
.and_then(|v| v.as_u128())
.ok_or_else(|| ChainError::Other(format!("leaf {index}: no amount")))?;
out.push(Leaf {
index,
transfer_count,
amount,
});
out.push((
which,
Leaf {
index,
transfer_count,
amount,
},
));
}
}
start = end;
}
out.sort_by_key(|l| l.index);
out.sort_by_key(|(_, l)| l.index);
Ok(out)
}
/// Whether a nullifier is in `Wormhole::UsedNullifiers`: the leaf it
/// belongs to has been spent.
pub async fn nullifier_spent(
/// Buckets of `Wormhole::UsedNullifiers` read per check, at least: one
/// deposit hides among a sixteenth of all spends rather than a 256th.
pub const MIN_NULLIFIER_BUCKETS: usize = 16;
/// Keys per `state_getKeysPaged` page.
const KEYS_PAGE: u32 = 1000;
/// The bucket a nullifier's `UsedNullifiers` key falls in: the first byte of
/// `blake2_128(n)`, the hash the map is keyed with.
pub fn bucket_of(nullifier: &[u8; 32]) -> u8 {
use blake2::digest::{Update, VariableOutput};
let mut h = blake2::Blake2bVar::new(16).expect("16 is a valid blake2b output size");
h.update(nullifier);
let mut out = [0u8; 16];
h.finalize_variable(&mut out)
.expect("the buffer is the output size");
out[0]
}
/// The buckets to read for `ours`: every bucket one of them falls in, padded
/// with random others to at least [`MIN_NULLIFIER_BUCKETS`], in shuffled
/// order so the ones that matter do not come first. `random` supplies
/// uniform bytes; pure, so the rule is testable.
pub fn plan_buckets(ours: &[[u8; 32]], mut random: impl FnMut() -> u8) -> Vec<u8> {
let mut set: Vec<u8> = Vec::with_capacity(MIN_NULLIFIER_BUCKETS);
for n in ours {
let b = bucket_of(n);
if !set.contains(&b) {
set.push(b);
}
}
while set.len() < MIN_NULLIFIER_BUCKETS {
let b = random();
if !set.contains(&b) {
set.push(b);
}
}
// Fisher-Yates with rejection sampling, so the order is unbiased.
for i in (1..set.len()).rev() {
let bound = (i + 1) as u16;
let j = loop {
let r = u16::from(random());
if r < 256 - (256 % bound) {
break (r % bound) as usize;
}
};
set.swap(i, j);
}
set
}
/// The storage prefix of one bucket: the map's root plus the bucket byte.
pub fn bucket_prefix(root: &[u8], bucket: u8) -> Vec<u8> {
let mut p = Vec::with_capacity(root.len() + 1);
p.extend_from_slice(root);
p.push(bucket);
p
}
/// The nullifier at the end of a `UsedNullifiers` key.
fn nullifier_from_key(root_len: usize, key: &[u8]) -> Option<[u8; 32]> {
if key.len() != root_len + 16 + 32 {
return None;
}
key[root_len + 16..].try_into().ok()
}
/// A uniformly random byte from the operating system.
fn os_random_byte() -> u8 {
let mut b = [0u8; 1];
getrandom::fill(&mut b).expect("the operating system has randomness");
b[0]
}
/// For each of `ours`, whether it is in `Wormhole::UsedNullifiers`, decided
/// here from whole buckets read at the node's best block. No request carries
/// a nullifier: each is a bucket prefix, the map's root and one byte.
pub async fn spent_among(
conn: &ChainConnection,
nullifier: &[u8; 32],
) -> Result<bool, ChainError> {
let addr = dynamic::storage(
ours: &[[u8; 32]],
) -> Result<Vec<bool>, ChainError> {
if ours.is_empty() {
return Ok(Vec::new());
}
let root = conn.client.storage().address_root_bytes(&dynamic::storage(
"Wormhole",
"UsedNullifiers",
vec![Value::from_bytes(nullifier)],
);
let v = conn
.client
.storage()
.at_latest()
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?
.fetch(&addr)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))?;
Ok(match v {
None => false,
Some(v) => v
.to_value()
.map_err(|e| ChainError::Other(e.to_string()))?
.as_bool()
.unwrap_or(true),
})
Vec::<Value>::new(),
));
let mut used = std::collections::HashSet::new();
for bucket in plan_buckets(ours, os_random_byte) {
let prefix = bucket_prefix(&root, bucket);
let mut start: Option<Vec<u8>> = None;
loop {
let keys = under_rate_limit(|| async {
conn.rpc
.state_get_keys_paged(&prefix, KEYS_PAGE, start.as_deref(), None)
.await
.map_err(|e| ChainError::Rpc(e.to_string()))
})
.await?;
for key in &keys {
if let Some(n) = nullifier_from_key(root.len(), key) {
used.insert(n);
}
}
if (keys.len() as u32) < KEYS_PAGE {
break;
}
start = keys.last().cloned();
}
}
Ok(ours.iter().map(|n| used.contains(n)).collect())
}
/// The shielded balance: unspent leaves summed, as base units.
@@ -176,6 +318,73 @@ fn leaf_index_from_key(key: &[u8]) -> Option<u64> {
mod tests {
use super::*;
fn nullifiers(k: u8) -> Vec<[u8; 32]> {
(0..k)
.map(|i| [i.wrapping_mul(37).wrapping_add(5); 32])
.collect()
}
#[test]
fn buckets_cover_ours_are_padded_to_sixteen_and_shuffled() {
let ours = nullifiers(3);
let mut counter = 0u8;
let plan = plan_buckets(&ours, || {
counter = counter.wrapping_add(97);
counter
});
assert!(plan.len() >= MIN_NULLIFIER_BUCKETS);
let mut dedup = plan.clone();
dedup.sort_unstable();
dedup.dedup();
assert_eq!(dedup.len(), plan.len(), "no bucket twice");
for n in &ours {
assert!(plan.contains(&bucket_of(n)));
}
// More buckets of ours than the minimum are all read, with no padding.
let many = nullifiers(60);
let needed: std::collections::HashSet<u8> = many.iter().map(bucket_of).collect();
let plan = plan_buckets(&many, os_random_byte);
assert_eq!(plan.len(), needed.len().max(MIN_NULLIFIER_BUCKETS));
// Over many plans ours do not always come first.
let first_is_ours = (0..64)
.filter(|_| {
let plan = plan_buckets(&ours[..1], os_random_byte);
plan[0] == bucket_of(&ours[0])
})
.count();
assert!(
first_is_ours < 32,
"{first_is_ours} of 64 plans led with ours"
);
}
/// The regression guard for #68: nothing the spent check asks for
/// contains one of our nullifiers, and every request is a bucket prefix.
#[test]
fn no_request_carries_a_nullifier() {
let root = [0xabu8; 32];
let ours = nullifiers(5);
for bucket in plan_buckets(&ours, os_random_byte) {
let prefix = bucket_prefix(&root, bucket);
assert_eq!(prefix.len(), root.len() + 1);
for n in &ours {
assert!(
!prefix.windows(32).any(|w| w == n),
"a request carries a nullifier"
);
}
}
// And the map's key layout is what the parser expects.
let n = ours[0];
let mut key = root.to_vec();
let mut h = [0u8; 16];
h[0] = bucket_of(&n);
key.extend_from_slice(&h);
key.extend_from_slice(&n);
assert_eq!(nullifier_from_key(root.len(), &key), Some(n));
assert_eq!(nullifier_from_key(root.len(), &key[..70]), None);
}
#[test]
fn the_leaf_index_is_the_last_eight_bytes_of_the_key() {
let mut key = vec![0xaa; 32];
@@ -204,14 +413,45 @@ mod tests {
assert!(count > 91928, "leaf count {count}");
let (_, bob) =
wallet_core::ss58::decode("qzp2AxZw8szXc5qDe1aU8P5kt1dkf1JFYktvV7N6cVbFZrs8L").unwrap();
let leaves = leaves_paying(&conn, &bob, 91900, 91930).await.unwrap();
let leaves: Vec<Leaf> = leaves_paying(&conn, &[bob], 91900, 91930)
.await
.unwrap()
.into_iter()
.map(|(_, l)| l)
.collect();
let l = leaves
.iter()
.find(|l| l.index == 91928)
.expect("leaf 91928 pays bob");
assert_eq!((l.transfer_count, l.amount), (30, 310_000_000_000));
// A nullifier nobody has used.
assert!(!nullifier_spent(&conn, &[7u8; 32]).await.unwrap());
// The bucket read agrees with the chain: a nullifier taken from a
// bucket is spent, one nobody has used is not, and the chain's own
// key hash puts the real one in the bucket it was read from.
let root = conn.client.storage().address_root_bytes(&dynamic::storage(
"Wormhole",
"UsedNullifiers",
Vec::<Value>::new(),
));
let mut real = None;
for bucket in 0..=255u8 {
let keys = conn
.rpc
.state_get_keys_paged(&bucket_prefix(&root, bucket), 1, None, None)
.await
.unwrap();
if let Some(n) = keys.first().and_then(|k| nullifier_from_key(root.len(), k)) {
assert_eq!(bucket_of(&n), bucket, "blake2_128(n)[0] is the bucket");
real = Some(n);
break;
}
}
match real {
Some(n) => assert_eq!(
spent_among(&conn, &[n, [7u8; 32]]).await.unwrap(),
[true, false]
),
None => eprintln!("no exit has spent a nullifier on this chain yet"),
}
eprintln!("leaf count {count}; bob's leaves in the window: {leaves:?}");
// The whole tree: every reward bob's miner earned is a leaf, so the
@@ -220,8 +460,8 @@ mod tests {
// 2026-09-16 was 9330000000000 over 30 rewards) plus anything sent
// to the address since. Timed, since it is what the first open pays.
let t0 = std::time::Instant::now();
let all = leaves_paying(&conn, &bob, 0, count).await.unwrap();
let total: u128 = all.iter().map(|l| l.amount).sum();
let all = leaves_paying(&conn, &[bob], 0, count).await.unwrap();
let total: u128 = all.iter().map(|(_, l)| l.amount).sum();
eprintln!(
"full scan of {count} leaves in {:?}: {} leaves pay bob, {} base units",
t0.elapsed(),
+15 -2
View File
@@ -168,6 +168,7 @@ impl OneClick {
deadline: deadline.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
referral: Some(REFERRAL.to_owned()),
quote_waiting_time_ms: Some(3_000),
app_fees: vec![],
})
}
@@ -315,6 +316,17 @@ pub struct QuoteRequest {
pub referral: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quote_waiting_time_ms: Option<u32>,
/// Fees paid to Intents accounts, in basis points of `amountIn` (#82).
/// Without a partner key 1Click passes on half of each and adds its
/// own 20 bps (measured 2026-10-09).
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub app_fees: Vec<AppFee>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AppFee {
pub recipient: String,
pub fee: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -514,7 +526,7 @@ mod tests {
let refused = p
.quote_request(
&SwapRequest {
from: asset("quantus", AssetKind::Native),
from: asset("planck", AssetKind::Native),
to: usdc_near(),
amount_in: Amount::new(1, 12),
recipient: "x".into(),
@@ -525,7 +537,7 @@ mod tests {
)
.unwrap_err();
assert!(
matches!(refused, SwapError::UnsupportedPair(ref a) if a == "quantus/native"),
matches!(refused, SwapError::UnsupportedPair(ref a) if a == "planck/native"),
"{refused}"
);
}
@@ -592,6 +604,7 @@ mod tests {
deadline,
referral: Some(REFERRAL.into()),
quote_waiting_time_ms: Some(3_000),
app_fees: vec![],
};
let resp = p.quote_raw(&req).await.unwrap();
let out: u128 = resp.quote.amount_out.parse().unwrap();
@@ -0,0 +1 @@
ml-dsa-65:AVvvkViAPmgq82tpUwKhJuFRNzEm2KHyxYcoaN5z1x9PyhRDbu32FUvvTMKdGC73gNqF63hnpV7mfkdhm7aKnm6U8fzGpeZZxMkSGdgFQ8quT3kodGmX2DNBuMQpfsmgWDF971AQZT3DoTxLcW57iAvS72HoAUXutVkz5raErZwsze2i7cbJ7dgEWVZnkJAKzJDXPvexYXMDwWH6r5vnK1pHsHYby1AuPuUZrfTEwvHqXCVC3ja5CXdZ7KLKqdKz1jcmv96y75uUhfKhemjVNCmwr23tQc4sqGf4fnW2oYiJT2h4K3MNw1C9F3fQKDnUAFSP8c86zSzRufGdwFj99uXzqg9ghUfSwSrSTkRWpQNPGf5ELgxXTRHauuXUS7R4S2o3LnqQhT29fZU9dy6BXKMU5pTSJ5f5jwkWj17Qe2Jh6JwnrV7DPevBP9oEkihXjeNv9iEDDtc5nEGX7frErynEmGvGuP71QEDNvJ1un2nCy1BExLvgNkvjgC2X8pp4eDEPzUjr73owKUMgPzm7qZUp6MTgNEv77GnAuYTZSCDsbDN52F1R5zmL4vbThWfcXdjGo6WD72iPoAeJEV8YD5q2haCMAEr7xGBws5vTVNieGJgYovm6YFYbnJChymbsak68jfcV8VfdX4szfCKL415cfN8BQ2h32XWcFo2X4cSUw28iZivpueuYXxeuDvNTbEQxt8tDhdn4yNwsjWijHspzHWU4mRKqghy4RawBsNY5aHBKGPQ85rFL8PvGDy5QA5o2TJ9YdC6dGhzZ5DNwjtLRQ4h3GLf2X79YAjRVw8477R2VjLYFyBeZtW7xbvpoChmPRKaMM4A3LFdPCgAJYHxNYNyyTHxw7vzDWPpLZZYzRZNkkWPbomADWja24kaAbWm6mRr1C5jzgzfVuFg3bW3TZ2Yuk3DUiyVQmcnwnKfc4HXsA4ddq2kcSxWQMSQtYtT6WNaScbNuznMz9nBHR35Mne2V3JVbaoTViLUCPTw2PUoQ3z54Y28CjGVnxPyK7FZkjLZbahk5fNkwqrUjpgqg772ESjNXue4E1BNxt1kGHankxM5RNPHeXEyrBPAMnpB8b6jDnXDgkkRZLjSRkoFfHGyXyu1sGbjzf8HxWULbS1TF8H34GytskEt3p2Q25SvPVn9GbUK21GsixnLrZy8G6bD1VqCCp1f29BrhgH2mLzKe8Rr1nGZaa7PN16C3tpHJMi48dzWeJ41TKq1XhEocjKGkbuKia4ape66b25ezyPCXn7dSYhmffYSZ4SVfyAae4hFVCeQCDRgN8rdh6gDLn6LW5pXPEiaRuFutVF4K6utosnTfRAgD3GCyzpSha8p2atA7THc35djCwJUxnBz9Q7ReSmE3MPfJQze964eunDAAvMV1YTuUTh15MCsNfmewehFaYtzKTpQeW72Rij2fiUk9rhnaujSsenJqQNsjvT2s6VoQ8tzdLuAAhydWrfua3AwDqYx6w7CvG27JCbm8j4bHJDT6NY7hfBnMLFRhQqhEuKgYExRDBpMTa78nyx43TY2PeGoqHvMHg9Bie6J5AXGm2H1VA2yUCEL2C41qWbpYwJYneiKbxQhQiYnPhokoXcWQGnpaWDXqp16xmGi5PcXfkhvh5zYfA8kdhGHLEMAHKSVbLQAz7bnkqNGJymSeLiU4kfsW6BEh3vW5jNRMiGBR43WRhhg7rKzB4EZiDdiX5tnGZ2JHR8vEJKZicNrpddkFzKmXMRdhai9NJsMSPUVBhdqg16hvSy2DPxoHQL2mt1fV9hnkHWVcFfiwZXB95S3rLiQ9zaFY8kv9qjZQkj2Y6MLj74crP7YwST4ejQTXQ1boTxA4S1wC6pz9Wm6wMsDLReGBK762Rud5ctarJnWWvQ5eFgsb2eu2FQB1sVYEUM61kgdSK91poQ7hKnEZW5aKvPRAe97BmZC9dxcYqQmpWKeP3crGZGAJaodPzWRXp6TEoWe4zhxmfJmsZVpMayRxz3vXDsAnN9r7EpNoXwvfQGPLTDM7xrSBsssriusdLFYahiVhDDsg6iJ94ys25iD38rN7gqMzcgVrG3emj9b8BATLzmV5eLFwaiFB6zVNGQEywu8gzTdJzyo5mn7gxPzyC7kca7r5BV5tD2LGRJh4oyR3cbq8z85zpoe8az4c5Dr8yiCCoZbZ6bwMVWrD8dwBpN6zLEdGSjz6Lgi39hakfq4yZskGtVg9HJBz1vg1s5xQyJuVcndesW1Qw7R1mJjjUCRxUEmjEBUncf35YdoSygtxXBVJfGUUkdueBbEuPEpSczMCjAFUw2ybcLovdYhZwRxoqJ8zgsy4D4JzqdNAn1TBdRE4UCPq37mj7ZQWjo17EeN3dCDduciXbEzjT3tNqJgeS38eqD4sT7RszHC75SBhnYN6J6khAYHL6AKv2xgdqq8J8tfhg5dNxPNZdxFJ5cxpsJT3MtgxVnaZwzvQf1Y3nsgCDofDD4tkLrv9FQXtyPtzNd1QCRz6XVyT9KY4MkZ8fxoBDgR8UR14xp3pC7oTQUnU7qeFh9tkYaQPGqDuSARCPBsvzr59Y4gryu3Ut1MZBLptWdkeA19CGGzrLX69KQ73pGRMSqSEqxuRw2wXQDmFsNS1MVdwvwVmBaXtojVax77Z9Da8JYiYoomY2ahwrYuQP5hmHt
@@ -21,6 +21,7 @@ use wallet_data::keystore::FileKeystore;
use wallet_entities::{
Amount, ChainId, Direction, HistoryEntry, HistoryKind, HistorySource, SignatureScheme,
};
use wallet_near::{NEAR_DEFAULT_PATH, NearKey};
const PHRASE: &str = "bottom drive obey lake curtain smoke basket hold race lonely fit walk";
@@ -176,6 +177,18 @@ fn the_flow_leaves_no_secret_in_logs_files_or_debug_output() {
.unwrap()
.unwrap();
tracing::trace!(?keys, "wormhole");
let near = session
.with_seed("audit", |s| {
NearKey::derive(s.as_bytes(), NEAR_DEFAULT_PATH)
})
.unwrap()
.unwrap()
.unwrap();
tracing::trace!(
?near,
sig_len = near.sign(b"tx hash").unwrap().len(),
"near"
);
let cache = SqliteHistoryCache::open(&data.join("history.sqlite")).unwrap();
let chain = ChainId("quantus".into());
@@ -213,7 +226,7 @@ fn the_flow_leaves_no_secret_in_logs_files_or_debug_output() {
drop(cache);
// Debug output of every secret-holding type, as a caller might log it.
let debug = format!("{signer:?}\n{keys:?}\n{session:?}\n{wormhole:?}");
let debug = format!("{signer:?}\n{keys:?}\n{session:?}\n{wormhole:?}\n{near:?}");
assert_clean("Debug output", debug.as_bytes(), &forbidden);
assert!(debug.contains("redacted"), "{debug}");
+110
View File
@@ -473,6 +473,22 @@ pub struct Settings {
/// every unlock. Kept here because the keystore file is quantus-cli's
/// and holds one keypair.
pub wallet_accounts: BTreeMap<String, Vec<u32>>,
/// The installation's one NEAR Intents account (#74, #81): every swap
/// routes through it, whichever wallet's QTC goes in or out. Recorded
/// when it is created or first found; not secret.
pub intents_account: Option<IntentsAccountRecord>,
}
/// Which intents account this installation uses, and which wallet's
/// phrase controls it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct IntentsAccountRecord {
/// The wallet whose phrase derives the account's key.
pub wallet: String,
pub account_id: String,
/// `ml-dsa-65:<base58>`.
pub public_key: String,
}
impl Default for Settings {
@@ -484,6 +500,7 @@ impl Default for Settings {
account_names: BTreeMap::new(),
preview_swaps: false,
wallet_accounts: BTreeMap::new(),
intents_account: None,
}
}
}
@@ -706,6 +723,88 @@ pub struct WormholeSummary {
pub leaf_count: u64,
}
/// The open wallet's NEAR Intents account (blackbeard/wallet #74): the
/// NEAR account its phrase's ML-DSA-65 key (NEP-649) controls, named by the
/// sponsor from that key (#81), and what it holds. No secret: the key is
/// public, and the account is derived again from the phrase on every look.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct NearAccountSummary {
/// The wallet whose phrase controls the account (or would, once made).
pub wallet: String,
/// Whether that wallet is open with its phrase, so the account can sign.
pub owner_open: bool,
/// Open wallets with a phrase that could own it, when none does yet.
pub candidates: Vec<String>,
/// `b<…>.blackbeard-observer.near`, whether or not it exists yet.
pub account_id: String,
/// `ml-dsa-65:<base58>`: the key that controls it.
pub public_key: String,
/// The NEP-649 path the key is derived at.
pub derivation_path: String,
/// Whether the account exists on NEAR.
pub exists: bool,
/// Whether this wallet's key is one of the account's access keys. False
/// for an existing account means it is not this wallet's to use.
pub key_on_account: bool,
/// Native NEAR on the account. The sponsor pays its gas (#81), so this
/// is only the storage the sponsor deposited; `None` until it exists.
pub gas: Option<Amount>,
/// Whether the sponsor will create it now: it does not exist, and the
/// sponsor is running on terms the wallet accepts.
pub can_create: bool,
/// Why it cannot be created, when it does not exist and cannot be.
pub sponsor_note: Option<String>,
/// What the account holds inside NEAR Intents, by the assets the wallet
/// knows; nonzero balances only.
pub intents: Vec<IntentsBalance>,
}
/// A swap inside the intents account, priced both ways and the better
/// route chosen (#76). Amounts are the tokens' own base units.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct IntentsSwapQuote {
/// `pool` (a Ref DCL pool) or `1click`.
pub route: String,
/// The pool, or `1Click`.
pub venue: String,
pub token_in: String,
pub symbol_in: String,
pub token_out: String,
pub symbol_out: String,
pub amount_in: Amount,
/// The swap fee (#82) in basis points, and in the input token for a
/// pool swap. On 1Click it is inside the quote.
pub fee_bps: u32,
pub fee: Amount,
pub amount_out: Amount,
/// The floor: a pool refuses and refunds below it, 1Click refunds.
pub min_out: Amount,
/// The other route's output, when it quoted, for comparison.
pub alternative_out: Option<Amount>,
}
/// Somewhere the intents account can pay out to (#77).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct CashoutDestination {
/// 1Click's asset id.
pub id: String,
pub chain: String,
pub symbol: String,
}
/// One token held inside NEAR Intents.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct IntentsBalance {
/// The Intents token id, `nep141:<contract>`.
pub token: String,
pub symbol: String,
pub amount: Amount,
}
/// Where a swap order is in its life (blackbeard/wallet #39). Terminal
/// states are `Success`, `Refunded`, `Failed` and `Expired`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
@@ -797,3 +896,14 @@ pub struct SwapAsk {
pub refund_to: String,
pub slippage_bps: u16,
}
/// What a scan for a phrase's used accounts found.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct AccountScan {
/// Account numbers opened that were not already known.
pub added: u32,
/// Account numbers looked at.
pub scanned: u32,
pub session: SessionStatusInfo,
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "wallet-near"
description = "NEAR for post-quantum accounts: the NEP-649 ML-DSA-65 key, transactions and NEP-366 delegate actions. No I/O; shared by the wallet and its sponsor."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[dependencies]
qp-rusty-crystals-dilithium.workspace = true
hmac.workspace = true
sha2.workspace = true
sha3.workspace = true
bs58.workspace = true
getrandom.workspace = true
zeroize.workspace = true
serde_json.workspace = true
thiserror.workspace = true
[dev-dependencies]
hex.workspace = true
bip39.workspace = true
+54
View File
@@ -0,0 +1,54 @@
//! Where the sponsor (#81) puts a wallet's account. The wallet and the
//! sponsor both compute it, so it lives here once.
/// The account the sponsor creates for a key: `b<name>.<sponsor>`, where
/// `<name>` is the lowercase unpadded base32 of the first 10 bytes of the
/// key's handle digest (the SHA3-256 that NEP-645 names an ML-DSA-65 key
/// by). Nothing needs storing: a restored phrase re-derives its key and so
/// its account. The leading `b` sorts every such account onto the shard
/// between `aurora-0` and `earn.kaiching` (8 on mainnet), which our nodes
/// serve.
pub fn sponsored_account(handle_digest: &[u8; 32], sponsor: &str) -> String {
format!("b{}.{sponsor}", base32_lower(&handle_digest[..10]))
}
/// RFC 4648 base32, lowercase, no padding. Lowercase letters and `2`-`7`
/// are all valid in a NEAR account id.
fn base32_lower(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
let mut out = String::new();
let (mut acc, mut bits) = (0u32, 0u32);
for &b in bytes {
acc = (acc << 8) | u32::from(b);
bits += 8;
while bits >= 5 {
bits -= 5;
out.push(ALPHABET[((acc >> bits) & 31) as usize] as char);
}
}
if bits > 0 {
out.push(ALPHABET[((acc << (5 - bits)) & 31) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base32_matches_rfc4648() {
// RFC 4648 §10, lowercased and unpadded.
for (input, want) in [
("", ""),
("f", "my"),
("fo", "mzxq"),
("foo", "mzxw6"),
("foob", "mzxw6yq"),
("fooba", "mzxw6ytb"),
("foobar", "mzxw6ytboi"),
] {
assert_eq!(base32_lower(input.as_bytes()), want);
}
}
}
+305
View File
@@ -0,0 +1,305 @@
//! NEP-366 delegate actions (#81): the wallet signs what it wants done, and
//! the sponsor wraps it in a transaction of its own and pays the gas.
//!
//! nearcore 2.13's classic `Delegate` action (tag 8): a `DelegateAction`
//! followed by the signature, which is over SHA-256 of the NEP-461 prefix
//! (`2^30 + 366` as a little-endian u32) and the action's borsh. The
//! delegate's nonce shares the sender key's counter with its transactions.
//! An ML-DSA-65 sender was proven on mainnet on 2026-10-09 (transaction
//! 2SVwJsa1seKQHvAJ2JCzaBvAq8cLfJSPJtppxkr9ahK4).
//!
//! The decoder is for the sponsor, which must read what a wallet sends
//! before relaying it: it accepts exactly the shapes the encoder writes and
//! refuses anything else, trailing bytes included.
use qp_rusty_crystals_dilithium::ml_dsa_65;
use sha2::{Digest, Sha256};
use crate::NearKeyError;
use crate::key::NearKey;
use crate::tx::{Action, ML_DSA_65, PublicKey, string};
/// NEP-461's on-chain discriminant for NEP-366.
const NEP366_PREFIX: u32 = (1 << 30) + 366;
const ML_DSA_65_PUBLIC: usize = 1952;
const ML_DSA_65_SIGNATURE: usize = 3309;
/// What the sender asks to be done, from an ML-DSA-65 key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DelegateAction {
pub sender_id: String,
pub receiver_id: String,
pub actions: Vec<Action>,
/// One past the sender key's current nonce, or more.
pub nonce: u64,
/// The last block height at which it may be included.
pub max_block_height: u64,
pub public_key: Vec<u8>,
}
impl DelegateAction {
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(2048);
string(&mut out, &self.sender_id);
string(&mut out, &self.receiver_id);
out.extend_from_slice(&(self.actions.len() as u32).to_le_bytes());
for a in &self.actions {
a.encode(&mut out);
}
out.extend_from_slice(&self.nonce.to_le_bytes());
out.extend_from_slice(&self.max_block_height.to_le_bytes());
PublicKey::MlDsa65(self.public_key.clone()).encode(&mut out);
out
}
/// What is signed: SHA-256 of the NEP-461 prefix and the encoding.
pub fn hash(&self) -> [u8; 32] {
let mut h = Sha256::new();
h.update(NEP366_PREFIX.to_le_bytes());
h.update(self.encode());
h.finalize().into()
}
pub fn sign(self, key: &NearKey) -> Result<SignedDelegate, NearKeyError> {
if key.public_key() != self.public_key.as_slice() {
return Err(NearKeyError::Signing(
"the key does not match the delegate action's public key".into(),
));
}
let signature = key.sign(&self.hash())?;
Ok(SignedDelegate {
action: self,
signature,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedDelegate {
pub action: DelegateAction,
pub signature: Vec<u8>,
}
impl SignedDelegate {
/// The bytes a relayer puts after tag 8 in its transaction.
pub fn encode(&self) -> Vec<u8> {
let mut out = self.action.encode();
out.push(ML_DSA_65);
out.extend_from_slice(&self.signature);
out
}
/// Whether the signature is the named key's, under the empty context
/// NEAR verifies with.
pub fn verify(&self) -> bool {
ml_dsa_65::PublicKey::from_bytes(&self.action.public_key)
.map(|pk| pk.verify(&self.action.hash(), &self.signature, None))
.unwrap_or(false)
}
/// Read what [`Self::encode`] wrote. Anything else is refused: another
/// key type, an action the wallet never sends, trailing bytes.
pub fn decode(b: &[u8]) -> Result<Self, NearKeyError> {
let mut r = Reader { b, at: 0 };
let sender_id = r.string()?;
let receiver_id = r.string()?;
let n = r.u32()?;
if n > 16 {
return Err(bad(format!("{n} actions")));
}
let mut actions = Vec::with_capacity(n as usize);
for _ in 0..n {
actions.push(r.action()?);
}
let nonce = r.u64()?;
let max_block_height = r.u64()?;
let public_key = match r.public_key()? {
PublicKey::MlDsa65(pk) => pk,
PublicKey::Ed25519(_) => return Err(bad("an ed25519 sender key".into())),
};
if r.u8()? != ML_DSA_65 {
return Err(bad("a signature that is not ML-DSA-65".into()));
}
let signature = r.take(ML_DSA_65_SIGNATURE)?.to_vec();
if r.at != b.len() {
return Err(bad(format!("{} trailing bytes", b.len() - r.at)));
}
Ok(Self {
action: DelegateAction {
sender_id,
receiver_id,
actions,
nonce,
max_block_height,
public_key,
},
signature,
})
}
}
fn bad(why: String) -> NearKeyError {
NearKeyError::InvalidKey(format!("delegate action: {why}"))
}
struct Reader<'a> {
b: &'a [u8],
at: usize,
}
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], NearKeyError> {
let end = self
.at
.checked_add(n)
.filter(|e| *e <= self.b.len())
.ok_or_else(|| bad("truncated".into()))?;
let s = &self.b[self.at..end];
self.at = end;
Ok(s)
}
fn u8(&mut self) -> Result<u8, NearKeyError> {
Ok(self.take(1)?[0])
}
fn u32(&mut self) -> Result<u32, NearKeyError> {
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn u64(&mut self) -> Result<u64, NearKeyError> {
Ok(u64::from_le_bytes(self.take(8)?.try_into().unwrap()))
}
fn u128(&mut self) -> Result<u128, NearKeyError> {
Ok(u128::from_le_bytes(self.take(16)?.try_into().unwrap()))
}
fn vec(&mut self, max: usize) -> Result<Vec<u8>, NearKeyError> {
let n = self.u32()? as usize;
if n > max {
return Err(bad(format!("a {n}-byte field")));
}
Ok(self.take(n)?.to_vec())
}
fn string(&mut self) -> Result<String, NearKeyError> {
String::from_utf8(self.vec(64)?).map_err(|_| bad("an account id that is not UTF-8".into()))
}
fn public_key(&mut self) -> Result<PublicKey, NearKeyError> {
match self.u8()? {
ML_DSA_65 => Ok(PublicKey::MlDsa65(self.take(ML_DSA_65_PUBLIC)?.to_vec())),
0 => Ok(PublicKey::Ed25519(self.take(32)?.try_into().unwrap())),
t => Err(bad(format!("key type {t}"))),
}
}
fn action(&mut self) -> Result<Action, NearKeyError> {
match self.u8()? {
2 => {
let method = String::from_utf8(self.vec(256)?)
.map_err(|_| bad("a method name that is not UTF-8".into()))?;
let args = self.vec(16 * 1024)?;
let gas = self.u64()?;
let deposit = self.u128()?;
Ok(Action::FunctionCall {
method,
args,
gas,
deposit,
})
}
3 => Ok(Action::Transfer {
deposit: self.u128()?,
}),
t => Err(bad(format!(
"action type {t}, which the wallet never delegates"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tx::TGAS;
fn key() -> NearKey {
use qp_rusty_crystals_dilithium::{SensitiveBytes32, ml_dsa_65::Keypair};
NearKey::from_keypair_bytes(
&Keypair::generate(&mut SensitiveBytes32::new(&mut [9u8; 32])).to_bytes()[..],
)
.unwrap()
}
fn sample(key: &NearKey) -> DelegateAction {
DelegateAction {
sender_id: "bx.blackbeard-observer.near".into(),
receiver_id: "intents.near".into(),
actions: vec![
Action::call("mt_transfer", &serde_json::json!({"a": 1}), 30 * TGAS, 1),
Action::Transfer { deposit: 2 },
],
nonce: 9,
max_block_height: 1000,
public_key: key.public_key().to_vec(),
}
}
#[test]
fn the_hash_carries_the_nep461_prefix() {
let k = key();
let d = sample(&k);
let mut want = Sha256::new();
want.update(1_073_742_190u32.to_le_bytes());
want.update(d.encode());
assert_eq!(d.hash(), <[u8; 32]>::from(want.finalize()));
}
#[test]
fn signed_round_trips_through_the_sponsors_decoder_and_verifies() {
let k = key();
let signed = sample(&k).sign(&k).unwrap();
let bytes = signed.encode();
let back = SignedDelegate::decode(&bytes).unwrap();
assert_eq!(back, signed);
assert!(back.verify());
}
#[test]
fn a_tampered_delegate_fails_verification() {
let k = key();
let mut signed = sample(&k).sign(&k).unwrap();
signed.action.receiver_id = "wrap.near".into();
assert!(!signed.verify());
}
#[test]
fn the_decoder_refuses_what_the_wallet_never_sends() {
let k = key();
let good = sample(&k).sign(&k).unwrap().encode();
let mut trailing = good.clone();
trailing.push(0);
assert!(SignedDelegate::decode(&trailing).is_err());
assert!(SignedDelegate::decode(&good[..good.len() - 1]).is_err());
let mut d = sample(&k);
d.actions = vec![Action::DeleteKey(PublicKey::Ed25519([0; 32]))];
let deletes = d.sign(&k).unwrap().encode();
assert!(
SignedDelegate::decode(&deletes).is_err(),
"a key deletion is never relayed"
);
}
#[test]
fn signing_with_a_key_the_action_does_not_name_is_refused() {
let k = key();
let mut d = sample(&k);
d.public_key = vec![0; 1952];
assert!(d.sign(&k).is_err());
}
#[test]
fn a_relayers_transaction_wraps_it_as_tag_8() {
let k = key();
let signed = sample(&k).sign(&k).unwrap().encode();
let mut out = vec![];
Action::Delegate(signed.clone()).encode(&mut out);
assert_eq!(out[0], 8);
assert_eq!(&out[1..], &signed[..]);
}
}
+279
View File
@@ -0,0 +1,279 @@
//! The NEAR key behind the intents account (#71, #74): ML-DSA-65, derived
//! from the wallet's phrase as NEP-649 specifies, so the same phrase gives
//! the same key in any wallet that follows it.
//!
//! NEP-649 is SLIP-10 with the HMAC key `ML-DSA-65 seed`, hardened children
//! only, and the final 32-byte node secret used as ξ for FIPS 204
//! `ML-DSA.KeyGen_internal`, which is what the dilithium crate's
//! `Keypair::generate` computes (checked against NEP-649's vectors below).
//! Coin type 397, default path `m/44'/397'/0'`.
//!
//! NEAR verifies pure ML-DSA under the EMPTY context (nearcore 2.13 signs
//! with aws-lc's `PqdsaKeyPair::sign`), the opposite of Quantus, which
//! refuses the empty context from spec 148. So this key signs with no
//! context and takes none: there is nothing to choose and nothing to get
//! wrong.
use hmac::{Hmac, Mac};
use qp_rusty_crystals_dilithium::{SensitiveBytes32, ml_dsa_65};
use sha2::Sha512;
use sha3::{Digest, Sha3_256};
use zeroize::Zeroizing;
use crate::NearKeyError as KeyError;
/// NEP-649's default path in its short form, which wallets check first on
/// recovery.
pub const NEAR_DEFAULT_PATH: &str = "m/44'/397'/0'";
const SLIP10_KEY: &[u8] = b"ML-DSA-65 seed";
const HARDENED: u32 = 0x8000_0000;
/// NEP-645's domain tag for the on-chain key handle.
const HANDLE_TAG: &[u8] = b"near:ml-dsa-65-pubkey-hash:v1";
/// A NEAR ML-DSA-65 key. The secret is inside the dilithium crate's
/// `Keypair`, which zeroizes on drop.
pub struct NearKey {
keypair: Box<ml_dsa_65::Keypair>,
public: Vec<u8>,
}
impl std::fmt::Debug for NearKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NearKey")
.field("public_key", &self.key_string())
.field("secret", &"[redacted]")
.finish()
}
}
impl NearKey {
/// Derive the key at `path` (every level hardened) from a BIP39 seed.
pub fn derive(seed: &[u8; 64], path: &str) -> Result<Self, KeyError> {
let xi = node_secret(seed, path)?;
Ok(Self::from_node_secret(xi))
}
/// A key from a keypair the dilithium crate serialised (secret then
/// public). For a key that was never derived from a phrase, such as
/// #72's spike key.
pub fn from_keypair_bytes(bytes: &[u8]) -> Result<Self, KeyError> {
let kp = ml_dsa_65::Keypair::from_bytes(bytes)
.map_err(|e| KeyError::InvalidKey(format!("{e:?}")))?;
let public = kp.public().to_bytes().to_vec();
Ok(Self {
keypair: Box::new(kp),
public,
})
}
fn from_node_secret(mut xi: Zeroizing<[u8; 32]>) -> Self {
let kp = ml_dsa_65::Keypair::generate(&mut SensitiveBytes32::new(&mut xi));
let public = kp.public().to_bytes().to_vec();
Self {
keypair: Box::new(kp),
public,
}
}
pub fn public_key(&self) -> &[u8] {
&self.public
}
/// NEAR's string form: `ml-dsa-65:<base58 of the 1952-byte key>`. What
/// `view_access_key` and every transaction take.
pub fn key_string(&self) -> String {
format!("ml-dsa-65:{}", bs58::encode(&self.public).into_string())
}
/// The handle state keeps and key listings show:
/// `ml-dsa-65-hash:<base58 of SHA3-256(tag ‖ public key)>`.
pub fn handle(&self) -> String {
format!(
"ml-dsa-65-hash:{}",
bs58::encode(self.handle_digest()).into_string()
)
}
/// The handle's 32 bytes, from which the sponsor names the account.
pub fn handle_digest(&self) -> [u8; 32] {
handle_digest(&self.public)
}
/// Sign under the empty context, as NEAR verifies. Hedged: fresh
/// randomness is folded in (FIPS 204 §3.4).
pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, KeyError> {
let mut rnd = Zeroizing::new([0u8; 32]);
getrandom::fill(&mut rnd[..]).map_err(|e| KeyError::Signing(e.to_string()))?;
let hedge = SensitiveBytes32::new(&mut rnd);
self.keypair
.sign(message, None, Some(&hedge))
.map(|s| s.to_vec())
.map_err(|e| KeyError::Signing(format!("{e:?}")))
}
}
/// NEP-645's handle digest of an ML-DSA-65 public key:
/// SHA3-256(`near:ml-dsa-65-pubkey-hash:v1` ‖ key). For a key known only by
/// its public bytes, such as one in a delegate action the sponsor reads.
pub fn handle_digest(public_key: &[u8]) -> [u8; 32] {
let mut h = Sha3_256::new();
h.update(HANDLE_TAG);
h.update(public_key);
h.finalize().into()
}
/// SLIP-10 over HMAC-SHA512 with NEP-649's key: the 32-byte node secret at
/// `path`. Wiped on drop, as is every intermediate.
fn node_secret(seed: &[u8], path: &str) -> Result<Zeroizing<[u8; 32]>, KeyError> {
let indices = parse_path(path)?;
let (mut k, mut c) = split(hmac512(SLIP10_KEY, &[seed]));
for i in indices {
let (nk, nc) = split(hmac512(&c[..], &[&[0u8], &k[..], &i.to_be_bytes()]));
k = nk;
c = nc;
}
Ok(k)
}
fn hmac512(key: &[u8], parts: &[&[u8]]) -> Zeroizing<[u8; 64]> {
let mut mac = Hmac::<Sha512>::new_from_slice(key).expect("HMAC takes a key of any length");
for p in parts {
mac.update(p);
}
let mut out = Zeroizing::new([0u8; 64]);
out.copy_from_slice(&mac.finalize().into_bytes());
out
}
fn split(i: Zeroizing<[u8; 64]>) -> (Zeroizing<[u8; 32]>, Zeroizing<[u8; 32]>) {
let mut l = Zeroizing::new([0u8; 32]);
let mut r = Zeroizing::new([0u8; 32]);
l.copy_from_slice(&i[..32]);
r.copy_from_slice(&i[32..]);
(l, r)
}
/// `m/44'/397'/0'` into hardened indices. A level that is not hardened is
/// refused: ML-DSA has no non-hardened derivation.
fn parse_path(path: &str) -> Result<Vec<u32>, KeyError> {
let mut parts = path.split('/');
if parts.next() != Some("m") {
return Err(KeyError::InvalidPath(format!("{path}: must start with m")));
}
parts
.map(|p| {
let n = p.strip_suffix('\'').ok_or_else(|| {
KeyError::InvalidPath(format!("{path}: level {p} is not hardened"))
})?;
let n: u32 = n
.parse()
.map_err(|_| KeyError::InvalidPath(format!("{path}: level {p} is not a number")))?;
if n >= HARDENED {
return Err(KeyError::InvalidPath(format!(
"{path}: level {p} is too large"
)));
}
Ok(n | HARDENED)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
/// NEP-649's test vectors (assets/nep-0649/test-vectors.json at
/// f8e50e7), trimmed to what is checked: per path, the node secret, the
/// chain code and the public key.
const VECTORS: &str = include_str!("../tests/fixtures/nep649-vectors.json");
#[test]
fn nep649_vectors_derive_to_the_published_keys() {
let v: serde_json::Value = serde_json::from_str(VECTORS).unwrap();
let mut checked = 0;
for t in v.as_array().unwrap() {
let seed = hex::decode(t["seed"].as_str().unwrap()).unwrap();
for d in t["derivations"].as_array().unwrap() {
let path = d["path"].as_str().unwrap();
let xi = node_secret(&seed, path).unwrap();
assert_eq!(
hex::encode(&xi[..]),
d["node_secret"].as_str().unwrap(),
"{path}"
);
let key = NearKey::from_node_secret(xi);
assert_eq!(
hex::encode(key.public_key()),
d["public_key"].as_str().unwrap(),
"{path}"
);
checked += 1;
}
}
assert_eq!(checked, 22);
}
#[test]
fn a_mnemonic_vector_derives_through_the_wallets_own_seed() {
// Vector 3 is a phrase, not a raw seed: the whole road a restore takes.
let v: serde_json::Value = serde_json::from_str(VECTORS).unwrap();
let t = &v[2];
let seed = bip39::Mnemonic::parse(t["mnemonic"].as_str().unwrap())
.unwrap()
.to_seed("");
assert_eq!(hex::encode(seed), t["seed"].as_str().unwrap());
let last = t["derivations"].as_array().unwrap().last().unwrap();
let key = NearKey::derive(&seed, NEAR_DEFAULT_PATH).unwrap();
assert_eq!(last["path"], NEAR_DEFAULT_PATH);
assert_eq!(
hex::encode(key.public_key()),
last["public_key"].as_str().unwrap()
);
}
#[test]
fn signs_under_the_empty_context_only() {
let key = NearKey::from_node_secret(Zeroizing::new([7u8; 32]));
let sig = key.sign(b"tx hash").unwrap();
let pk = ml_dsa_65::PublicKey::from_bytes(key.public_key()).unwrap();
assert!(pk.verify(b"tx hash", &sig, None));
assert!(!pk.verify(b"tx hash", &sig, Some(b"QUANTUS_EXTRINSIC")));
}
#[test]
fn the_handle_is_the_one_mainnet_shows_for_72s_key() {
let key = NearKey {
public: bs58::decode(
include_str!("../tests/fixtures/bbspike72-ml-dsa-65.pub")
.trim()
.strip_prefix("ml-dsa-65:")
.unwrap(),
)
.into_vec()
.unwrap(),
keypair: Box::new(ml_dsa_65::Keypair::generate(&mut SensitiveBytes32::new(
&mut [1u8; 32],
))),
};
assert_eq!(
key.handle(),
"ml-dsa-65-hash:BGQfipngvvnrNKHEV41FDVPiFy1kEajb9capGbX1uibt"
);
}
#[test]
fn non_hardened_and_malformed_paths_are_refused() {
for p in ["m/44/397'/0'", "44'/397'", "m/44'/x'", "m/2147483648'"] {
assert!(parse_path(p).is_err(), "{p}");
}
assert_eq!(parse_path("m").unwrap(), Vec::<u32>::new());
}
#[test]
fn debug_never_prints_the_secret() {
let key = NearKey::from_node_secret(Zeroizing::new([3u8; 32]));
let d = format!("{key:?}");
assert!(d.contains("[redacted]") && d.contains("ml-dsa-65:"));
}
}
+23
View File
@@ -0,0 +1,23 @@
//! NEAR for an account whose only key is ML-DSA-65 (blackbeard/wallet #71):
//! the key, derived from a BIP39 seed as NEP-649 specifies; transactions,
//! encoded as nearcore lays them out; and NEP-366 delegate actions, which
//! the sponsor relays (#81). No I/O: the wallet and the sponsor both build
//! on this, so they cannot disagree about a byte.
pub mod account;
pub mod delegate;
pub mod key;
pub mod tx;
pub use account::sponsored_account;
pub use key::{NEAR_DEFAULT_PATH, NearKey};
#[derive(Debug, thiserror::Error)]
pub enum NearKeyError {
#[error("invalid derivation path: {0}")]
InvalidPath(String),
#[error("invalid key: {0}")]
InvalidKey(String),
#[error("signing failed: {0}")]
Signing(String),
}
+258
View File
@@ -0,0 +1,258 @@
//! NEAR transactions, encoded and signed here (#74). Borsh, as nearcore
//! 2.13 lays it out: `TransactionV0` (no version byte), the hash is
//! SHA-256 of that encoding, and the signature is over the hash. An
//! ML-DSA-65 key is type 2 followed by its 1952 bytes, its signature type 2
//! followed by 3309; a signed transaction is about 5.5 kB.
//!
//! Only the actions the wallet uses exist here.
use sha2::{Digest, Sha256};
use crate::NearKeyError as KeyError;
use crate::key::NearKey;
/// NEP-645's key and signature type for ML-DSA-65.
pub(crate) const ML_DSA_65: u8 = 2;
pub(crate) const ED25519: u8 = 0;
pub const TGAS: u64 = 1_000_000_000_000;
/// A public key an action names. The wallet only ever signs with ML-DSA-65,
/// but it removes the ed25519 key an account was created with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublicKey {
MlDsa65(Vec<u8>),
Ed25519([u8; 32]),
}
impl PublicKey {
pub(crate) fn encode(&self, out: &mut Vec<u8>) {
match self {
Self::MlDsa65(pk) => {
out.push(ML_DSA_65);
out.extend_from_slice(pk);
}
Self::Ed25519(pk) => {
out.push(ED25519);
out.extend_from_slice(pk);
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
CreateAccount,
FunctionCall {
method: String,
args: Vec<u8>,
gas: u64,
deposit: u128,
},
Transfer {
deposit: u128,
},
AddFullAccessKey(PublicKey),
DeleteKey(PublicKey),
/// A signed NEP-366 delegate action, as the sponsor relays it: the
/// bytes of [`crate::delegate::SignedDelegate::encode`].
Delegate(Vec<u8>),
}
impl Action {
/// A function call with JSON arguments.
pub fn call(method: &str, args: &serde_json::Value, gas: u64, deposit: u128) -> Self {
Self::FunctionCall {
method: method.into(),
args: args.to_string().into_bytes(),
gas,
deposit,
}
}
pub(crate) fn encode(&self, out: &mut Vec<u8>) {
match self {
Self::CreateAccount => out.push(0),
Self::FunctionCall {
method,
args,
gas,
deposit,
} => {
out.push(2);
string(out, method);
bytes(out, args);
out.extend_from_slice(&gas.to_le_bytes());
out.extend_from_slice(&deposit.to_le_bytes());
}
Self::Transfer { deposit } => {
out.push(3);
out.extend_from_slice(&deposit.to_le_bytes());
}
Self::AddFullAccessKey(pk) => {
out.push(5);
pk.encode(out);
// AccessKey { nonce: 0, permission: FullAccess (1) }
out.extend_from_slice(&0u64.to_le_bytes());
out.push(1);
}
Self::DeleteKey(pk) => {
out.push(6);
pk.encode(out);
}
Self::Delegate(signed) => {
out.push(8);
out.extend_from_slice(signed);
}
}
}
}
/// An unsigned transaction from an ML-DSA-65 key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transaction {
pub signer_id: String,
pub public_key: Vec<u8>,
pub nonce: u64,
pub receiver_id: String,
pub block_hash: [u8; 32],
pub actions: Vec<Action>,
}
impl Transaction {
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(2048);
string(&mut out, &self.signer_id);
PublicKey::MlDsa65(self.public_key.clone()).encode(&mut out);
out.extend_from_slice(&self.nonce.to_le_bytes());
string(&mut out, &self.receiver_id);
out.extend_from_slice(&self.block_hash);
out.extend_from_slice(&(self.actions.len() as u32).to_le_bytes());
for a in &self.actions {
a.encode(&mut out);
}
out
}
/// The transaction's hash, which is what is signed and what NEAR
/// reports it by.
pub fn hash(&self) -> [u8; 32] {
Sha256::digest(self.encode()).into()
}
/// Sign with `key`, which must be the key the transaction names.
/// Returns the signed transaction's bytes and its hash.
pub fn sign(&self, key: &NearKey) -> Result<(Vec<u8>, [u8; 32]), KeyError> {
if key.public_key() != self.public_key.as_slice() {
return Err(KeyError::Signing(
"the key does not match the transaction's public key".into(),
));
}
let body = self.encode();
let hash: [u8; 32] = Sha256::digest(&body).into();
let sig = key.sign(&hash)?;
let mut out = body;
out.push(ML_DSA_65);
out.extend_from_slice(&sig);
Ok((out, hash))
}
}
pub(crate) fn string(out: &mut Vec<u8>, s: &str) {
bytes(out, s.as_bytes());
}
pub(crate) fn bytes(out: &mut Vec<u8>, b: &[u8]) {
out.extend_from_slice(&(b.len() as u32).to_le_bytes());
out.extend_from_slice(b);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_as_nearcore_lays_it_out() {
let tx = Transaction {
signer_id: "a.near".into(),
public_key: vec![0xAB; 3],
nonce: 0x0102,
receiver_id: "b".into(),
block_hash: [0x11; 32],
actions: vec![
Action::Transfer { deposit: 5 },
Action::call("m", &serde_json::json!({}), 7, 1),
Action::DeleteKey(PublicKey::Ed25519([0x22; 32])),
],
};
let mut want = vec![];
want.extend_from_slice(&[6, 0, 0, 0]);
want.extend_from_slice(b"a.near");
want.extend_from_slice(&[2, 0xAB, 0xAB, 0xAB]);
want.extend_from_slice(&[0x02, 0x01, 0, 0, 0, 0, 0, 0]);
want.extend_from_slice(&[1, 0, 0, 0, b'b']);
want.extend_from_slice(&[0x11; 32]);
want.extend_from_slice(&[3, 0, 0, 0]);
// Transfer
want.push(3);
want.extend_from_slice(&5u128.to_le_bytes());
// FunctionCall "m", args "{}", gas 7, deposit 1
want.extend_from_slice(&[2, 1, 0, 0, 0, b'm', 2, 0, 0, 0, b'{', b'}']);
want.extend_from_slice(&7u64.to_le_bytes());
want.extend_from_slice(&1u128.to_le_bytes());
// DeleteKey ed25519
want.extend_from_slice(&[6, 0]);
want.extend_from_slice(&[0x22; 32]);
assert_eq!(tx.encode(), want);
}
#[test]
fn add_key_is_full_access_with_a_zero_nonce() {
let mut out = vec![];
Action::AddFullAccessKey(PublicKey::MlDsa65(vec![9; 2])).encode(&mut out);
assert_eq!(out, [5, 2, 9, 9, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
}
#[test]
fn a_signed_transaction_is_the_body_then_an_ml_dsa_signature_over_its_hash() {
let key = NearKey::from_keypair_bytes(&test_keypair()).unwrap();
let tx = Transaction {
signer_id: "x.near".into(),
public_key: key.public_key().to_vec(),
nonce: 1,
receiver_id: "x.near".into(),
block_hash: [0; 32],
actions: vec![Action::CreateAccount],
};
let (signed, hash) = tx.sign(&key).unwrap();
let body = tx.encode();
assert_eq!(&signed[..body.len()], &body[..]);
assert_eq!(signed[body.len()], 2);
assert_eq!(signed.len(), body.len() + 1 + 3309);
assert_eq!(hash, tx.hash());
let pk = qp_rusty_crystals_dilithium::ml_dsa_65::PublicKey::from_bytes(key.public_key())
.unwrap();
assert!(pk.verify(&hash, &signed[body.len() + 1..], None));
}
#[test]
fn signing_with_a_key_the_transaction_does_not_name_is_refused() {
let key = NearKey::from_keypair_bytes(&test_keypair()).unwrap();
let tx = Transaction {
signer_id: "x.near".into(),
public_key: vec![0; 1952],
nonce: 1,
receiver_id: "x.near".into(),
block_hash: [0; 32],
actions: vec![],
};
assert!(tx.sign(&key).is_err());
}
fn test_keypair() -> Vec<u8> {
use qp_rusty_crystals_dilithium::{SensitiveBytes32, ml_dsa_65::Keypair};
Keypair::generate(&mut SensitiveBytes32::new(&mut [5u8; 32]))
.to_bytes()
.to_vec()
}
}
@@ -0,0 +1 @@
ml-dsa-65:AVvvkViAPmgq82tpUwKhJuFRNzEm2KHyxYcoaN5z1x9PyhRDbu32FUvvTMKdGC73gNqF63hnpV7mfkdhm7aKnm6U8fzGpeZZxMkSGdgFQ8quT3kodGmX2DNBuMQpfsmgWDF971AQZT3DoTxLcW57iAvS72HoAUXutVkz5raErZwsze2i7cbJ7dgEWVZnkJAKzJDXPvexYXMDwWH6r5vnK1pHsHYby1AuPuUZrfTEwvHqXCVC3ja5CXdZ7KLKqdKz1jcmv96y75uUhfKhemjVNCmwr23tQc4sqGf4fnW2oYiJT2h4K3MNw1C9F3fQKDnUAFSP8c86zSzRufGdwFj99uXzqg9ghUfSwSrSTkRWpQNPGf5ELgxXTRHauuXUS7R4S2o3LnqQhT29fZU9dy6BXKMU5pTSJ5f5jwkWj17Qe2Jh6JwnrV7DPevBP9oEkihXjeNv9iEDDtc5nEGX7frErynEmGvGuP71QEDNvJ1un2nCy1BExLvgNkvjgC2X8pp4eDEPzUjr73owKUMgPzm7qZUp6MTgNEv77GnAuYTZSCDsbDN52F1R5zmL4vbThWfcXdjGo6WD72iPoAeJEV8YD5q2haCMAEr7xGBws5vTVNieGJgYovm6YFYbnJChymbsak68jfcV8VfdX4szfCKL415cfN8BQ2h32XWcFo2X4cSUw28iZivpueuYXxeuDvNTbEQxt8tDhdn4yNwsjWijHspzHWU4mRKqghy4RawBsNY5aHBKGPQ85rFL8PvGDy5QA5o2TJ9YdC6dGhzZ5DNwjtLRQ4h3GLf2X79YAjRVw8477R2VjLYFyBeZtW7xbvpoChmPRKaMM4A3LFdPCgAJYHxNYNyyTHxw7vzDWPpLZZYzRZNkkWPbomADWja24kaAbWm6mRr1C5jzgzfVuFg3bW3TZ2Yuk3DUiyVQmcnwnKfc4HXsA4ddq2kcSxWQMSQtYtT6WNaScbNuznMz9nBHR35Mne2V3JVbaoTViLUCPTw2PUoQ3z54Y28CjGVnxPyK7FZkjLZbahk5fNkwqrUjpgqg772ESjNXue4E1BNxt1kGHankxM5RNPHeXEyrBPAMnpB8b6jDnXDgkkRZLjSRkoFfHGyXyu1sGbjzf8HxWULbS1TF8H34GytskEt3p2Q25SvPVn9GbUK21GsixnLrZy8G6bD1VqCCp1f29BrhgH2mLzKe8Rr1nGZaa7PN16C3tpHJMi48dzWeJ41TKq1XhEocjKGkbuKia4ape66b25ezyPCXn7dSYhmffYSZ4SVfyAae4hFVCeQCDRgN8rdh6gDLn6LW5pXPEiaRuFutVF4K6utosnTfRAgD3GCyzpSha8p2atA7THc35djCwJUxnBz9Q7ReSmE3MPfJQze964eunDAAvMV1YTuUTh15MCsNfmewehFaYtzKTpQeW72Rij2fiUk9rhnaujSsenJqQNsjvT2s6VoQ8tzdLuAAhydWrfua3AwDqYx6w7CvG27JCbm8j4bHJDT6NY7hfBnMLFRhQqhEuKgYExRDBpMTa78nyx43TY2PeGoqHvMHg9Bie6J5AXGm2H1VA2yUCEL2C41qWbpYwJYneiKbxQhQiYnPhokoXcWQGnpaWDXqp16xmGi5PcXfkhvh5zYfA8kdhGHLEMAHKSVbLQAz7bnkqNGJymSeLiU4kfsW6BEh3vW5jNRMiGBR43WRhhg7rKzB4EZiDdiX5tnGZ2JHR8vEJKZicNrpddkFzKmXMRdhai9NJsMSPUVBhdqg16hvSy2DPxoHQL2mt1fV9hnkHWVcFfiwZXB95S3rLiQ9zaFY8kv9qjZQkj2Y6MLj74crP7YwST4ejQTXQ1boTxA4S1wC6pz9Wm6wMsDLReGBK762Rud5ctarJnWWvQ5eFgsb2eu2FQB1sVYEUM61kgdSK91poQ7hKnEZW5aKvPRAe97BmZC9dxcYqQmpWKeP3crGZGAJaodPzWRXp6TEoWe4zhxmfJmsZVpMayRxz3vXDsAnN9r7EpNoXwvfQGPLTDM7xrSBsssriusdLFYahiVhDDsg6iJ94ys25iD38rN7gqMzcgVrG3emj9b8BATLzmV5eLFwaiFB6zVNGQEywu8gzTdJzyo5mn7gxPzyC7kca7r5BV5tD2LGRJh4oyR3cbq8z85zpoe8az4c5Dr8yiCCoZbZ6bwMVWrD8dwBpN6zLEdGSjz6Lgi39hakfq4yZskGtVg9HJBz1vg1s5xQyJuVcndesW1Qw7R1mJjjUCRxUEmjEBUncf35YdoSygtxXBVJfGUUkdueBbEuPEpSczMCjAFUw2ybcLovdYhZwRxoqJ8zgsy4D4JzqdNAn1TBdRE4UCPq37mj7ZQWjo17EeN3dCDduciXbEzjT3tNqJgeS38eqD4sT7RszHC75SBhnYN6J6khAYHL6AKv2xgdqq8J8tfhg5dNxPNZdxFJ5cxpsJT3MtgxVnaZwzvQf1Y3nsgCDofDD4tkLrv9FQXtyPtzNd1QCRz6XVyT9KY4MkZ8fxoBDgR8UR14xp3pC7oTQUnU7qeFh9tkYaQPGqDuSARCPBsvzr59Y4gryu3Ut1MZBLptWdkeA19CGGzrLX69KQ73pGRMSqSEqxuRw2wXQDmFsNS1MVdwvwVmBaXtojVax77Z9Da8JYiYoomY2ahwrYuQP5hmHt
+162
View File
@@ -0,0 +1,162 @@
[
{
"name": "Test vector SLIP-0010 1",
"mnemonic": "",
"seed": "000102030405060708090a0b0c0d0e0f",
"derivations": [
{
"path": "m",
"node_secret": "dbe1e4f588227dec9e2dc59a4534e7721bf3e977452f0193591603f79880795b",
"chain_code": "7e74b6275f92cc4fb2cbdac0c63cb5e7ac2bce1ded2b7dbc7bf2232f772578d5",
"public_key": "2165122a18adecfcfaae985fcfe34c2f0705e0fea03c0db22826ae66e83976d5451b45a53bce4b1d9c882467914f04f183f89fa826edee009b9738a27492b56a9c836d1197fddc1b4b513b5c34273470d73f8e7e26e6d17c9985ab4f63572a93c73b498b4bb96df52448b0ba1ac34dbe0f0c0da2c8567736d638123846aa124711fe82dee450cb422c153dbca00ca9e3306040e0d5bfd8b892c5cb25aa74b84efc90b69560050c49be3eef1790d89b6b7d79aaaf9d882064b980a590112962d6c34bf4e876a87230ca42185b580f757a530abf9710be1fb71b4b2b961a527a38879f9a4415ea7481cca522595edf32e737d125bf1b40f72bbf2819ef31d460a29b95e067157b44b8342f8920256e8d4766e9711b6eb82ee7abf2573323d1c797ac727bbbb97781fac46978caa55061052cb725872f3093fe36c0f51252fd6d37aada27e43f8e1fa0b06bedcd3f394844987efe197c5b02e2453bbace331e22e2846628ee6d029a6cdc34c0950b7656ac51cfd2fe9747dd449b48c4f6cc54436b138f9159d1dac12d27bc7ce4199c67329eb6fc2a56f1ebec8c82a840e02eccdecb211ce2188caafb981969388b4ef8a00d1098445be5945c2013e3c7b8c7071303911c9f87b717b52c4be713174e0c13194896146f810fc59f3302ca7b22dc797fad44ece4f5bb65b41585a3bc6e63d888a0bfcc08b8192c65bcf7d5bbeaf178a1e24c183f5337ce307d1b93b7511b1902bdcf5891eba2921351690dd3e2161468400e5bdba88443f48875ca60b4023446981cce3a2173d0003e986ddc4f22bb892bd3eeb2a3f095a7fcf96bbffcbeee9da3ded8e730f6396a1063e814cf457424e03e66a5b82a43042964ca5e4b3b7e01405b7521c892f86ed3e6ee2fd48c45fc63f613f10d94d7696dafb27fa01493c02fdf1fa741b1992884de0f23abc84327fb0f4b920d9231fcca9398908f9bbb473f9a820a3eec69907f525de4ec8e647cdc0f36ab8e3df2894a1512488aa91dadd9dcc4248a180141c0f80770f0fdf182f081d9b6bc1dffbf5cf3859f45500bebbb04a914df44283f17495a863d0f3db914d37f60b58050cd5ad9b934c39b212c4716afc778aade447c31d670eeab8a6e6e6e267fdc8f0605378986abb2e58ce966f28dde295be677c070f5d722fa3cae2f553ab1b5dea863d174f0866d69f49fa4dda90dbd0c7775876802666bdbe09cd363e04ed17be4a9b7064c2e8d69d819b7a757d7a5fcbbdde3ee29b5e38cc18300024f7fda951dcbeff2e693bf55bd7addcb080f7eeb12a8e1dc774d089c7fecc7bc61641cf6156ab0c95dc01dffd7be9aeca72f218ea925776c85056604758593c4317b92a4c90cc4adbc0faddf563cdaba00562ea289906099653114214416cab78e3616d3b38ff7e2f5b809c1f7bcbdfbcd7546f62a329e11c4b00e5039c94fde0387b99e1d96478ec9cd110b8e55f1d7003372964050fe4fe108bb0192e66a8f99b8b0818cfe0e26fb85f3d616dfe21af6e378a5dc4cc324e989599a19f8ab828fe49d9dcb005ce27a0f0c74731c76d43763c0cb2f89b59e5867941f590d838c9a85e4ef372244e76a302cd828329674a28abe19e21d054f51205b800d55b4f2d49dec94c37323d50d720dd57b9fa230f557bc9d6ccd9a1cca434ee1b0ac9f0973f7d04c2f139d5b01ffa1826516529d19d3022cc5a24b7e1f53b301cde8ad8c11ea261201e84a7b7f1daedbec544cde5a0a612f616635a551d8138aee6bedc66aa72dbae4277d40f60533ac314a60b4b14d0558490f1957c10730d19fd2f87c1e2ba9dafdbdaa706d2b21f160af40d0feed11c10886447fa680c80cf323f769f7cab46cbb76cd02637d911a820d9a96084cb7a8120f77eb533bb5c5b1d172a228956dd9a268fcecdad2963eebdea2326b13f412a19d9e27e181d40b3332c7de807d979dd8401ba79368d055981e0995c14db497b8a5f8fe6ea9afb4f09e67b43f612ed5bc4b1d57a0abb16da622da063d61e489fab1cf0e3f97424341d39b02fadb4c2419c7edac88d71113ec09efe886b3816660aae3cb20446e8cb37ba448aeccdd52a34717d11bc884ccf9c7b90a36615e7546f2b470b56750a98f6fa3301408195e94a13aa34f3acdca4c9ccc352e8b444a44aa5464eb3e79e5e7fb19a99bcc8e8d3ccce81b32195c0157232efd0895f2dc5d4f34ec90c8a900421a27cee0700998b0df5ae20ff4a5cdce4cafa496ebe502459011c2e0dca51db84b493c88aa94d5ade4b2024931ff37ce7f49a897c931ffcd39d1e6e7e626de7c54371794ab745cb12400b801532f4b5bcaa22d7d2f4060175e75fe1652507657741870e0787044a022b0a799e11832095462e1ff924db1c450a6a2bdfdb56afe4aeaa73322feb9dde0cb9918940784193541ede369c486d0a9be120a74f29f9a224d4dd65283effe678a61bd5f3cd8e76f70a33b676319bc8aa34d278ff6903dee8e4e6c52271a9ae9b717f657452159f27631e96be9bb5c89c1b1e15e67891f2fac3389b9a5cf6a3a612d7c6be907d459c0fe930d4455faa7456ae69243164645ac4b663bca05e7eca59cdaff9986efcc7a6ad0541240e5bc7cdee0be9acb89f51d2fcebfd1e76eb3a3d4f4c3b8b06c9dd78c4e1827ea30d4be603fc5671ec448bc110e1f04dc4e43a46668b8faf4cef31445e24d30233f22e4068c10e38cab357a748099b69afbf2037d2a25ea105d96723b6271cd465628e34f5c43e6ef4e"
},
{
"path": "m/0'",
"node_secret": "ee139cc19250043529ae16f3d716208298deefa7cfd4a5a2d9af7ac6d6cac4a1",
"chain_code": "d8b27c87ec212d6501629199262a9d0d66ec26deab313c26a474bc4ddd5dce7c",
"public_key": "9c1de4633ccf1b108b39b47e9667935d713636dd7de779fa64f881d6476c8bd9607cdd6da156000c5ef33876b8b11d75078431ce21cced93e00a5a0bb2bd7520abf32656258f099ad0ba90c1ed10382254f7458b60cfce4218ec83a3a3e2cf68edbe08344f7a6729108e723a54c0cad6acba5e08c318fc4bbc2ab044339044cc0b5fb8a740d9f5f837cc54b408e77e5fe77ae93560042fe084e844e1f72c00c9c733f916b02f1e42a48a9b47cda742b6070a39e6c71f98fd89082ce9f9f59324c4022fd74f6ffcd3b370e36104fc14525c2977ae75a3b05dc90ec38eeaa61b20aba9ced4080cd9f9460178bf62180df5248e79c35a9a99c9158e8fde3aaea402384edb36c898ebf77ea569c4f09aa1de4b75ae10cc5ab4f88e5b8971582727dd881f9fd27a634618198e74718a84dd873a595627e83f68c91ff9c629347639aeba1f6b240678293d09c60179328c112aa238dbfdf7c4455d34f910328480cd8efba6cead3bb1277ac288f93d3555c9ccdfee2483e5da7a1151059d3e2dd59d40e27a227ff695fac6e7890cc61c4a61fd70f6d67d1bef4af8096edb36fe4f3c769f831621b6a5d8d6487d181df2f6ff4619526bdabbd4c2b0d0dfe822120f6d1f7f3ab1c49e7d3d38c353fc19691d523e395a7e0eea826a058ec4520f2ebf1b4f1f5cd6e7111a804b8c3642803798924a86493b539d6b539d09a95ef1bcf12b24bba520ca18cb58e58727721122d11aa841de47bd37b8ac4f42b0b3e2a8a559a8d9687936a49385da46003730783c360422622228254c8ba657ae20a44c99df173c457d3acf6ef2d240f31b4c58615379ef2823d6c702026aae3d2a5155efc4d1f0ed9afc45f7bc3b644804deb97100e3fba6b8023447055bb65d370f6c5d3b83981bab4fbb6a7aeaeb84699da3200ef727cb52c13f22518a6501d0e5b23093c0654a52ba29a0dda5e436ce54b682ef5a85f9bf60d9f840484222a3a102491e7bc202e4e5e08a15285671ad07888aafaf85665c5bead448c777ae17cd38d1447fe6498ddb715397bb6cf8d79d21242ae1cafb10d6121e4a5db4cfe304f4b563bfa3a9b65938a97cc470fbd87480281e07c98f71f6a53f989db6d78916f646912d3a64b6bac7517f058c7358708c2611dd3fec1004f688f9d35ecec9fb571bb20275ddeb7200198fd82c375c46fda057e7184ee4461d4a8c07c529b9b065d3f721ab406fb15182d93fd1016577d81f3709cceeb195250ecfddfc35e4edab597fbb658995146807bb6598ab96c17d0ba09d2dba0c18476d6c7e5cf34a9442af4f59ba62c9a99418c22b0544d507b6d0abed9d7e30e1b784114352000f4c3c252c99c435400392ce8d19e6163b6c2576388beb8d50112cd994ffbbfcca43393b095da461d7ae552fd50812827000e4a593bae876501cd2da9712bf3e093c2420bb923f0f4afc5dce12e957f7ec1df5688b2ba996897a05b80c741c5431e694ac5bc634a16bc1cc614a94e93a51ffc557fbd985179563838a12146423c054b81c24570b88c39207da4ad03be7eaed2279c77a54baaf516f58f5dd25c62b502305d555e66c2116a23b707fa3ceb6421f1a5ee21750d1cc6383d5a1220b4b0794bf06a3c82cb1df3cea03be48829d24f23f59fba7a3cdcb28d4ce7506513b5906a71991f6e8b951f4c39e22f3f6c67ad0adeea75b9fdd60320d42193706322365915e17e78f4329c81f88096575b87e3c4d868236ccdda109b80e0cb45d1724650d5686657b982f710e8b3dd47bc6e0c585e22530e54b53dec2e0a31e0d1915afc4a5f45b0b4d1e528ca165039d4a4efd9cf77e8df71f9d81b92866ed69383c55666fec6e50073082374269b7f236cb877076b777b1024b56c66b9553c6fa1e0288c70d00c5190de79dc9670108bb7747a927a1729dbc787479d5f0a787af354161c26dddfe30cfaee47c6f5fd0073d670e1833884ba475738cbd0efe72d20008113d243192d39029788dc4af1e76279e99f8ec4e40a6881ee0088aa51dcd27bb39f1b40cfa8736d460d53e0df38389eb44e126dca87048614a7227ac7e1f20f311b1220e3acc3632023c3862c865e74971401d312a8bed8bd92a3f305daa6f638982d75a4af89ccdc47f7046c0bf30a7420b74c6c4c4202e6150a5a889c832ea6b40e8653c187fb550debdbf1d6575cc4fb1000493916aa14ea8c5c3259b5248765a724c89aab4066b4ae88c10eac53420d63d1bfb131cb4b77440853bf1c29ff6ce11e8eb735e69ddc5945a7444a41d16a1ff83923219c20813a1eee7ee1752e53342b3b27f0df75cd5f37b1d2041d3bfdda70fb1cc0f3b1af74a1fe231b46162d52df5199744ca4858fc99dd386cfd84df73ca8f075cdf56a715e2719fdfa50eb7967dce27f7d83c587a678d84754931f1a334826923057ca94d740c71ddddf676e2c36d5ede493a403aba13aa968526547414ed97d37ac88a2019c3bfe0dd3b7b691defed077e0375d5fbf06ed9cb3d5c4d45564a67a3292f5ee8bd685409061d5c309075f27a093b95025d2cfeea0a44852270508c4c30a404ed47f45cc5366cdf261b91e5cd9d1687b9944f5cbc1c6d22da306d0220ab8bae352fafd20939458babd66c502a6ef00efe5d2be9c23e5aaed4ad2e12ff2b232c12d06943496a90da0d4cc5dd2548a94759a6128401dba8e9d63da91650c6a0e4fb9776959c9f1821122a311c342cfadda29ddc6f269a06f14ad9edc17db3f595318031a16cac52f6"
},
{
"path": "m/0'/1'",
"node_secret": "a14dd01ed1fc242b0613e1e0911cc67c1b4f790860649068bc1eda32c89a0e5d",
"chain_code": "60219beef857bd0bc7870424c4f60464decb097a18ae37b035df22ebaa7515b9",
"public_key": "706e10d57ffa3c414081804f98838b7fc6a5a99743bf70c59b8349cf90c6689d7e3b1024a4bf78a842f2c7372f110c2ccaa28cb691a936b4c34bcea81b07d8bdc1c40681025fed8e1a8764c70d5c5b3533342acb9bf9ca9222f16ab4b8078e76285678b0c81068b23c19ecd6d6dbd4aaddede213c7bfce766173f4ca6af81732e36d55f29cd21f87c7f242bcd49b2892b2e79be205ea9f237f35cf9b8f86e12127bf975a11a4f812077fae0104a268d27c9d7f1d753300b131385891e2034c78a1132e2443bcce6e99fbce30e302f479f477236c5437801838d56d58cb5b1d0146c34d713e063b048bd09c9fb3283189d1bfaabd9f13117a9368d7666a45e66f617857c825570d83ed37c370a1750dbe94dc6a33fd347af03ce530cf731ac853bc330936c565adb6ccd44cc2747a988fc84955be1de017fe35356e006326ae94b2f290cb9dd4e2ec8c753f6584729670364470afbc3391e9e2a31ba0e279bd96baea5266fff1658a60db61a5ccc69f325a6749547b2e7a3eb3c297c57cd0e418e07af866410bf8df41c95e0cf54e3a2e806e46913df7504c6f4985714ee91078afd5a0150eca9e616b8079af74b07a740b0bd550511d7b55c32afaab6ecee140f22b0fc5efd09f5071d523a5582fb40057ec19d7081f923f7c952904c91222cc3ba127501d5e67b731a80d95c9156667eb45f77bcd49f40bea6d2b572d479f0c7e628487fd7e6bbf482d525381478d689296c8891c200c3a711cb035bd4391bdf787e49119cd9cea6277d54a12c99bcbbea96553115bee987f17b0e727b5bb95b104aa62415cdf632f7070c58f55afd3c8954bf1d50220f17ce50aae63c1cbd71b551adfda9034a4156ce66f467767554f8ee9bea9153440240156187c239f1bf13e47d5bacd72a1ea82de1625cf4b2ec50d02de84ae0726c0f0f3de4035e8dd166b706afff4e9cc709a5ca02ca6cf08dacc43e268b82e90cffcc4ff85bc33fdc8cf1ed9f5d3e63375869674f3ad249db87f306e26f17ee421f8170a23f1a6cc9be579595060ff96e0ece69462d6c360d5c1bca017137fcb7360172d66017686012112e674e7763028c87ddaaeda4dcae0f9682abb2c9a8dc3a95a7231c9c2921d27a82650f523654b27e7541db4827cf31f8d6931f03ed03323cb35ecd3efa55171a61d19658ea5a34dea9ef004f06327cac31a3a85702ea358840c82c3eb61044cd6251e662a6a0e6cfe4ca4307902ea4a917208c7a1825e7f3fa0d8f1edf139b289fbcb81c4c7f2e28dda39bf740e2872faf3e42856971693fe97fb6c3fee21a3644a6690b46291d339d2c50f9975da34cbcc4fb3ff1cfd95479f871ae8966bdeebcde31e3ce6225feab7599caf88b5d760f1f741fc5af49f5603e6f1fb1f3c94731ad7814eca19a59ae6e8c20929b4dc905dbb7af8ce727bc2af44da8c73f54774037b0b8aeceaeba5ed660f7c72f981899038898b2162812b8a3fb3995b8db5c370fa472f3ba2e33d5327ad37b3631734397b6e8d69dddc853cd1bd849759d98806ef5cee505e0b67f55c4a4d2d4520e4641a0a19e914d4dc17fa73a0bd8c586e2258334a8134e45e5ff4907ba6d009977eb338bd21caeb40734e652939e293978cd134b782ecbd95821a8678cf5bb707121cf28e1557d3a59302661e55af631f962ca2bd2afbd4a87ca6c0d79b52d0cce7dea11151ad1b8fdb7793ed91f7dd977a218a41c7781d0a0c525a87e0d6e49c7bc68613a4622cbc9af14d9d031f6ad408d8ad8c61b32b0eb32ae0784aa226f8bb793fd93787d76a99d49ef29ccc3e5dedb0284133c681b9fb81af6a823631386f8ecd3c9c7e8023cb9431d5a5229f81c1f04908e09fe2f036330e18e1d9411eb7d0d05cbed07739f573b584d6a584be46d2b4f4b953f1267ec820222a7711252830f82dd381be82312e1b59bc892ba2bd006174db8a08932156346e76e2e9b144251b832ccc2ab09f70d3de285bcd055a9b375fb7341ec187f893fe17339fe39d7a7a5d13031ac788cdf44a01772c7e4fe850439d423fb64bb09c953a0c4c62b80097c5e9f187941649b12d183aa06ca2ba709179656496c62b5550c6df724c7c2362ac4bcf4c951931c352ab1fdae7ba7d2a18dc43cc94c21c38a82e22bafd2b6349dd282da6242065c1ab43080fe9a4ec1ec0c360a71cb6aedf80e2307b003f438b631754433e7c9b11def6951593fbe510d6013ae93008d8e27a670dc942ee97dea9fac26e529bc90a95e6820b5bec82db3495372675d8bb35a24f8d916018423d79b4a93b58e1a86e0c2cbabcfc787d387f79f23170b1aad5032f119b8c24fac27183198c5419df0b9607dcd5f0cc700dab201100a8ea0c2f1f3bd268b5d7e8915cc9b68a5170b4a4abf95403f35577e5b77cf0b6c43c407b95ff5a04fa5e45d87c585299d14e6ddeb0f184b2e4bc3eb7702ce3434f34d72b5cae4e3dc984f5335d2c9b26ce3c10907c5a4e2ddf18a112a7e12a65c352a613fe3cdd1f37167d68c8c4426faa27c10103e699452cea90998b575cead44d272dfdfbc4746e76ff3e5c3a52c9e0c7e9decd273c6eb1e8e0ea50a3726484f99a733c32262166ea1ab0fee4a5380105a8069df7f2e0947c9ce48c07ad632c0d48e17343103a13cb1dac3d43077a9165760f39d644910ecdb1b0c8271b1ff8942bfd969fba92104132de298ca2da991f65265796678920ffe0d3e80e79ac84eec716afe0926134517979a96c6965d3e72f105244"
},
{
"path": "m/0'/1'/2'",
"node_secret": "5ec67a61dcd29fa73ab142c6bd54e745b4c47272f2339810bfe1f30a55342a26",
"chain_code": "dc7b0e1379b3f1acd02d1e25f13d8bb16830ca68c73b00d900b9030a41d6a658",
"public_key": "24282adca41909107102fff7cf05626d4d9d7fbfce6d4d9c66e7655832e9aeec0939f0d0b559077478e99e4af7c67b84ade0b19b19a61e0199fd756c098e59a6e71eabf0eacd60dcf3c222359fb05a16295780c4df90b58ab0d36cc11ffc01b9154e368e2c40a4e5b93455399f7585e0234f35d3151cdb6478c82824a2954567a2a7c0ef78e1464ccc4558087f39cca492c2c22637c67cbcc78d9811302e4dae652b871e692d1741255e6f1e6a8a5b97da431d19431b0742688dbfb60d48e28bdd54fcb361cddb4957be8d750a51d15077c8b0bc3d19aa67097786d812ec12ece2bfea48486c3f3b9c9a3091daedf5e518779660b8ff113a33935fd31eb62bf0f4a5e8f7f5a8317ff19bb54dd9f8db2698c04c0c4900d00428c386e4e2c4a5386a35d29f4a6a6ecfb5a0a7c5fd92db8a1e38c9a53bb05efc203a2697ac7e033592bb262a2f6a1219e7001d47cc4ff023c3b6fceda24fbef41d334b8df2a3d4ca53f85c7d73f6d52c59f595e811991b8435d26f9a1293047880f06f3fc3d0f3b13a4eb4272c63354fff2c1ef5032936df781893cd145e756777996181a1ab2fa5ca2c069e44a59b69b2ee55cae00efa10b2f7796f2f29bf5bd9e93b64aec9fe6e1a12e6ecabad715893ac732f1c37d31c754f62395a90d7a95280bdee3f45f1ace87b7b44a2dbfaa74a490e72dee2b8af60172bcb72e96cb46e4fcbf1d577be4048bdd230b31775a006e8f800ed187d8b7762fa4a892d4c383a8f8fe1fb7f32d8894c5a6340b6bd383ceaea50c7adfc390caec9e00610f53880f13c8a4f4d695bf93dac65b9c00a1cd5e8c3c7774182cc8b854ea42d9a7f4a6a284bc5d69f4a5b5789f5c857f0f6875581c5921218a30dc0d5db0b6030f38b425352cae9560fd6afc7a6bac0d9c93bec00a385e57e18bfc3791129f02da9d01ef340aab11226cfc464d4897ec9d5704eeadaac2602340d9a3d686375ffcb33c3e21235177383b35c69b76229876ecdb7879042fd490cb2e03a241e7bc58e94b8cd682bd3a4c29f9e154db507c31cb9201cbd41ff3c78952c0a7ceb86d77864571f14d02c8bba68926d216b13881c4f5b0c977324f22c7aec66a0d3fc3e88f20b126d88a97bbbff0d90272b11bad8cf21692196fccafe7771dc96ba64a50c6c2d052a16d2c2f66b3aced96541caec1d9921e1294fb87367742ee661c6e61a7bedf9cba2bb47e85fdbb2a91a1f6415746ac085d2bda22e91e8cdb00fd923c8c1f4492145c7065c40db2ab5b489e4e2b1038ac3d287aa2effa812ba0ad97988c6369e45a54ab5fd16ad39fe165b3c3ea8167cd8ea6652bc87704c596c5783e0a126195ba839ab27d50644c192918e2a3147578cceffc6ee5d30d9be797cc9c47ba89d5caf2dca8afd074e21563ccd06c838300fadfb48fc32415639b537c9a63eb0c943e12c4cf35c590f4b9f86b4a9632f7f5fc1afb66eef3c3ac36951c0e9c8947e8868997bd5920c12a8f3b49a2454b27dca4677ea3b52847841066525efce590273bf4e328039bc60bc65ce9c6bd70fec975b5938e45dc7bd2ad629ddab595c134858a08711a679ed3c7354ee5fdd4ff906dcdcc716734b7ab65797f308404bea9fc163dc8da93b4eaab60eedef616f0eee6211a62559e524b0152bfc6b7b41cee2aa287d485b3a0b0c7177edea55903e634c9dcb119e59d0240e0c632ac20f1c1f50fa6175b25f761280b4993c8e8c67b36f58dc273d9571f9fdb4d4892cef4d45071fb8303c3bec7cd0c94bcf733913e112fe748279e7e9cb6f6449c03c8dfd5ef8e71d65ee09f72dcb7f111a5f3ed79add3de512915f8198d80615e528ed2178f96a355acc820c1ae19a5296c833005ecbe368ad3e4ee174f2188a6663e1398c7c603a0e1f150f295e9bfa5c55e626a0af459a21eb2dfc671060cb1f13a20f47a444a09051af1859102c15c44e82a176d5d4f28cf3f519de2ea071f9e3c7446da94d455ce9003fdde09cfefacaf3b026bbb111ddfa08b997c708258d3a55b61c38a41bb8694367dd24fbdbf6354ad19955e8b95472cc12780e5f5d7aacbb3da3189626248312fc390a4e1ad64b5204610dbbc84c0acef2486db15a8bd951cb972fede16fa47d1af1e38eefcec161f20321476abee13f3b67e6fd271c9c2a95293bbcd7740275f3ae96677c984a1086612e32573aa1a98bf8c69d63591d2a22f94cebe829612e383395856ac7819dfb224f47ec87e425a9ac39db489d46e1204b0085bff3c5219fa8dd1075ab87ba436b8cc69ec6f7833ff324bb4f263a50478a4a6ae745798728cb24c79369434a363eeb1386b3942858fc7a2efc2a0174607d92d4d15f797d8727f34d21e1ac618d562b1d6f0acad06db8902b0da83a20d422fe19b3a070285dfe8d37efdf714a71f453ba4261f624088dc850e0a16f4f7c2a1b6c6693cc4a3ea76579cbc63cbd1a5d8015a003b86ce23a36b6f8200ee16ec27a0034c5e3dab186ea9705ef4575c0db40ae1d601ffa9a6469f507d98bd57668aad32ab1a92d6093b556d0a53820d4a534756d70a0fd811f11147342c0337ee302f62673d419b5aae2aa1aa949e6cdee096d8015ebda8bff91055b102d211803dacad587403f5dff19c4d276b12af0148248a7ac40e801dbe568db36c5d258ee29794358be73155735eef67cbe25049048f12854e537c3678f74ae6af82d4a9ac84136167bf0babc25dd320c2182e6e5e9532046d89461d85d0e38b538e9cc4b045fabf02e"
},
{
"path": "m/0'/1'/2'/2'",
"node_secret": "636c66df2469c084e524faa342810079b1e10c7ed92de94c10e8860695e6b56e",
"chain_code": "565cb34027c3b54773f7f48e329bc86db7ffc6f618b112af6d59a3f82501e17a",
"public_key": "bb8ccfec42981233ab44bb97889c0efe41798c6af212e4b34bc55f900f2d9be48c9bdc7e8fa812554fff2de6e9e89e0cd262352f01c7cef2a0a86f31b8bc049a8102a4f51d05163bef5870e9c12ba49f80a4878d78e086abb4b75b9a1d6aee3916f169552515a09a352bc1fb7f2858a7dbbd5ebe7c612a88f6cd5e4db25268f688671510eedcde7338fc615e40375ce4a9dcba6c49fdb5b83841a6ee5baca4d29f0484f9d30783884547c7f506310116e8bf46ac5a8aa0f8f825b69f6e247d7b95b883b59337171a681c1da620743289c3b35602f7776131dde043433273d5d3136db44622d8e87bd2221810ce6a143e40c7119504e184df403fe5c1dfd468689cdbc0813a6f767d58e1c3b32b0db6cb5d24964e3dd0b148ea52847d2598b24dc3f47bb5939d2038ef032f94eca54913d6ac4a8bfe6fd9476b2eb9d20023c92dbfca33ad1fa32941b06b309357efd331425c92ae295f9189ec01ac360b70c37bc17174f0987bc1aa5b74f9e5e1f4f11352fa8660c4985c47a72b54c74dae0f8fc183675a598e481d022d4fc2f6683e664255d4f2f9045eddf1816acb8e0d746e5508b10a87d302e79a28ebc464fe9955198671c2a553c85fdbdada9b222fe2b84cf28c307002388e7286e975963e92791f46c8866e52a06f416a771341f46499fb36847e46bd31301a06103021564231d70df170b84e3e6b23e17a697cfd4888050b36ee949f32741130fdf33fb41f71cffa5aa4a8cbd32dee32775b673110fc1cd67cf165f50bc2c07abc682e61fbdd42d5b14e8f8ec2e05cc2a3fc5e4caa46dc7a642a8271189288ec13a317fa08cd09feab7254acfb5d30c9024b6132b6f6ea635cc059d5f4c02d122b84d7f221775ecbec42577d38ca983d437b1ae930b134f02ab83285ecaf684c85f8227355c64dd62e5879644cdcbaadc3cd7d616eb09937967b13e897105e248c3e7fad540ba77bf77f7974eb7cbf63f958cc59d3eca89ef9a5d890e92708075fbda2011057c1dadd00d34f895640b970dd210e832a6aaa08a1e567bbd9198e11355a933a2d3e06cf7f8aa33fe60760a8e05784260c6a6f22dfd73be53eb6267231766bc2537b762b31dc5ceb2d8eb8137bc532bca2d678f8538bba7b04de069796d3d63c142e6d0f103428af084b00bdd7ffdca6bfd0ad41f6bd57b9cc9743c43944f86e69a4d28a68c7dc3e15f8441e024decd61e7a6df50f36ee6ae8752bb262c29bba079751214e7490af63e55816f91b578c0d01724b456ba5765132f108a0ca3df374ed4b4b6a0b9981827944569e29827eb9871bf940987e8f1231b51c0e553b2a9f5fe6fabb28c02f66d0a7f0d2fbe1ff0e0685ab32680d21d742fb8b158f3f47a8a705f431fa08dbe9bcedfc988318c8d3bc390ad434efde835d9748ac0a32a44830646de22465112ab3aceb2705827fc8364ad81d03ef1e453485953b08af3aebdfb8c94148b5480f9ddfa472ccfd08152be9e436d58d08bb9de636280ea2c2e1da706ba01f90cbdff1d50709c839ebf0d24e4ec555e9dcd19200ef8b70ad7f111e5500bb410cf1cee9d45977269cd5fc7d81834462e337ebc1bc8d5dcda71ab3f8b67e442ae99a81727edbc1b508baa71323fa363bed32548445cadb2b5a9c2f73f386dd3585c1e87ac7f95977892f114681fe48ca17a9d62999834f43a8b561c9c2cd21354fc4bc7cfe34a6a0160ec360d0e02b6c50afedd3c94ef1dd4a78a3553420c76ded79515a61a5c88febc0508577e40fa58513beca97f62abbeaf9aad930c0d722a32ca5329f6fd669e8eab7f7f23465cbea294d1a6a2f9490f56c5560dad7a777b4bd6de22e0fe6a863d44e626788d4aae4c3ac2472e2ae8e24c097b7985ae84d32a41ae0f9d06cc5f727d5381f0f2c632120575d0265145ccc138cd3843ba8e027570b25bd47ba8ef5a67780c60566f11387ee2e46765be03dd3eb24f42cccf4c0d1e9be4a683a2fa67c3f6553eff2227894a48f34c82b91c3a29bcf61915da00aaa4034ec43f60ba4a4f566e1e86e4cdaa8a05f67fed773b0faebf96b51c13341cad4e0fe64464814fe551f2ca8ee8e09a593e7d81c157b33dddd3b8865afdb097eea6c5d80e94a531e793552c003739c2356c6edd95945039ffb249e362b9460eb60504bf1ec67566a092ef89d8c50145b294771cff04787b7b71e2d9197e6fc5cefafb3b096bef6ac9ff66a9310fd854b3a859451345902446bc2696212472b20e8251f9496bb9b26dc2e8f5d434638e213add89584c77989da704645a764f37f6912b036a1d9711cba43ea96107a13232e2140df6ef16bf2924184844787622ec4e48c4a28a84372790c93ca90858a610ae28d6c06d3e294b25320fe39ddc702f8dc070fa142bd49621b3184e0df96952ade541a276d97065bd2c555186e51fc449e0746d3d615fb09186eb56ba0af63db622c282f01f02769a767b1be03df147b55fa30b2bf40e61b651d0f582da2dfcd858d00c73311af7f7532445f9508d093ece05c2c2031d2b91bec4aec5e693de1b5b31babd8214f1040a066dc5eb52bbb707a90cc4f12ad16bbf31c4f949628f10ba94e0cc5b39735474ddbc24044cc7567a57387ca5a000a0cb73c338ed6102ac584bb949ae0b3bf1bbb747df5090b9796f0534a5b1060f237b3b411e8e6edb95d337dedc3aeceb67b45e2284be300268928d4cd2f3758522906120918a12b2aa6f8772dcec2c0b005f922c5718aed348c5c91536cd20b4e"
},
{
"path": "m/0'/1'/2'/2'/1000000000'",
"node_secret": "3a7a1bdc57af0ee8a92da8ec4d900ecfeb269c13073127f032794af13f86c038",
"chain_code": "dc65d6cf4fa993c2f04fae2b41d70d8c5ba4c9d2042ea5720bf42a2315a6b7db",
"public_key": "5ea1a7a38b18d3cff129a9f91011ed911e3cb4e10db7524fb1d321fc306149df8cba72abf1a6830d567ddeb8e776d6ab9bb5c8952f8b1f3140e3091a4f7c34cf6a256e17a69dbc83811377f1ed8e6ce9fc51d5efa3d47235be9eb87a8784a65d1ba401b141313b81e5dc0dcfb1c5d35a5739cefa91f86d2f936ced4556ef444085567ae284f870dc7a048747dce574b03dfc4fc7e93e75c0cceff71b6fe75e00fd59dc6274705eb5367d6b5629363b3a8bf5436de576025f0064f806f651e2361beb5c8aacf68d995706974cbd87dcbe72fc9906dbe981672920e92e982ea2e959e318c8882242d22cec7c0e17f9b4a3eddbc37591109a1d7ebef0c7db7b620288ea4ac2381c4284031fa2faef8ee83fe997aae9de8e677002f1cfeba95def8b9c4c33a3cfb3e25f7a5ea8e07714e7c2bfd5cf21719ca34da5924ae8e44e0c1d700e1d1b1c17f68f88b4ae1d35dbb21d6c3dc5008dedc47dcb4f96830bb7484c286c5f9b7b8dbcc89445be5d724ee2c825681309094d26496047a6ac84c37992ca4ad9d184f645fc6bf9953297a3aa03a8429b5929b57e7c4878f0b75b71e638b6e12be722555653afa8c8f0a6ccbf49c82e09098c3908cab58ee1d30d1252af572a9cd16ef305ad2f13bac4a921b5358a9cefbfd33e16a29ea7bcf16d7b152a4220f20040a93871f5dec84fbb776a65614d666052701717d3cf4e764151935cd9d8a9fa96e916f49177ea7cd565f25de6d43daa9740f6ad5db83ce8725b6a96c1f2ca9258ffc004b7f7f2ac98aa77be2727b093e37318fbf1dcc9a2c2c2dded703e0f0cdfc608bb20fe0ba936b5abff246f4597fff8f3ee7c673c78f1a161d05605a38664b49cc95800a05d881f94a5d0c8a014ca0a45c79f878a5fe3dda66d424407ec195ba824fc406d840e7b5c6756403e3b6140844be0ac5d57a79b8d6599ae99da5ec68b2b1d0fc41b5d16b2568fbff786a6d600b6c39e975bd7e511ffe407e6eb957ecbabec2818c82e6f038fdda5cede30a7c2acfe8d2e1a8c9f97c4772181a88810136b536f9e912e2292a186a8d06a990c083f1662f3a40b44f18ee87ebf3fa1478239e267fb5a08b2122e85f195ee77ba642ccc2ca252159a345abfa84b0c8b665bccab0ef036f1e2e1f12522aa4c2db4b1ba5747fbe7237d58798b35e9e3e735e16358b36b031a6a74b39337d3afaf456e1af1e28bf12081ae1119d04f073d8f1e2b9c1bf83ae6c35ed7ccfd364268931a6a5435543ec578fef9dbb2468b03e3272f158626403eae1f04fc873bdca9f3e6466841c659372a34539f9d3646561a8d30e4b3f2b5501d4241f37921dcc731087f1ff2527b0a18772bcfc2fa94d7bc0aa683c1426b30868ab3420029aace8ebf831412d5516292ac35b7ab2ba353dd7ba178fbeadd63d465a3841957601df1317b5c5968a13da5d3de6fc401205794a22648fc4a69424aa3b19f49b787b989bd196aabebb015428e64bf8287e198219e306460b1a5282794762e16cd04cb549cb3ca21a414fbe0acdf56bde86e7889b69452ea6ce55828b5a5533da91cfaefa752a23bbbf5e7aeaf1b035dcc7a91d9371ca2777c1525d707d0023ce0496bf834da260027d0f3ecef926d347c8104dc614d13cdc757d7e4358a794b44b8160ebbeab0082f2aa80dcc989436fe205f56bf3ba56ecae4a99360fd9aad0b3af4afa02c74f92a8cd5f17b1ae302f03b3364141854608334941420f9190529e24f3faff8a8de069f18fae0ed64e71adffedc97bf2853e4d1c42bd42c3f31150135551aadd7ff98cc1544264063551916a9a037397f688e56625e07801de94119dbb775213075c611bae8aa9fa42f01b1f664aaa4d34dac51ca738d9e1d11ad5261b1a35ba0d77410c3c2abe8294224c7d60c52907f29c967215ce7a621dd10f140710439213ddd17f5cc3e0a398029d91b2b53b9bd21ff223b524ab437f430f75f184b7365b66bfde265c6edc9dafa93f8e9fd011256340b9c607127451d8b7aa20efdc7d5d46c33c0e875b89702b2f8ac5973ce1db99ac39cd8000eddba51f1ebfeab4bd8935e9999b0d171c52718ad83cf76066815f75992a7c9f784d6c5d35fd9a7ca2c2b683637f53c21609d7f640b2f5fa7b4447b82a1b9bb4a87ee27b4a898e1e47a4f5b9dc884ab74b329c872295d99c995470920e5be9800f616a936222ec0acad4aabe9e9a99d8e569cc8db78e170c629d275e55c1bfb2b326e54463655e948abf8704c86b5830b6fd28cdc37019d280d404c9ba7d87ab2d0b01068da64d8cecd6e1cdb1b68654c788d16f570559bfb5d0205f54471018dd47b040cfee817e162c4812cd8479ae8502598a5acaaa365a544a526e5b49538ced436dfc13948285b1cf075e071ed5c97268d560fb7d4af5d972f5c66e7e57ca53d8a586289d778f6d34e5e006672d1e618ba62dda14f9381d38ac5a6a8719eccce940a0f36026b887341723e1951b2ea16527f4ad33645da563b457a61decc6df3ea547723881d4384a7e775d633fd689bf8336d62d3a585395e72544c3b12af268f843fb3414c4420cc1eb4da3072081d2103d0802feec2f76ffaac313ed70191a88fd6ab8ebc9d4f495bc91981c6c8eff2da7b1c81d16ca1a708bd0b502f27ccd1c9ad0b65618a08fc4b54a40171e012001aaa7d9aeeb359c896b12fecf61dd6d1b51c7135cc213dc3d93adab0f4a9f2f0f2b656a098ed6d38d598633ece23a198c77f49d80008e669e026bdbc4d"
}
]
},
{
"name": "Test vector SLIP-0010 2",
"mnemonic": "",
"seed": "fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542",
"derivations": [
{
"path": "m",
"node_secret": "67c25895a02758460a9bd084aed3fb3db0ec33b85219d6723e356013da7a6714",
"chain_code": "0e696f43f0e71c1c9febf61f43f10903385b78cee5871915472027b25ba755cc",
"public_key": "76a60a07293f849edd2a8f5afba5e567139448c27935f30957765d21c411d0765e54abad936f1223aacae11c64d185f0ae0e6c917fa805bbc7bbcd4a4cf66e37a6e8ba10ec49af9cb17f6ef554409a8e2d5137f2ac3eff1106fca75fbda244ed72115afd824007d0bd105c0d99a39c243a2778e679cf29dc0a53f0f3ae3b28aec16fdbc0c103d814c25b35c9a61df18d3c622e737c6c759e8c401b0060aabf50de1dc99f28cd5a37b94c1cbd8c4e6942fa789ca74c85dbfcc027e7b4989739d569c492db783b2808162800a4ed42fe4717348af2efc2f097916595d4e8be01ef3ba97d172e9ac7db59370037a92f0a61a1ab7f98808655d60d7fc5fe6d75fda03977444bb23d4111769db4cba8a71aeb0665f9e3c3b0e1947b25ee9533476a92ec81761effa077daf0c4349f69792ff778411e853aa3442f45bb456a7e845b6cfe1d1b0cabd7c67341810b75094ef5995fce1ae570a9be49304494afdc1ae972ea5977ba640ed4fe3283414bbda8986b3bf0e529c6ec958af533617e2fcbe56068cf39a5591ecfd388b69b37b7eb0c38179c3003e96709850791ec266bb42e9f3ff188bae23b6d54a89519cb50256b03b5da8747124d7f6c4958dc056a832170b479e88160e0ba6f89889028369a4035fbd1d9617612efd4a26a66dbc1db3d45e9ae8d58681d8e7bfbac623b01a2953200ec7b6e5eb072e71d0cc1636992d1c681441236296a95d33b1323c17c4eefb46126fd2f8fc471ddfcaafc9b74c8bd94aa3725e4012ed256c3aea201a09246dad6e867df6e43e3152e38461e9051fbe971becc5a099a189d99d1add0ec82a7179bbc03d290578f472d7aae0cfd117d97fca5167e9abc62d73416d0b240ce8d162a1e40ff2a5cd8840b7f3372a7cfc266ee6726a7d6d6b5646db95664d2f93423153f1b28cc8c23156b18cfe3892e771e58f5bc682d575090e76e8eddffbe2dc900f4743307c5619fba2d3b16624c871a7dde495829ec6dae57d6d5efbada840c01346c3f49ebdaae5c303791891c690e2ca00b2059a73ce50cdc9607b965f9581fdf929050064e2f3f907ff1a8be9a0d18dd1cae5c6ed5b5140bccb9ea06cbd3f61da5afedefd0fa3ad3f1f7c870c7f9778dda0a7c42b59b6b4141af796a7e4423e531424eb5228b4a4a453284717133ed4f7d691efa2d1462e15f4bcea0aabdfd14bd63a6282956d3b6714e1d025eb5f0e76c1fa4f9f9313c79a1266bd6ff6c5eb7ba193172e669e8745d8ebe24e4726b676eb8d2983eecf68967d55db41e2c57964a6c21b0db94238fbf265bf9cb2ec75810911064f30b6e4008cad5b7842165987ef26fa456d68c3d36ae9b3b39539798bbe66fe0d3bc0118d3fe0d0e616a40f346bf9c021aecd4bb89717f7d3ceeeba7d68098652da4f7392ff4d92a625df9218c4e7e694d006c5e81129fb5c81fba57e62fc1c78d70ddfae102c68b29d017360458893eb85dd845c64167cf1a394c0f8769c6e2923c6f87fbf610dd2deccd3f8743c1d61f19ddc652f4706c9e890858bb5bac459d7df95cececb9aedb57eebf5b9652147e55877eda78217044404101a4c26f8b746d5b0ce357034a622b047ba2eb95aaae0a8e526ae98948c4ccfca9ed63f73602c032c665b13d2e855b1fc06a2fdada049dfaf22f23b1d9beb0c7ac6e16f14e1c8df845511f3fd96c693a9560c25e41ae951e59a3d1fb12f659d4d9260583e8503d39a67cdc5921fb32dadbc1f6154a80b5c465a038f58eeea63e5905bf148d9c21a69b4a60cc1feb3151b8d537d6c1384c6f33184908d1f94b0f34c60d8d2d705cdc63314f51d1981803154e5d395825d6f61c39cb0254f40e2cd7f6e007ddd5f86675048cc1fc29a2ecf692b7ce2f6aa8fdcae883be230465c7c9ed677a400bfc9a57ef9abb523b01beeb5890ecf8c7bf3171e185ecc1350b57acff9085a798b191e3e0077adb5ac38799c886abab2616740cc9591e601fc595f9cb284125a3f1e6a16e127163e1fdd42ee7e9697f3e42c8a1609c148ebac900927dd8f11698246d540482b352a286e3ae3a570fedcc673a0bbbe0d4af47e7568d145b749f6f11fe00f8c25a980b19092165ecb4a825bdc9e0aabfc9f245ee8952c37416dcc1343560698e975e8423d075e2ecb338640aed0161d8d60d729ef4b23e9688d584cb754e87f69c1440c872e4ab9c0815894d6ded585637f11c8e24eeb326ca0a5960e2291948e767dc8b5ce9969648a680aa39f5b0dab6d62bd5e600544668ad1b3055137368b42e08c76f890339328d3390677d993cb6a077e340454d17e01e578d43795b308642517f41691ec8e249ba27a4d9e31b8d096b4b05be1509bce6a0c97d683865d8a8d64fd4300babbae5034d321134c38d49088b21b4dedf9ec0bbf9481548a63d702f59a6a7f4f7cd730ad9922906c79a7946e3ab9503cda6c1fd78dbe3006c089b4c4ba02f97568acfb998d0031fe91efe4607b457cd190891feaf9c628312b912dd314219184ca01864b19bd783440f8a64dd11156bfb0ef0e70a8d44f4bd63f157e22006b1497126a6f3f9c3926ec7d6d5b2a6ed35f765bd24205a310b9b53d37af2d9929abc846995603e5b15d056f4ad9f3df174a132081898e6b145f5249c63128b4fb31d3e3ce5ecb1ba2cef8d0e2a49ae2c367498f0d471e5cf293cea7bba5400ccfd343cb065d4074affc545cde44d1d8048cb1ddf8d9bac170d92aae16193ccfc04c3a53876935405d559a3488cbd"
},
{
"path": "m/0'",
"node_secret": "e8bf8681f4a1a1d6c854e69b75fd0b39069b21ad983d74cf901dcb01985e9091",
"chain_code": "d977be3c8525364e155764ef76b985126d8f6be41b55e6e50a9915ae29f27a56",
"public_key": "ee70c58e80c4cde4d52cfdf7d0f028a07131e17bcc7540d2e0fa17af6eb8aa950012d43b5406d617ccd72b89fff3206bb033545fafeb2d4d800a5560f5bdc710eba65590887b5e0ca836168340015cd35641c080f22da1bb8ac2802edd04c0c38d00ca89565aa44ec506a9007e97515fcf2e5fb362ceb761a09439d811d7281abcae1483a2c11eebe3de9277ac464a3216ee43da900d1c32796d6748aefacd4187aa3a3c6f58e76a1f85d0e2c8bb07a65bde85a4d52433d122ad3e10d0a2a84249efc8b4c6e1b3a54666ef060186c30cfab67172559c0108829fc33f864ff9abc5ebd85ce58d527e8d6d976bb15fa1620456f971129aee3710485bf842424a8ac3626bd7d194cb51eb4d4606af79a5fdc704753d7a0cace02f95beb4be839c85c5bee6fe1f4b127543a1bf9486b6b2319198a483d8ab8037ec180427e55388f0e165e2ab7e763bf80dfeaa308b97c930b6bf13cb5149707a11d24260ed4513e14e7eabbae82a95b520228c0a476cbc03cf659254f47454783c3503b90ba58aa6574086ba80129c3a81989c292fd435b729d7b24a17c3f52fca9a14aec462217314cf780cd4813b8e0a4a6c60fbecbbbdcb63f4528f7fce8993e28fc13e7ed7e687d2a3ad2d562fb9f897304dd20ee2fa59d68b06cc04c4879d1ce7e08c8a1a1bfa235da895644b366b2fa3ba67f4aed57aac430ff135ff86a5d8036b41b6f6b466ad0c5c1d3076a810c87f86f6f5fef48b195247370882105d161584d2e53c1cd4e3b425dd6631f75e8293114a4b35a6ee6141167be20215452bd2a89b9e302f6171e119e070b45d0cec366286438e06faa8375feb93ace36a854ee6fee0e3961f6be24725437f3c61a57cce899cae5219b7546e55fadec98e456e54c48f4ca5351a5772224296f5102bb3a60ce92a2c6113515288f1a74bd9b2a682301b27a8fda8b70c635c44b7d1f0fe3ca0892c881bd7ea9bbc98f1079cb981e998e1e74b7ca7b081349816ead884e3b5aca792722ddaeeda2354f648630747bb9363c90a32182e215efa01c1924481a44aa6ebcec85f426761d1d8306d3c2c808e6f32d2be3a554b1f90eea75e0607759ba8f3f76b7bdeb21114b574c9f86e91c4f11365bc3b45736aee54b57b4e3e2962e1ed11b1c8271f63fa9b538a7952bd5b9367e40345b465cc05859bece9a25fc0c9b76905f89d3c293833158366b7b0db7287f524a56984e99481bdde81ae6eb8de7f3786f235adc076280e24961c5b1fe27139a49ad68a5c1a7b1cf0d453832d0d4287a92df02b321c9690ca4a4b56265c38d744ecde948b72b986a2b545b53f9997f74024041bbddbe77863cbcb99a17dbfa9fe00e14fe4d28f48c5ccb0246511e39f1d015bbdb5846dbd762d3b98d29a71106dca2ffbf3e2301f17e6df25b84bd8740d1dcfe4a0123555622c0a6c006bbda09970eb3c2ac58a5797c7dfd37a9e8c2b0aec1434b7f737a43e175cc2a086d0392d2a1e7265cedff34eabd2288f78d9cb39653bf90f33860fde2ae94b0de3a992a9d22a9a1c9f8046d4e1c20bd13bf194f1bb45046b1213c2f071418af180149a363166383c2f757f4d9deeff6d982d8547922bbad52fb8605577c932f5103722c545fd5dbff6bb685927d592c0f417b3445d8f8f35be7883449ee5ca69326a72b1f270aded626fc44ac3b2d2b95d3b0e6c236de2634c1d2a3b2098beaee54fb400a3555190b6ef9153caf835203a0c414acaf9e4a459b61b0043bf2bbfb59c0c2abe6712aab71a80a8e830043ab019390ad256c2554fd2b61c215e0181e583e6e62571b0c5d4d536a6b79adb423c82777f73e5dac3812253432c6cd5560964ea17d1a8ad3a7f0682468816f6742ab535fc4ab36abfbb55102b4bb3286a2557a70c3946aca2d493d50829210436e6f2bc972a02915d77259460e223b79e527bb8abef08ec6b9b37ce3119f843b7dc0e8346f35ef4c3ce2d7eade0b4477e642f2a0921256bbd36187e41c4e600684dc74ac419b2c9dae7550c079b2747222426024ce15b268c5934aefd537fe62084330b80f3b4cc89392247cf6f1a3318286be3133199e14dbd227fa881be16793965e408469c7bdeb244cd9eaae0c5c8dc9aed34da27c2e5d690b11634e6baade94c003b1c0cb9002e1dcfef504089052c6e6051208901146ba05b7454ade4856ac786a0454680cb140d0f60c9f68fc3980daecba970331050e4faccbbecafb44d83e0ef921c26b38a816220487bc7a2d472b5a9ed5c9eeed310937c47ddc48759d67b58c9f0a7629801e837d2623d463115f837d14d54bfa0d8841a1e26345d1d9ba2efc484241fbc5e496ffc4a94d0e645b1a391e3ce50db61d11e6f44cc8715417c53eaf91ace70263749bc1bd073e2cd4919cd0aacefe59eae8472a50b02ff00f8e203b02d314386e91a289be47a72678f7a8d88f140866807c5fc109e811d69f3e522547335e9e3fe8078e3f086084ec06baec52c2d7d428bf602d2fe8b399b0b7c45378d11fb52ed8226929fd72799c2690c97f391626f22cafb3330bb6e6ec2905fc2e9e2cff90182a6c4c16c30abbfbc825bb25ca376bf694e442004a687d696787d29dfbfe2fde664dbadfc3cd00a9f855cdd2f154364f01a4ed7926a9df86a1480418a4b89c263589a32729515baf2d2c342ce6cf588901e38996254185ccb090387aa2a06481e8066919a0f461fdeed2ffceb1ab9a99382ac258deb5644fcf480eacbffbcc156c50b3941bc3a67"
},
{
"path": "m/0'/2147483647'",
"node_secret": "4cfca7898c7bbb4a0bb7dc3d14a2d489eaaa87f2c4593985525d256f93cf8888",
"chain_code": "45406bac7fc36390d89ac938bff6ca1ebf9fc6da2057d1580b26a8f2e25a0d2f",
"public_key": "fe8cfd0a323c2345152ef30feb7392aff4a43ca9bf409261865ae3323a156a83536712817ff4767bca6cf799d593ffb03ae751a77540765d979f92f91f31c00cab1c37f0449d9c68abf4a83bbd5d2a18620591486bcb4f264741ae679cc3286c0e8fc55260471111ac6fafc996d4123dbd274cb69813e80b12d1ca95eedafad899050950d8709bb38a3fe2a3ec55b746457cea19550d20d0829ff6e674e8c12ed8c262246b540454fbc4408accf9f7febea988505101522b58898520c67a4c40adf7364496824f809e8dda006fbcf98474296cd3580983bbe894441b17007622762592446348eda3c24107ffdde62c62a1437c91ea44b508e37d6103fecdcb357b4d96086f063d1ec2f9f01f29750716b88098fb720b1b751df3363603ba60d4a50b8455b4ca4a94796ef32b7c9b07c86bc90cb21dff4fb24845e6ef2a7e8eba5398e966f5a2b44c14029307d7e825e52ca5ef6f89c79b98bf0298b627e425aaab65093db3d414b9ed63f9160943fe5de99dbe711654c82733399d4ee86c29ad22218ca5c8d2e6a18943ba3fe47acfdb4b5ef0a8d0e8a3710731c9dd99b6ea356abab2bccdaa021b24a9f53aa49aca40d01ec1255638ef842e803f344e0138afec7ef8010e8dbf541b13b6ff3206ef9547475de13477f4d9b0005aab196f1a1ed9e15c81ea375e6e5eadf2e3202c645c9c27f486dd9f0b8f5eb0e428ed635313b633e9cb22e8abc563851b70f3ca415ca4306a679774b3c725dbe2713aed76a0e9c25ec0761caed713bb693bc0b1a0265bd6eff8922168212441dd91fb1ee0de70772cb7765b206b6ad60d1eefaa7cf99d7cc1932c6dee141941b9fbd3182572291ae11e406c02d91baa284f5526085b80350150c6a53b5956a2fa5ee30470ad21a17e527dfb41de92261c78e361239eb5cb3a29c41f7669333f12343d2efe3b151646079ac05e5198b55b94ed3002afed414ceb154cc9b1a7655fcba051bc106727c7f77e9b14ed7c5322cba4c4b3a130fcfc19389156a00b0a212359e0b1abd24cdbe11a62bf6e4805825cc07a8c3f2e78868b94833860416aedcc8e0118eeaeaab60aae932f66446f58f4136fd7f0fe450413d36833ebea0e4b6948845e4737658a7a761b0f1739f71054732e7b2904be3c9d5057966f036e27a8bc3a85998984ca1e0ac60694912afa56c983f439985ca865c7260120ad5f5f46341a4cd4940f00dd1f63da81ed818088d1bb5a148b86d5a19ee7a9acf28d56688a2f509acf83c73f7440a3906541e2b3dd14c313f5d3b132f594fee4782abf2a3194a6cb55101e8022ebbd97df3574505087423029cccca0cf1a68fec85395a7235357577c3529913258c4bd49b3164131151f759fc36a1af9d7d91159aa3e69e740f2188156503d671f83a8eabe6e2dcababf57a7b6416d997033724afe0f3eb9088d86b5c0183ec03759cfb3705b9e0b5e5beb31500b60fd4bf98d3268f3b490e451d49be21f0f399d95dd2b123297f6ab0f5f2d006b646b18b779900257ae196128a4a9be3473ae1fba8d7bb0f301fa6b4ac885f15c500220ffe1115cfa16bcf5dda7817f84ecc255a225a7cf2685613107c727f8172d76b5a760a16e152e9c66abeebca4cf0fc046c4164081732cf388be57b1d87cd7a3b198ef72ba4adf5c3f869c8bee54914e1cc4e7968fb7f19f4264c8e24d4722c00f3cd6e22c194300333d2481d0ab9d3f33ad859056aebcc26500762e82d7207214baf3d862a3524443a7cf2458fa930d6182cafccf09fc90304e9a56c80f4c60f08a6888e91ca3c06ffce2883822042db6fe0df3b8b27552d38ba76dd6ad8975c4f2530e208309f865f1fda1dad137f61ae5b94a879d2342c8b8987b62d551ba3ac13df8786ea53bd7e866d97359108e9cfc9236b6c29fa0878f9ef8783b070e73bb8e348d615369a7ccfbe30f3dce0a1e6464f95f7ba8475d6d97c0315529a7e23ba54d61e4b6854ad9ff88446f113db17fc19ae9f1cab5124530dbcff3c27c925e36412fb9f8bbf1d261bf56506d2fadb69d6569668dc2f84690ba571e68985eec6f3375d314a490a5785509b631682015fac1eecfdafa1d4756620982e83ee99fe4eba735291f4eeafcfe3737ac5817015534023197d0238405f4362abb4a182499fad2b2bbb883b46b718b433d06c798146ee3886afe052d9a56aa89b0f0bfcd601e6c2a208596ce1dac93f5dbb51265f84205b3392ff075a11aacb3cac068414bf4097ee1bd1a1a85c81165161a210767a1bf377a368caca429783079c8880557588fb54c6b73768b29a2b7084d5fd15c6f8f6ab2c2813e41825321f2bfe7e30a531c13eb803ab183754fda6462e4507af458d1aec5a0bdf1c219fdf664709674479d58287cdd5248c02444e1c269b5125fbc806464dc8ea8f9b5e75a11a323e647a46f935746cd69e65099e60e0a8facdddc538fc9fb387cd90ba10d5ea4a965bc62fb63b1967137f497eba3b150cb5f688ffa061f12851a65a674d09cf68b3bd7a78220b06a0f738401835936f70824e3a3a456c5806019b816fe58a0ffb4e5eb0fbf49f7f2248abb4519ecda55693ea59a9c589e6df9e986127be75499f948aad9d79443b9a39694066cd80c69071ea77bc7b6bea6df0d57a19ee5491fb0c8d253dc02fbe1f8105031a4c56090d9c51dfb7536fb899ebe5a5ed607c15cfbcdfebb39274094a58895deea18c5c498b3b7e330faa122e1c3cb2e3dcf5dcae8fccb8b7b19368feae8"
},
{
"path": "m/0'/2147483647'/1'",
"node_secret": "969352513da88e996a133be855316dc3b46a0cd9792ff340c3bf5a06fa826c3f",
"chain_code": "10ffddec3c4acf719afe528b1ced03e0f7d8555999c11b69376bd5ff4e04dbea",
"public_key": "6d0316c401318c0f184bc9b8737993fefeb0d955f479b457a5aad2091f2cffa401d55f461497701d76ef471cd79adfa0dd3c7b9876d7ff884d3abd5a6e97cbc1a1c53b6788d9f3c63b32c811b0ce9c7a92cb889cfb858e4ca59238be39182ea3c7d831a12ee2b7cf518978e102438d8a2e576bf394e74f4448fd306656add1eb710f72d0bb60fcf6f17124196b20272e4259f01f9c54c697ad3e460f9126923943bc9b08f19b0786925fad4f6111f5c96928a5ad66b0e6c5a3544a4c5422a725e8c1626deb4ced4f0d3d53132e6a5342ce9953ef6473025e971849a1ceaafd9b2782495b3128990d02833f753f7d0cc1cdf215437f863b6c3c274e9117c1bb3d02f2ffa28147cfd280a541d20b9e2092b90ed0d94edcb41cd752bf9e1650840912a722519c6e0a8ecb83a7205c185516aecfb9cf693b0808fe264662cab14980ba0f47d3feb1c962e051daede8c5397584dd5b8da23355f3deff9e7ccd00df8aacbfa37430769f87874baa281853bcf19a5c7f8606403fffc6eaacb7f8507bd37616d64870053f892b41ad49aa70c827af3b77316e8dbe14e6bdcc66ac71ab68e6e6c5168eeaa91ef25c13f736460449ba94b3b599d5787da03e65566e0ac1ad247f43bdae9d504d0ac02867d419690fd30c024d47c4fa995d608b8821589a3155a6a6621b043528cae9b9b71c773827c5f9091ae26cf0d191edb2269c38fa116e56593c150e927ce6b5c955bf141676c5d085b6bffa06358f18a9e3d6b95c4053b17d001f35e901974f01970c2247bbf32b9e646070e29feec0bd7898befbe4dff58fe2a863cf27fc1d9e1bcd495cfeb13df1d072867a1ce8f1a6457304dc978849fa3d9965959839209e17ade982916255d96515f3cc96ab64db6cf15ffaa98e980d917c3f1ee3e65c9712f39e850c6992e4d77cde73635e62f7beadba4584f079ccf64eee9d2a6aae4986d368ffcef6936fcd65e5701ae1c83074d39edac37de642f720422e64b7d998dece159cb14ba1f4f0b8f4425d500b1671e231311c3571a1178ddf6a132cfdfee50027491122be90783eab729ff7a174a770865c5b2fe2e2a5eb680b618828c7ab20f330ae7c64e70f7799a3aaeb4f05ab0babe08735c888636eb5b5ca71a0dcd0813181d99c4e8f6f20f34ad5dde1bb68fe76a190cc2784a443d088bb35457eb922a3d324285171564699f7d49295b1dbcf6eba9b475032e9c9fcc27b4dc7476e9ac44368f1faa5fa9820c08219bdcfb50eb4836ef8b055ac13874ceeb61425ddee89254606e09ac0916811f61225a6680794d592ec880af285bf124ee7224aba534e692aa889f55519e4d845a4b9d8682eb64054536950241a9d7efa862f304e8e63871f0ecd2fbff6c5809c46659e624b0aaa501efe16460783c05f1c2dd40ab1fc4ce2c52da2215d217c24cd99a6bc53480924448dce69e3c8c54776ecf0d5940538156688a2762c721fa4c2be5ac830aeafd88ba7c04f3f06acb13fa59101fa01dd629f176284ac627b77431ada912073a6ca5b9a83c0423f4ecdb378563bbcbec9f93b75263cf0f4282c8bbe037225cd7ca13d0a4d416d4ecb5052549ee82d2eadba8a75cb8d6265e8cc2d6744352b46c4a2f31f0eed396e2c351f4175cacd27eb5141fb3f930de6be549fdd646967e48087952b16723b43433e55eb71d710ef6f9474c9740d3ba27586904879c63ae958dcccdf5f92abbbbeec0f4d592c23b639d9d085ba7c88c263c397892705b181a166c875273e19797e0d3c96fa9c0d1ad94b6760d40958089b640f82f0dca19ed801c396bf91084913c1ab3cf74237632999d6c1c62e46d78b506c8eba3675d330253ee0c2e92f297d0bd2202dd69109af85d6481579163a0cdc21ae3acf146b9aa69b11dc453c3c1046a569e2bfa747a2501d46e23186b0ab65d27defb4832933038fcbe338f4d970e492fd75cfa9e8b762d3b097449340ccbc02493ec95d2e1a9a8093ff78a9986ae2f99e31e0e4688d8ed1895e5ac4ac703399a88875a2aa2cc20c96581ff4bcaeb0e6dab7369dfbca09107d0edba3b1f081956a72db6de053804daf9fa6296d60b8023209323b3156b8423273a447cde9a39cffbae7bc58095450935499462681820495c427a64c4275aba96ffa7df024824fccc1d98bafeb965d552e846bf0bd198acac408a1be2c885335d4cf61f3ceddb5e4892053ffc75821ad2eae41450f8d1eb02907a6c57d527c55ddf2839a180eb4ec35177db6cd16414a4998d7b578ae6477f6b2b4ad32ca7997c89dd7d2f718178aa367f0e484e007df61d078f31770050cffbd9ec7dc377ee4d4f17900e65a47f70ad0d84395e0c188f2f7e681da6993df5f81495d204c0fea931c03e79d360f366dc3561e8ab528f4a60e2fd659dc841d2ee502fcdb24f886e6dcfcbd77a1848ec5676af3c787c8dfcb1e95be6626accfea8e9f67e391472d7b47792e09be33a7916c167e60fd2001d51dc31698b8a43e042eb35fa966d658ded6c487e3e560b6ce3e50ed4375400da524d22e628991ba5be05985579aa7ad9e891ec085b3467861d9cf596bb85a4682ad0189b44253c5890a01697326f791546e6e4e6fe5bb6dc3df5a5b44f6a021e259ded783c033d4bc04b11eff08d6bc6b6360d097397db92e36e45322eaa6ac61f9cdd6a785b13ad54b49d1e77d1f79a503a94f5e3821bb2633088da68e39028b0a67455d17184fa00839d07867f93b36c101f488c7c8a236a3cf2d4469"
},
{
"path": "m/0'/2147483647'/1'/2147483646'",
"node_secret": "ea4de14b6b31d080ad6dc5150642e59e79b83c4fdb90224b1c0d27a3240133da",
"chain_code": "3580d842e9d8d6a072b77d95e08b0a87648edfaec9bc25cee6c9127d1aa4a679",
"public_key": "b91a1ee2d6ff2f4fd8ae16dbd040249b4bf2e8c8b1b08e1d42d7e3fa01d3bbdea21f82d6a27f7a640f35ec6aea9b8c14afaff52d414c0168b063e066a395622cb402dbe025540066373aa454c4a110ae43e0108e1eb09d5dca42aa378453dcd47ede3321963d81470c0b359d71f66e5b47fa5a2f25d27f703369aacac1522326ea8ca55c567d0ed470d37ebf4e13e7898f6e3778892bf1e43ead7d3a1a4b99d70a3867c525b263ece00643420eba5b185048473955f4b3bf64e44325cac44e2abfe580983fb83e93f18fdf741b085be73ee094deed50772ddc2f78eda372c89ac33f61ffb87b7c840e5ad9ca208db37fd378384751e36bb1e2b5673da04f3a4217a182029552997fbc495a29db227067cf52fdfd740e7e1d9a3059058513efd467bfda97301a6118f3b8bd003830908e52cc5cf9cb9a19230af3239185e4f5b7da109dc55d0b865a4c46102619a5b62f3162fcc425063361ef69511ff3a2ae364b014a116ea60fbbbfd9d0948163e37003bcb9f833be3eb264d8cd95c98952fbbd6bcca75ccf7889b60cabd210d31873de24c100d485e3dcb4af9f32cc8f6f1020831ab62fc580da9b3086b8f20aa92260a5fc86ddf4bff593b303d56cce6bd8cab3479c755ec44c99037cb11f908b2eeb53d7ab13f51443d5ca2759eef481ad423d62574ab162273ebf012bda195a6b1874dc3943a2b243847d279b13a6ec661f4983e4acab7831331ba7566b2b93d8c30ef9fed52b32063f2e18a5aa9575813dedfe3ad9448c6e642d5c29fbdd15bbbb121ce42ceb847014c70588699aaad4ca3749b913afdf7fa7e480312e10fdefe182ab91d5d172c0d7ca4c0657a9bec457afe651f0f4f21a3c38262badfe32ab7b70e8d8acca67c4b5b9a94793fd998051dddb1757a2a1374956c9781f61407bfebfd4883ff986f3df228da76a5c732d07f6ca0b11fa9ab37ffd145141c206461c9fd937b5db6b19cd8a736d4d437c71dcb1296242e87ee08393f053b5a49285dffec06b56ef495573722769ac68e4bf88b4c912b2c7e708bf737e9a51e6a29d6c8f61a62a9da167bbba82e47d3fa5b19de5e96b8448d791db27bf1831809242ff74c8498f97e29e07e84cad83d07d4d9727b700d4c78f6c6ae66443a6653b69deae1f1b690a17143bb4126fdeee950073a8e057a6a91f2a80ed1054e84302985f60007606918313aa16208425307f134452e71b1be052ba3bc192b8004eeedd4e742e6098ebfbc37d75480ec8c991e78d43197c527203572c4d08cfe5b8e899f3fc343fda649329622d84eefef09fd24d8f5675294fcc29cd3806779a6361f22933b4ed071fd8bace31c08d54c0936c24b8c2b06e8db494ceb9b8e8912a5cd5f290cbab88a4dbde9e8d62a1f947ee0803fe64d55bcacbfa3a47d8d371e78ef2aee044752bddca7e3a1de97ec3bb505fbfbec9f9dfb38c780dabe6dd10f8e25083a640ddcf971bd51425801946645f2d95d706e80302a223fc059f17b4ea493f26975fc7f8f61428433518fda66971610f49b66d96b8cafef6f97b0292ae14460c2c09eeb87383e21ef1732ad4773287f2131f4b3a38b9cac271e47d4db25146c1fec73455b709e60e6756bbc76ecbfe8dd5edaa7a2d1c2cc674cf5a4cff883ae3e745c1c03498e4f99add89e9b52943642ea9df4327a606d0cf6ab060031ea38eeabbd8b49bac35ce8f4fa3c6e7fe2260356c53b996b7a362e078c2b58c7299d847734129f34955a84d3f78200a8989386ca629e9cf6561beaba1715409d61334dbdc057cd19a3907fe2691d2a3e560bc5cfa7d8d3b643099ac1fc07ad1fcaf82eb8e9fe4c08ed12fd4b5afafa0cdfc8774b4e251bd8af7493d04457e2d06d06e08491434dff3acf7d48dcb47db176c93b9faf1cb67d6af91a42eda2f3add91745a8cc89c5134350cafe3a8cd57c6de8373ccf7aa9a34be57487f794a82ffda18a09e44e60181a23bb76493d75771c7703a6c908e472c5b65c9ef25028cab1c0b726aa84cdcb22e6006e9fb5825e4e5ec8327140f09653e038115809908253b39e099d3dafab607efe6f5bc61fff44cb5f83aaa634d25f022f3d6eeb4d32df6174d904c4179bf5e77d623ee0745e437c31c348aa1861a2ea50f47b4f7922708929181608c4d32dcd80c2c9cb82fb66120506f2eeacd6934e3f2c16a1817c96eb195e42b8b198a4b9b69e16613bb5db51f7eece2c3bb5613192abbeb4c7d5c06b20e7769100923cc7acc4c2b05b239e75ab828ac3e6f1c628d490137449401b7ac950e5e5fb9dd8d5312d252e13c1ec12c9640eb2c7ab8526896237bc7c6fcd226b19b6d7313f1484776b5b472bdf65e338de249100b8126c489dfd88d6adb2123ae1a3473a8c2780d6dbbb528727d160acdbbaac5f0c47252236ef2b2e16ae09bd0399bbb435fe789c535f772493ab3206a97124c761ee9370d271624bcb9f930d6850a38af97faac7f009f3f4346a00dc0e56daf9843165ea76ea089dd50ba3c243564bbaeaf5f58787f5957f67141503fc7c2f1508075eeb3708cc7dc69f4f4e380d4d6bf22556a5460b859672e9f0f5a0e2d263edf19b04ce37f0836f00e08c48e7ff766347b687b4744001a50c6ef73160ee779f848378ffc593b55a3b4d2969af875509df1f650ace6d8cd7d38f126e80304f3d07847d42860398ba240ea22e11b6bd883ec8198e9504f7ecf001022f03ba5c0180b979f1ca501145a10b3887131366c5523fba989c5b1e417e5"
},
{
"path": "m/0'/2147483647'/1'/2147483646'/2'",
"node_secret": "b73d5dd516a99ab278011406a328066f80f34c1ae878aa9fa5b92c4ef95a965f",
"chain_code": "55a202d3f7803dd79e31c454e65eb7da49dee824b467bfed5204df980e71571d",
"public_key": "9440a490e82f2521e971840d633ebbb505a15d6433b1d00fb56ba6a5826fcaf49d8f2c0e63f7a3d504c2f030f9e3d82666e20742aeb06f39a0555907abaf1a1d21bd007d6f17fa743a3ee414cc473798f181c77268d08da102832bbb031b3bd111b555e23d63d0ad21ff38886a9056a7ba8e4e86025649890ef74d77dce262c4d65c22fb675783e9ecaeb53742ada3fb264da32e9f856dd128c31573593704e0c247177bcc5cb9363893c528ce59c6a92a829da14ef7fa16ed8aed8f3764334a40564d59f7b8527f75f2f7f417760bbaa060321412cd561c323b34ea5e270d2fff13aeea76770269723e4c2da3db198ed845017c1273e5c0cccce105f77170533248ecd97954219e20dcc3ba930d81a3d4b4400472210e9eaea01ffd5e227dd7cd6e290e64ab02346c2fa13ff5816479c2c6c4b341179b12b563a761a9f9bd1415440e7cfd7cff3b4a96b5dfee4f676bfe05efce7d65e471b4f13c29e5375439a14468b28f4cce3f5ab095e136d9d4c86e38570e0dbd9ad880aa1d84aa32b88a1b5bb68deb78c4d1febe8ff0914232c2f84e514fc61c5dd173f5ee6edab87f53ddaf9fe94c7c172f0251dbd6d55a582989d21fc22aaecf0b30cd6d8ee645f1234238ed33f317fef5cb220ded77c8166c7949528d0ce2107e3396c6176af0176a216c85baf08f4464cdc6e1935a68bb33e74ad025cb25a2d0e74557ab79c4445f242ff18e863e2d4598ce82b865f04161c768d87d5812a5a2c68ed79f1760a6f3167bcb4c74b493b5d5eb1a738eaa34e61914df49b8be6bf2e7b753fcb6c8c5ab08a4ffc88b529534f72180d9a8a34da8f17522a7c1b3ecee1f9ed71404dc6747d4d234e9afed9eae1bc98cb76d5d86815e82bd016ca64206b6d04bae68f4f98475a3ebec32ecdbef77662bb4011ad52fe785505ce51794a35906907f96b978ccf0d107ae6267a60db437321ed5579e409c278f62dd8243b005179627314e1acd146639d9a6d5d7a10c5dcf802601b88bdf44701b9646752a348d1d75151ebb28a027a9a6e13579c7791bc58554ad74fb8637057efa3395f495101b7b363adbaa3b6f97c634a11e364858353a951f09fc0af8d7855041c3195adf62bd440996675ae0422e632beff0a31fc4fa49ea2cbb8604665ba796ed74af566e3421780ef3e293a57b3802040027ece7a18a0a95074df1a649bdbd0cf5ab61102a1ff2eb1f2cd20f71301201458d9199b5761429eae2455f36bb26e0f057c9b01b55b169a275cd7e4f8bbe07a49283583f328f4c4121c6c191ef8de1b694896b6afbda38e9b167847d8af01de37ddb82efb2f7042124c16a47d3ae08a47b9b16b016c0dd59a968c7b18b52809ab78ffdfd222f6d6ca04664bf927e7105cf3eb3475ec441f4e8a57b2c725fa9b8ee5d839605a77b4c2524d87dfe89788c30dcf15748c8921042cc5c150bfd7d0b6b16fbe6f3adb854ffc172cb5e26b5697bc08ac753681321e3415c6ac7a83e89853556eff9cb5d4feb7a93914fa4e80926333257631b6975f3f34af6556dce2cde6264df67983e7746b18a0cfe47096e30f249a583806a45934bb8561516820450d858c4575451bf21b9f2e6af27c6e2c72d31f0719b48aabb52966b9cc27178235cf8eabffe742ba33bc3729ebf72feabee8db4f28ca56fb7b25739dfb6ff43e76e7e2b45a79a827ad79ccf2e78b55c126a661c54767d79eb96556e98b12c95e0513a6eff78b2e5a174d24e93dad7c7bc01ee1e7a4de86445db45eb33a4ff8840b91425bcebbed2ec3d0d1f794cc0f3a89b8499585657c05456a087d47078d772b75bd144d601c2417f79e6732732857e52858cf4fbebb2fea83e03bf7837b4bbffb28ee2b793493155ab8f4b600fcfb9de7066dbecfc08e2f25ae558096cb8d5204d9cfae384953a7394aeff828af7b89cce9394c73c22a1e5fa6adb4fc0bf788bf1a6c62b2dbfd2867f4bda916b74218e7890c24102421626eeb9ed9c6b5422925b51c8189519d1afeab853be4f1eb1dcfc018798890243a43962280c80045cada1b1f536a2048698ffb684381a2bf618067afd4518da7112ffa315ab5ef2117de676a2b35f6746b31c4d31e7686358ab956598c3b84a459505b018e2be2edef8fe8463ba20e13cbcf072732278d9a327ec9f7fa779de4ab188bd9c6bd3b2b7f08a5bc012cb4e33824052b708a980935a8ae5dc04bdd4f1edadb6203927751156aa7277aa32c73813a052d560a26e81361f3e497a98f9dbcef113ea050c5cf42af8790235785067f9a84a898a52cf390e96a81559aea0f92b391d36c33f2cac2ca0af72c20c5aebb10c96b96a5f62af0fea8d325f3380d0dbbe6907e1350f3083273fe13a0d1dc893951b918d31dec78c6e1a9ebfa8ca932e1c2b984423e08e1930724af0182ab9c5cdecb3c875512358aa6d8b2d02b7e928ce12dc9cbf4d4cb6864746d007a93d27238e58ab0ec5e72358844579474a1b56f2fb4c35a950b47cb365c7970a77d8c2901073e2bfd024f9097181d3bb23c24e1c2275cc25dc5edbb3aeba681fba5d0e65132383bd6fb00624f52ff52afe8346de99eb39c6b0d82462010e17073010580d0f0a7c3933f450e30911e90b9f3bb5b2c074ee77beeee533019559ed1e7fe082664f5e073ec3f5f6ae19f583f07128af29af4dcb47961e565dfd1159c6115898f5b0273fcd9febc8be42ccd92627bb0fc1d268e6a95f9e4f3de412bca582d0726ebaabd7904245f1a89c9170c7"
}
]
},
{
"name": "Test vector NEP-649 1",
"mnemonic": "orbit pill once attitude garbage prefer afford what banner weird path present dream tide unfold despair inject physical lens battle fancy finish fever toward",
"seed": "85d44f13ba9c837a8fe51474cd7b7ed6777591808aca0eb1d5695e914a060c8efbf0ee7e325e24490d69ca824af62071416504ddca17332d2a28bea7377792da",
"derivations": [
{
"path": "m",
"node_secret": "765883590f2bb53c8186828791f58b59231a3945d695464f117f677c7fac55ec",
"chain_code": "7e326a33cdeedc0a32ce01e834831dddc253e27b3d00c87c59c159e662ef257b",
"public_key": "1661a2713f01484a6027fa11e3e6e2ea47513beb7d735d8821a26a162e8a2689920e0f662147c454743bf634c4ea758f390c0294f30f329ab987f7875ce1d36cdc3505639a65d47feaa65bf33539453fa89dde90215aa980be55bb33c30362252cb439380785b1a750c61d0de120ff5a45482111b9e7c8a72fb7b71772a673ce32f6e287f3a3bb1507af31077423c912ced910019ff645875ae1e63691e898fac67a835afc7db8c8663ac1f7f5c03afee7432241b726b3fbe5729fd29bd46b435e2d82fa7177f500ba19ffc796aa41fef14713ad1740c8d05ba339ff337bd077bff038f7e5446fed599083dd60eff44bda2ec73ae3a941457bcf38a1b260ac2de1e3de456533c5a615dad205995cf887bc831bca7d65673be608ec404ed2aaa63dd9e68af039c2e532a88c32634c076d7e8a9db50da6fe3dd5cecb50b3bc153b3a6717fb41037de1f8c0abbd17b76f710873c75eb15b2246ba55bf5d9b542fa7a11742a3de4fcbca44665cac138da843a74eb5e0b4407349b5c7c0a930039b3bcc2b6643c72476b8fa38ece694cd34eb52f026cfed7cb4e8c620cbd816567011ced7b515b481f7a7ccb678887e32488125a791707fbed0a65a5c25ef7032212c12350b586295ce63c18b7a9d0a603d68bea05dd7e5ec07c2a08adf77ee518cdcddcf724b9015500650519eaeb31fe888faa2749d8f564c113f04d218bd06019155d5b200c02ffc91134ff988548d3147c09b3fecfe92a9f5d1e576ea68285d41c65c7abff111e4857988bf625e62aca4151b46458cf788ab262ad4b1cf892edf89dd85b7e3b804f4551e1124a99188ce8f4c3bd116ca2f35c0ae110a3ac5d9bad11d7577f2033275d60fb6396849e9cb0c7545ea917afdfc0b3d7d8ffbad1fc5e464bcf3c24c5692e47dc662d91130c436021094441c7984dcbe462d0de2a358d0b2f5f4c51549c4bc7f330ffde40e5d0e0a184dbd2f52942f3904cb3b39853877a5bbd5aeaa28135dae2a5a2786f886b82134698afca2e890ee56d40be21ad7c6b0f960d86b5dbfad04bca4477f1dd4f142d2be83fa5f2a701d5d14e64a7002784182a99419ea651b3c19b45e9bb5bd5a203cd12904aa1783378cb31b26f41e3f35bd024b1d6574d748250996a18b3f130dba4f8b04a4c11e449ca5125a01314ecaa77f0b8ac266d87bbddced1db2b9093963391003c4b0426aa6b6df5f6a825265c512ce6b29389537cdce576eddf7cc1b038b7d1d0d022cc4339443553ae4bc5990c9378c2e0f0e5d1c97d4761ed0562d9bf60ff9f56c550eabb1e35cbbf5520026dd2a5a4e44f7ba0e48b14474de8c9702cd92cddaf3831f6b795bb29d0329517d7d2bc64cabed16305ba998797c6406461d9656529f4635528b537758073fadcc61ad9b6c64066b614fbead4c64bb96409a6b92049cdbfa51533be68b0440e365a27242df821c68ffd2a9d62e38001a41b487e9a89be576408d5fd0ed0e142549c889a59b277bb346cf2eefc6cf66fdcebac036aa27b77e1b609c5356de70d539757ccdc93f6f680a154c73ea9796d9b7ebc06180ef56c3aaec27ebd365cf58ecc8c61093dd57b356e4b309da76a95bbdb1c9a5875c555b02434ca2503fb1366c65cddb38e45239ecfbfd77aa6973dbffd3d7f886787a80fbb1a206fb6d8165070b67d9022422d658bbd0dfb9662a41022e636be370711e35c2b00cc889d9d56554b1a3d64ced5febcfa4129148bc5e790a6c8f16372b3efbd65a055ca641f7963821f6df8548348374565766b146ab99ad7d3dae73ad768153ce99cb9057d013b030193aba76f372f42aca27ed8b1e4f51fa1b81c979efcd31694402c9d58769f20b4fc256de06c948efee7d84e957cad9cca1256d7db92fcc2cbd7717c70b29b57383c893db6a11fa861c96ab3695e48c702888cd23ca3fc3adbecde40846217677e2e54d6862273a925a616831434a200086b14841a2ced6eb19b1037cbad42b85d1e1b049f87500b1a88a6b28999294cd373781f217da5700b43c6f31b10a40aaee327d46949c95d1045c8c12e9871178caae564614edccafa77f4ecc6d9fb7fca54a59079bd6ec8a254534610d771ae65405edcf40d88380962ed05560d5d3f0e58d272850ffd5237b87d0b71858c594b394cf1647d053ec68b263282d20e5f1db5341de9da87daca044826856dea3833e3552d40e31a1618997f8fc7a78b3252708122fda229cf9f676d44a9b9950378714349bb485bda0666ea8b6906ae67d969c0e23b7da66c649cbd36f77c8ec83921d2f2f737ef786972de2f945959bf85a3b3bb4bca79209a1f3b1457e2dacfb6a32d0410b79628b65e8b6429dbfd3306545f9ba43eed008578a0a57436a74fc4d8919826cac5a9eee4bd5211dc4ffc055ec1f0522291e82429cec58652b5be46d898889815f26c83bca1794fce6b0870962b89f7e2f17229fce2ac914ffb1f16b0298c0f74ab50c22c3a23f30a417922a17d08139e1eda79e09567086caee8150b4cb80721cd173d5563e792d5c6277dd692508231a6985c83ec7681f700cea28686ddca3e3770922c729491c7729f1654c79272f6a1f20b55ff61bd85f7b5e23937cc0bb2a513321c77cf1db8ae3f152b5bce3d5f197856bf82255f5965e88f0032afe04448d13224febbb423b00d7b15e801fe6d67899bd9a665ffcb927f80b55f052cb3b2866ae4a58f3f1865c73098d6e682ae6319ef4991156aeb2f8f1f39aecd5b6f7d99f46a5dc"
},
{
"path": "m/44'",
"node_secret": "fe556e3fa041d7b8cad66516c8b45b4ccf585c137d5e1898d145c20651828f94",
"chain_code": "f0344afcced1f9e18e6cfbae4f204820a3d9a19f0b23327c1a59ab8365c2f905",
"public_key": "da3cdae6479ae961008f9faafd6c059c004ad6e7fa785c04bb2b8bd344397636bf44d0b55bfc0155cefe320c443a4aa902eadaf9fd2085dc077b1050a48816aa565b1585fefcb3789a179843a250385759b3b4d235bf3167367d29d01e63ca84a8b74e809a1abd4d09415aba402335a995059e1b8a49e03cec7428e85389a35f956c4bdda3c08868e915089d73c14701e7bd0646ff7bfec5668fc99a02d5eda19bf12b1979cfdc26925e8b2c046e1e5b6dd29d6df74ec1cfb259a9b739b6bd2210909afb32bdff2892de9899e46873d7061754e86bf724ae628726b844ebd54636abb28b9e38a98b4cc18d1862114c0d733dfe0588729320ee80effc68bc2f7b2c0514ad64392211d5d25955f033b0272f2dc0dce405fa2406e8783dbbaf3cc2840e81e45f1751d364c26009de23177a6bdd03752b978fd98c23b5e04fc3dd6f4d96a23830c7816c0ba105ea81baca554c4bc309d65c5166df4931de547b819ecb9ef8717321f470d7f619999f47d9be6cdee2402fd3b94364a5f8852114cbe3246ed56bab42a89c5374321ea3b7ccb71e7c7e891949bfe57a704bb29660d01370c3df3b9c6eb918c955adfa240db1e1d39eae3e2e6c003ee3c28491221c71ce9994caab3964bf82f2a07af2075564ada3b749635eb50ababca9bed988c0fc108d265ef4b07001ab90ebb50f04c341f4eadc610edc8b4102c9d00078b88390f08525b9d8baec707cf07343cd98ff6caf21b7a4a82e74b9fa2dcb0f53344cb51ebcdf6c9eb5d72d9645f8960ed6ed3a2230de58a38e35ed4678fbf8788586c5168065a5fe4fb65f4c707123ada4f72aec991495bf9f3596c2b7ee54e5c7e79941f8f52413c6cd277434dcac6f5fcfd4d245d4603d33f278797f54752326ddb45359194852a91ffc93f25a820e987db2de5c9d3e4d0ba111e67770cdadf4943dd8b087d5078f30a83c59f959d1679fd7bf1bd5ee029bcefb174ee37bbd4a528f4f2a73bc3c77af01d19b768c310572d9f21f98a818ab977a6aab1a1b7af66a28e9134d212ba55626e8da9b1ff34a47a66a9f1dc100dc4e85d2450070f16c51dfff7128252f04793e0d1a68d2ef5d536fbe0bd881f4ee393b38470ee1fc11b1e2cab91d5ff8e57599dc74f6ec172156642a0dad64ffdbd7a43eb8533ca94c55322ba984a2f7641f4581b707bb513667b87c1d67e9a6a3cc400fc2673bb8bdd902c27d33a406e0490a824baf4cd42bc8ea83d1c42166fa28cb971b50d22a38a02f4665256daa63dc097fde84addf393732a3a09b091b1160392b9013b0689e68c216ac4c0ce022802e65aa2ddd92c36ee6f10076ca8193eff3e489ae25c07b3beadc66b4140899bc1d6aa862c52deda07519515cc1e3bf0e939b0cd991a6d80b52f98bba1df466eb75440e2cc6e76e1facaa6bcd0a739444213c0c1032912b586d13f9b78caa15e94f39857400a19153f174a16d99b7eaf4f07bf8963afbc32fa7f3e9cf1971a2281a82a79689e2a8daf62605f15af23b8e46a78b70585bd677b5cbdd09a4264ccdd6b2a5e5d42fe00073159fc98fda068308e492cf7440fb5d971c3117ca5984448b401ac597f9c405e108f9b6afb2cc145be27f58a9e73f970d17c0425a08f2c1a959caa0ab12d0138967f79b4afe38389ece35dbb1f3385f6fe0d0aa1dc08579d7d1adde44aee98b707e90997f7609cb60a7be2a752bf9cd606d0b1e828fcc7ae532a197ab0a607391e4ea2c59d588c142069382b601d96c76ca0014a4494a0ad39f34d6a327e4425481c74a8a072499b1c7cc25a4bd3becc7a4a160842ca3d526319d84cf86fb64b02c0b6d54adfc5ba5799a6c06a7ebede2341b7b19ec04d061935804a7af76c5852483dc183d2db2e164e60f365a5823619b8f79264859e3d9f7c6d9374c638f5e5120617906e7786d59d0582740e1377cdfc6e0b6b4a69cb8bf1820ffe3968a61db731b295b3aec3edb547076c7379666b936fd369462d74b039aa31ba891404f9d46e9f9b329195ff551afad5c59af07d0c5a4f0fe715952257771a69459f45a397f3f5ac68f46eacdee3ba82c15c1be01a8c30c6ac147376793a09534908e66a970b3466f7f2bb569dcc7e052e47b6b476c3887da6e49a0816f0a034c61d6f7619d72eb0f8170a8b67d8321d437e7affeadfbcf8b9f21c6566f0b7ad349bbd481e4abc2650b619e4a6a0f6e6051802d1592c7870dfa1462d8108d06d7fd87d5fdbfc86f883b8dd897833da981d803dc8a351965dab7965dfe660405cdffdc28d2fe459cbc179de3dde9a3546bea73bd91aadebff3fd39e7c8473b086dc50aed19932d26757ac8f25b7f6f8428375bae24534d2331ca551c330a2ec861e13c82dd3f49eab2481bbfea2f0e6ff2ecacff9d6fe869638de5f28d5b57feb3bf6b9436db8c34ccf6f32260c2450bc876567bdb1c747c28241471d3001e1f22ff7bd6ba7e5fffca96f15b02a40abef704f2d363c28a0487a736acb63911060f32020635406ad84040e88f1ec88e85385104a927036abb6e7ed171ea0eb8145f669c96d3ea15e40a6591332bd08d35d393e38ed20aa347f4aeb40a2e666c7ec395990bf3c9499362507b7c3d74b068afa3d97de6bf3c9bcc9f16dd634583388f48e1a139409904636cc3179343d2dc9ccb046a19433d6d4887a73bd9039dc50d4bcc9ddcca8d561b05044c2b9fc7532c79825c16da909cb1f11c13f05751447387ebe267294add9154d45a40c7ef1c36be2e48a4"
},
{
"path": "m/44'/397'",
"node_secret": "9058b860f62989e323875ac4b1f5e11f61b659bb7f4faf244c9b69151b7d36aa",
"chain_code": "685f1b3530adcb2918fb8ba1debf0ca5cf79bedeba4bf0f9be2058525b991aeb",
"public_key": "2718ed8367f8eaa54b5c680dadf71cf0bad87d5d750c24e2977e87a9d785a36bdebb03715a627f7460451724a814e7843ccdc5c5fb6ef659bbe46dbd4df7444dce6b342fc254565361f64ff862d64f20818ed42eabe03e15d3b97e0cd4c4f6e4dfdb4048caac0fb5e5919448d0eb3b41c2ea28de0bb4a58304b50aec1a3f12e945d4095b7bbd42aa2ba0c3adea1851299655fa516fee108b55899faebb1e3bcbd3691b38c30fea40fa747e569c652dc2a38df23768a2bc771fe3e8e186e33945e13277527b2ee0e6bf9af81dd16ef6bd9553abaf78661b518d95d1ef8d49378f4014fb4cadf7c5fc14be3161afa4d561159602eaf728578512179894c037ffa97cb69fc2e847dcb86ff7956d98affb1cec03e8005170a88b3f403958fad0726c02feba68c9cdcad468a08e295c6a730cb8507dea9261850464a6213919beaeb5ab9dc1298b0e6726f716ce476ebdebb4d136c1bf91c9078ce0971520fabbd976fa8573a67aff12e11af85b12867639e78468766123f599a8d2a42b9424aeba080c599bbab04ef99f0c15ff6aa6037de8df311f3dbef01fb336b994605d34fc5bcdcd900eb545af76d159183f7a2690a4e148d0399f2d1cda83e03bc40aae89b12a770611b2a22006abd13afa908dbc2ccde6081502c5ef7fc55e22c28c2d96813436dfaabe27a01f91b49c392d6d468db6e22de6b96d7e55a886ef3f100285571e0881074812202a688df2715c6a0fa30d6e3a83d0fb70100c6c268aa1b03b07d4a62290ec9b47720c80ecee19512a8bac7696147c4029f5e2446657229fe66ac034911d6b2513eb0d514e3464f4009966fb658f1c9a4b1106203a17e30ae8129108aa036b455af8f2dfaebe6a0625c60a197216eb6fbd4acd560886d1500bd325e19af4fd746c74674fdeee320e6a8e5b296b53e420c3e697869683eb6de75aa6faa5da4d58a1e79f7c191d1962ca80ff8e5d5c37d6d2ed2b267d3d5ced818f056ed3843d4eaafccaef1d31a3fdbfa4eb164f361c150b93abfb49ec86fdfc15fa00ea641913dae61e495e45dd4d2f9c7fcc6693bfa23b87fb0c2d75dea81477e930eff5f1d8e263364209cb596d2995b41054516a632636b354960df04c95f8f4489f270368448c13fed3941d22acbfdf1859391d897f0dbfd6a022b7e0fa33a800ab64de0093f6251cd8b9a5059ca1def9f0c4d587f0021499b117a173fad903d519a947f457c3713eee65da120d299ffce5edf6527aedea74c8b099a1e792fc5a4f52365414aba5d771535fc9503e4dab9a115228d8fcb5e954dd5f62fc0b150e359bd6f1112cd2dfe0cec119f7523cdf8d6cdf2ca7e134212ec713031e4c2efae5538168d5df9863e58350f8f98b354b0aba673e754fe1948f586dd583d2439f6d024719c5ec0eee6747b41d1e5579fbd680fcebe024f0456bd878dac25cbc4b75b6e2dbcb0b7ff80c57caa753ccb877d9c31af51454bb92c20bea9ec6acbed394f88d3a613c6d4bb4b543a01059c2a9e83b01dca256ead3676a8b156a5e9e27699179370a60fae97e2ac1b726dcdd86d76b265398b462c7a56e546720675656d20da16d0f7b30a662fc321202328641c1b1ee2254a670a6ba987689f4d18b40b792556fbb379219d3bf6cffb63ff77410717629498ffdd8349bb6bef7284fc951842551ccd04fc3723e944798e555d53926ad76f7a5abb4f6175940b0b0eb83c8e15677bd4834787abff79b88a2c650550757fe9cdb9629aa4987a3902ed6a6b1dd512fbd3da33ee1fe5eded3764b4dfeaccdf3af75fabecdfde105f671eb46a0fa99999fadaaee73ad0495a5774bd7ccf167fdd6b7742a04642df4e62f3928393578c4c5b03a645b5dffa2c44f6bbf53ce4b8ec5a75ad6bf5195bccb3c7dd23aa7f782c07457bc2d3a1a7a991e8642d5050f7d9c07a2e6461d041f3ae8dffa97769fd465a7e6488e100d59633c430f929d540d959345053a387df317fe7d002320a32274f4272fbb0872ff21b381422bea550d17a79ff84a4359a251beb3f59de606d19449e9027a42ab0ea214fb9006166fe03c6af01b3ac7b3c0a1e2178fe4ab67958ea294a366878b887e169a49c71d93edda9e5bed64cd78a4490d408ed93118b7b4805cad40cbc9fafd6cae3541851a8c0cab6b3281f39db46744d1643c19f6fd61a729481b7b420f41d92b2ecebbf178024ffeb9ce225ef186d1e6f9c0b020f2d52ad87f512c703895726da81c519ae8d20f5b0a79729f14ff4cea3c52239e3b77611cbc418450ded85b0b3cf02336f927f113b6b0cdd6040c021519e225f06fdada06aff07ee3bf8156b41325b879f692d27825eb2a286c429aa6b2e3ebdb514f888a70d303eb1e907c387a4411bed474faa6a9b03ea39e32d33ed7c2a693cfcc9918ece16b6e527b228915763ca0bb9296ede4d6197bc9dac85c4e3c1aa7cb207d501dd44986354477e3fc2cb93121d41c481e3e556be354b8c7b1a4b5fa7badc4bae44a64d8ec4189e237e8ca6ae40a7b951da67c5475b059195a6e57f4f0350a2bb2a77a241ce66cdc04f20dc7b36cdde2f98db233c13b2dddd2390d725b26d891368ae4e02087fc2be512f25905ff32496630acfb14c3522f0213d04048e5a6b6b680aaef650a71572936909f40bba9c02a005de64565e711cf5dd88691ba14cac350dd66257407f2cc978143606ca2ebfc89d43c1abb8503d1450c4193e3be31a40fa7a80442933b545fab163fa799e3e6c543f04bc089"
},
{
"path": "m/44'/397'/0'",
"node_secret": "e82fe9d0fd2a6af0798736fc2ed44907faf66d4a7a543499e7f1c04f18a7d3c4",
"chain_code": "c95feddbe3025baf6bc5b448b4c8f5c81817b653d594fe554f80c2da8e1a431d",
"public_key": "d90973ab0e1e7f6d2d9ce05347ac9375898061bab6948c0ebbed3465a066a0e56a389676c53df14b905a64d7ce650d479f26f40665eb09b705632c176eabed5d7bbe48fcd9c566d1ec2bc2be6126e3e9e22928ccd87e31c5c74e3485f57a323d777592fd679da23c9291edc5679bd212d5f232d76991c798c331ed2cdcd1ee460e75326792fc53af609bcb42c6265528645e937618ddcb1972545957b1a2816e212f08576b2412c94925e060a7f4f5afa4bde06d29e2245013604883fa28a966e214f9203a40181ec37f27980d1f95c4871dfbbf188fc51e80b560184928bb73feeadf53e9e6ae1e3bdbd1b6ce7c9e7201cb481f3269a263c043b0b016afe455a9e4c542b5f167aadad9e9e5fc96bc3dd04931c587010d10c3d64020b2b23f97b5d60fe64bdaa91a480cb3e946ab2a2391bb5b398b0c8fbcd8c9cf28d234318fa7574ba8ca5cd55fd6e0fb7fd190213f0983026257965b4bd74c7a0368242b02fadcbe75631f458bb0284c00f772da3388124cbc69b22acd356fb6d2488bad92d2a4e92d1b568fe8608962b238881b8ae4ae88f5faa14c721a1032b6b7f60cad6b0862a8656373e7dc0fd28899f2a05feab633f8d6ceb94be5f54fa2ee1477bba01fe1ef874b8ae9fab846f94f9f35ebe3443b7fb7e85347bc8fd9d2ee6df69d932729583b6ef92b0d7620fcbafc49a1b12ec4850fda1d4f6c0378398af0a41ec5f459b84718a5125e8e5e497a44feb702ed1561b81757d8601cfec28b0dcd1e62e2f17b75bf7d284fd7e0870a36477b0ee28651fe48a94f5163ae908f3e538e95c63b9852c672fdf2fba9f5e312c0d6bb113fcd95e290eaf067fbda6a9dd5019328805d05a0e652a3bf0bf8d9c7af41a0cd36fda690b66a1198c2343292736e6b81f885046e59d51cf774650bfb0887fb10a419202ba3e78c8b8942024977e0649d18a5d62568d076e54f4848d13c14c55607ac1d9fd1930b70d4ef0a2f6f932dd11bac2b7766306cd97323ab399852b68e7c930162872b7a19f18727d76413f032de1198b312989261d8d8882162c986ea046cfd1bd0d2640ae50c2fcbcd859ad4c04b0dbc46ef6946db30ba58c0d692e9b42f8a62e05f175c8f4ce67c7344dad6c5a993b8d0721f4a1292dbbbc419cb2de7c47a0ca21216488e13eb5d4ca17b1d2c903bf39efc18f48581d1f392b8cc9e7656e68d70765c16ce0ca914a2bdd7992582c3251e62209d7db0891afcd34707bab8546de6305afe9bbf9d22f271e9e769658c33bc5487a3c4542c42f3a8a8f2c03c9d557b442baa755abccb6a5e681b6b107902bac63336819a9ae1d5d36e5219d01cccab3fa4ca6023ef31358d5d5d14e36e3716ab116563c9fe3fe1cd7fe824cdaceeeb0b5857515513fc212487ded2293cd84da07bd967b041f0a823bea5d8fd3c3fc3f7d3dd249a0ad937688ba7fe8f7f9dbe25bc9dd9994f5607fded2d349cb10e11e7ae3f8bae778c602f1a8ef2a99ce3a1c61ed8736d5905b418de65d5de0b5ae14795e8dbd1dd60e060db88ddbbbbacc8bcb34c5f7ee97bb84a5102cc0df818504c8f2ae750c2e8005ce7f8a1ccc940e92d9e75d6324ccb823c917c36d0982c70b033855d9c874ec44ada4968e942429aa7fb329026cf31961df7dd902fe8b197c93c32217a4c6c92833c372db8e1df8d1e5a2497f0f06a9d94062ebe6e718c7fbbdb178ea37cea887959fb78c1d0ffb37caec235298c02202e1887f9049aae9c227d57f3d07cf9a4148b924624b004cdcc90826260d92e11bb1c31dbb51048f34ba48968c1229373d52b64c5ef4a8912afc6a70497167ceecec0cdbce0d66b6650bdfabe11af9cfa6bc9a671f694fa773505f53fb21069997170442d3b296a856f447a601fab59e888925f3c23e0a2521180c31dfc51241071de01813ba52da744df4dc577c49b5f10b4faf15948fd6138993faf6128b763d956ae379d9284fc2d88f43cb0d45b8dbb825c55c2aedb172d32162cfd90ad0448640c2754a875f5834e6f28eea18dc068e9ceb04dec12c9691c7866a605f91cc0efe6426ccb0b1c9df3cf8c7be81dcf49d2963a61bf985eb07bb7d96d578adedd99115533d5ca4431e9c87d697d3a9f3533c1d0b8974cb4b63d4f2aeb96a7332563c934e58d2f3cd45542f85dc5aabf8c4c50ea88b1df191c6a09b672fe5c222891e21a69fe63a082c23da55186899a1fd8cb4545897da2d445b2ac501e1072a23956579fef95a623116ef63b87ddfdfe0410c0f88c2e6c33c31b7974e1139e748d7c7892db85e2141816a121539d7c7ac4fc298006207717ee051e931cbe94a372e2d1719531aa77dea002185dce0b726be6f76ca0e29b886b1c499ac1ab32a68df778b2202b528ab4a07315639a783c9588d336a9a25d619574421295ff0b6432978b94afb19e50e58f396d15097dfce8479c67392b405098b901eee45e5d990d41563ab2958d84f8dc84c9d5eaaf16ef4004089254890c6ea82c5b248c38169b704ac2eef4200f9da8b09b9f559bdbd22b48762fec17b3e1e1c0ab1d6319acfc5054f9a55c8833ca03fc91c07f7423a78c95473d93ff4f91d2e8d26421540d51e204618b553c39c4a904dd6b6e6894d5143b5d7f108d8dda2c54236096f0ccb3ef0c94452998ff3a6c8d108d25a99697d36c05254617841eb8d6395fdbd387e5cd754ed02f420fd29387d825337d466359a512afbe3245924d89b18e5039a9da381fcd6b1cd320af62d9eb3f71eb3c"
}
]
},
{
"name": "Test vector NEP-649 2",
"mnemonic": "orbit pill once attitude garbage prefer afford what banner weird path present dream tide unfold despair inject physical lens battle fancy finish fever toward",
"seed": "85d44f13ba9c837a8fe51474cd7b7ed6777591808aca0eb1d5695e914a060c8efbf0ee7e325e24490d69ca824af62071416504ddca17332d2a28bea7377792da",
"derivations": [
{
"path": "m",
"node_secret": "765883590f2bb53c8186828791f58b59231a3945d695464f117f677c7fac55ec",
"chain_code": "7e326a33cdeedc0a32ce01e834831dddc253e27b3d00c87c59c159e662ef257b",
"public_key": "1661a2713f01484a6027fa11e3e6e2ea47513beb7d735d8821a26a162e8a2689920e0f662147c454743bf634c4ea758f390c0294f30f329ab987f7875ce1d36cdc3505639a65d47feaa65bf33539453fa89dde90215aa980be55bb33c30362252cb439380785b1a750c61d0de120ff5a45482111b9e7c8a72fb7b71772a673ce32f6e287f3a3bb1507af31077423c912ced910019ff645875ae1e63691e898fac67a835afc7db8c8663ac1f7f5c03afee7432241b726b3fbe5729fd29bd46b435e2d82fa7177f500ba19ffc796aa41fef14713ad1740c8d05ba339ff337bd077bff038f7e5446fed599083dd60eff44bda2ec73ae3a941457bcf38a1b260ac2de1e3de456533c5a615dad205995cf887bc831bca7d65673be608ec404ed2aaa63dd9e68af039c2e532a88c32634c076d7e8a9db50da6fe3dd5cecb50b3bc153b3a6717fb41037de1f8c0abbd17b76f710873c75eb15b2246ba55bf5d9b542fa7a11742a3de4fcbca44665cac138da843a74eb5e0b4407349b5c7c0a930039b3bcc2b6643c72476b8fa38ece694cd34eb52f026cfed7cb4e8c620cbd816567011ced7b515b481f7a7ccb678887e32488125a791707fbed0a65a5c25ef7032212c12350b586295ce63c18b7a9d0a603d68bea05dd7e5ec07c2a08adf77ee518cdcddcf724b9015500650519eaeb31fe888faa2749d8f564c113f04d218bd06019155d5b200c02ffc91134ff988548d3147c09b3fecfe92a9f5d1e576ea68285d41c65c7abff111e4857988bf625e62aca4151b46458cf788ab262ad4b1cf892edf89dd85b7e3b804f4551e1124a99188ce8f4c3bd116ca2f35c0ae110a3ac5d9bad11d7577f2033275d60fb6396849e9cb0c7545ea917afdfc0b3d7d8ffbad1fc5e464bcf3c24c5692e47dc662d91130c436021094441c7984dcbe462d0de2a358d0b2f5f4c51549c4bc7f330ffde40e5d0e0a184dbd2f52942f3904cb3b39853877a5bbd5aeaa28135dae2a5a2786f886b82134698afca2e890ee56d40be21ad7c6b0f960d86b5dbfad04bca4477f1dd4f142d2be83fa5f2a701d5d14e64a7002784182a99419ea651b3c19b45e9bb5bd5a203cd12904aa1783378cb31b26f41e3f35bd024b1d6574d748250996a18b3f130dba4f8b04a4c11e449ca5125a01314ecaa77f0b8ac266d87bbddced1db2b9093963391003c4b0426aa6b6df5f6a825265c512ce6b29389537cdce576eddf7cc1b038b7d1d0d022cc4339443553ae4bc5990c9378c2e0f0e5d1c97d4761ed0562d9bf60ff9f56c550eabb1e35cbbf5520026dd2a5a4e44f7ba0e48b14474de8c9702cd92cddaf3831f6b795bb29d0329517d7d2bc64cabed16305ba998797c6406461d9656529f4635528b537758073fadcc61ad9b6c64066b614fbead4c64bb96409a6b92049cdbfa51533be68b0440e365a27242df821c68ffd2a9d62e38001a41b487e9a89be576408d5fd0ed0e142549c889a59b277bb346cf2eefc6cf66fdcebac036aa27b77e1b609c5356de70d539757ccdc93f6f680a154c73ea9796d9b7ebc06180ef56c3aaec27ebd365cf58ecc8c61093dd57b356e4b309da76a95bbdb1c9a5875c555b02434ca2503fb1366c65cddb38e45239ecfbfd77aa6973dbffd3d7f886787a80fbb1a206fb6d8165070b67d9022422d658bbd0dfb9662a41022e636be370711e35c2b00cc889d9d56554b1a3d64ced5febcfa4129148bc5e790a6c8f16372b3efbd65a055ca641f7963821f6df8548348374565766b146ab99ad7d3dae73ad768153ce99cb9057d013b030193aba76f372f42aca27ed8b1e4f51fa1b81c979efcd31694402c9d58769f20b4fc256de06c948efee7d84e957cad9cca1256d7db92fcc2cbd7717c70b29b57383c893db6a11fa861c96ab3695e48c702888cd23ca3fc3adbecde40846217677e2e54d6862273a925a616831434a200086b14841a2ced6eb19b1037cbad42b85d1e1b049f87500b1a88a6b28999294cd373781f217da5700b43c6f31b10a40aaee327d46949c95d1045c8c12e9871178caae564614edccafa77f4ecc6d9fb7fca54a59079bd6ec8a254534610d771ae65405edcf40d88380962ed05560d5d3f0e58d272850ffd5237b87d0b71858c594b394cf1647d053ec68b263282d20e5f1db5341de9da87daca044826856dea3833e3552d40e31a1618997f8fc7a78b3252708122fda229cf9f676d44a9b9950378714349bb485bda0666ea8b6906ae67d969c0e23b7da66c649cbd36f77c8ec83921d2f2f737ef786972de2f945959bf85a3b3bb4bca79209a1f3b1457e2dacfb6a32d0410b79628b65e8b6429dbfd3306545f9ba43eed008578a0a57436a74fc4d8919826cac5a9eee4bd5211dc4ffc055ec1f0522291e82429cec58652b5be46d898889815f26c83bca1794fce6b0870962b89f7e2f17229fce2ac914ffb1f16b0298c0f74ab50c22c3a23f30a417922a17d08139e1eda79e09567086caee8150b4cb80721cd173d5563e792d5c6277dd692508231a6985c83ec7681f700cea28686ddca3e3770922c729491c7729f1654c79272f6a1f20b55ff61bd85f7b5e23937cc0bb2a513321c77cf1db8ae3f152b5bce3d5f197856bf82255f5965e88f0032afe04448d13224febbb423b00d7b15e801fe6d67899bd9a665ffcb927f80b55f052cb3b2866ae4a58f3f1865c73098d6e682ae6319ef4991156aeb2f8f1f39aecd5b6f7d99f46a5dc"
},
{
"path": "m/44'",
"node_secret": "fe556e3fa041d7b8cad66516c8b45b4ccf585c137d5e1898d145c20651828f94",
"chain_code": "f0344afcced1f9e18e6cfbae4f204820a3d9a19f0b23327c1a59ab8365c2f905",
"public_key": "da3cdae6479ae961008f9faafd6c059c004ad6e7fa785c04bb2b8bd344397636bf44d0b55bfc0155cefe320c443a4aa902eadaf9fd2085dc077b1050a48816aa565b1585fefcb3789a179843a250385759b3b4d235bf3167367d29d01e63ca84a8b74e809a1abd4d09415aba402335a995059e1b8a49e03cec7428e85389a35f956c4bdda3c08868e915089d73c14701e7bd0646ff7bfec5668fc99a02d5eda19bf12b1979cfdc26925e8b2c046e1e5b6dd29d6df74ec1cfb259a9b739b6bd2210909afb32bdff2892de9899e46873d7061754e86bf724ae628726b844ebd54636abb28b9e38a98b4cc18d1862114c0d733dfe0588729320ee80effc68bc2f7b2c0514ad64392211d5d25955f033b0272f2dc0dce405fa2406e8783dbbaf3cc2840e81e45f1751d364c26009de23177a6bdd03752b978fd98c23b5e04fc3dd6f4d96a23830c7816c0ba105ea81baca554c4bc309d65c5166df4931de547b819ecb9ef8717321f470d7f619999f47d9be6cdee2402fd3b94364a5f8852114cbe3246ed56bab42a89c5374321ea3b7ccb71e7c7e891949bfe57a704bb29660d01370c3df3b9c6eb918c955adfa240db1e1d39eae3e2e6c003ee3c28491221c71ce9994caab3964bf82f2a07af2075564ada3b749635eb50ababca9bed988c0fc108d265ef4b07001ab90ebb50f04c341f4eadc610edc8b4102c9d00078b88390f08525b9d8baec707cf07343cd98ff6caf21b7a4a82e74b9fa2dcb0f53344cb51ebcdf6c9eb5d72d9645f8960ed6ed3a2230de58a38e35ed4678fbf8788586c5168065a5fe4fb65f4c707123ada4f72aec991495bf9f3596c2b7ee54e5c7e79941f8f52413c6cd277434dcac6f5fcfd4d245d4603d33f278797f54752326ddb45359194852a91ffc93f25a820e987db2de5c9d3e4d0ba111e67770cdadf4943dd8b087d5078f30a83c59f959d1679fd7bf1bd5ee029bcefb174ee37bbd4a528f4f2a73bc3c77af01d19b768c310572d9f21f98a818ab977a6aab1a1b7af66a28e9134d212ba55626e8da9b1ff34a47a66a9f1dc100dc4e85d2450070f16c51dfff7128252f04793e0d1a68d2ef5d536fbe0bd881f4ee393b38470ee1fc11b1e2cab91d5ff8e57599dc74f6ec172156642a0dad64ffdbd7a43eb8533ca94c55322ba984a2f7641f4581b707bb513667b87c1d67e9a6a3cc400fc2673bb8bdd902c27d33a406e0490a824baf4cd42bc8ea83d1c42166fa28cb971b50d22a38a02f4665256daa63dc097fde84addf393732a3a09b091b1160392b9013b0689e68c216ac4c0ce022802e65aa2ddd92c36ee6f10076ca8193eff3e489ae25c07b3beadc66b4140899bc1d6aa862c52deda07519515cc1e3bf0e939b0cd991a6d80b52f98bba1df466eb75440e2cc6e76e1facaa6bcd0a739444213c0c1032912b586d13f9b78caa15e94f39857400a19153f174a16d99b7eaf4f07bf8963afbc32fa7f3e9cf1971a2281a82a79689e2a8daf62605f15af23b8e46a78b70585bd677b5cbdd09a4264ccdd6b2a5e5d42fe00073159fc98fda068308e492cf7440fb5d971c3117ca5984448b401ac597f9c405e108f9b6afb2cc145be27f58a9e73f970d17c0425a08f2c1a959caa0ab12d0138967f79b4afe38389ece35dbb1f3385f6fe0d0aa1dc08579d7d1adde44aee98b707e90997f7609cb60a7be2a752bf9cd606d0b1e828fcc7ae532a197ab0a607391e4ea2c59d588c142069382b601d96c76ca0014a4494a0ad39f34d6a327e4425481c74a8a072499b1c7cc25a4bd3becc7a4a160842ca3d526319d84cf86fb64b02c0b6d54adfc5ba5799a6c06a7ebede2341b7b19ec04d061935804a7af76c5852483dc183d2db2e164e60f365a5823619b8f79264859e3d9f7c6d9374c638f5e5120617906e7786d59d0582740e1377cdfc6e0b6b4a69cb8bf1820ffe3968a61db731b295b3aec3edb547076c7379666b936fd369462d74b039aa31ba891404f9d46e9f9b329195ff551afad5c59af07d0c5a4f0fe715952257771a69459f45a397f3f5ac68f46eacdee3ba82c15c1be01a8c30c6ac147376793a09534908e66a970b3466f7f2bb569dcc7e052e47b6b476c3887da6e49a0816f0a034c61d6f7619d72eb0f8170a8b67d8321d437e7affeadfbcf8b9f21c6566f0b7ad349bbd481e4abc2650b619e4a6a0f6e6051802d1592c7870dfa1462d8108d06d7fd87d5fdbfc86f883b8dd897833da981d803dc8a351965dab7965dfe660405cdffdc28d2fe459cbc179de3dde9a3546bea73bd91aadebff3fd39e7c8473b086dc50aed19932d26757ac8f25b7f6f8428375bae24534d2331ca551c330a2ec861e13c82dd3f49eab2481bbfea2f0e6ff2ecacff9d6fe869638de5f28d5b57feb3bf6b9436db8c34ccf6f32260c2450bc876567bdb1c747c28241471d3001e1f22ff7bd6ba7e5fffca96f15b02a40abef704f2d363c28a0487a736acb63911060f32020635406ad84040e88f1ec88e85385104a927036abb6e7ed171ea0eb8145f669c96d3ea15e40a6591332bd08d35d393e38ed20aa347f4aeb40a2e666c7ec395990bf3c9499362507b7c3d74b068afa3d97de6bf3c9bcc9f16dd634583388f48e1a139409904636cc3179343d2dc9ccb046a19433d6d4887a73bd9039dc50d4bcc9ddcca8d561b05044c2b9fc7532c79825c16da909cb1f11c13f05751447387ebe267294add9154d45a40c7ef1c36be2e48a4"
},
{
"path": "m/44'/397'",
"node_secret": "9058b860f62989e323875ac4b1f5e11f61b659bb7f4faf244c9b69151b7d36aa",
"chain_code": "685f1b3530adcb2918fb8ba1debf0ca5cf79bedeba4bf0f9be2058525b991aeb",
"public_key": "2718ed8367f8eaa54b5c680dadf71cf0bad87d5d750c24e2977e87a9d785a36bdebb03715a627f7460451724a814e7843ccdc5c5fb6ef659bbe46dbd4df7444dce6b342fc254565361f64ff862d64f20818ed42eabe03e15d3b97e0cd4c4f6e4dfdb4048caac0fb5e5919448d0eb3b41c2ea28de0bb4a58304b50aec1a3f12e945d4095b7bbd42aa2ba0c3adea1851299655fa516fee108b55899faebb1e3bcbd3691b38c30fea40fa747e569c652dc2a38df23768a2bc771fe3e8e186e33945e13277527b2ee0e6bf9af81dd16ef6bd9553abaf78661b518d95d1ef8d49378f4014fb4cadf7c5fc14be3161afa4d561159602eaf728578512179894c037ffa97cb69fc2e847dcb86ff7956d98affb1cec03e8005170a88b3f403958fad0726c02feba68c9cdcad468a08e295c6a730cb8507dea9261850464a6213919beaeb5ab9dc1298b0e6726f716ce476ebdebb4d136c1bf91c9078ce0971520fabbd976fa8573a67aff12e11af85b12867639e78468766123f599a8d2a42b9424aeba080c599bbab04ef99f0c15ff6aa6037de8df311f3dbef01fb336b994605d34fc5bcdcd900eb545af76d159183f7a2690a4e148d0399f2d1cda83e03bc40aae89b12a770611b2a22006abd13afa908dbc2ccde6081502c5ef7fc55e22c28c2d96813436dfaabe27a01f91b49c392d6d468db6e22de6b96d7e55a886ef3f100285571e0881074812202a688df2715c6a0fa30d6e3a83d0fb70100c6c268aa1b03b07d4a62290ec9b47720c80ecee19512a8bac7696147c4029f5e2446657229fe66ac034911d6b2513eb0d514e3464f4009966fb658f1c9a4b1106203a17e30ae8129108aa036b455af8f2dfaebe6a0625c60a197216eb6fbd4acd560886d1500bd325e19af4fd746c74674fdeee320e6a8e5b296b53e420c3e697869683eb6de75aa6faa5da4d58a1e79f7c191d1962ca80ff8e5d5c37d6d2ed2b267d3d5ced818f056ed3843d4eaafccaef1d31a3fdbfa4eb164f361c150b93abfb49ec86fdfc15fa00ea641913dae61e495e45dd4d2f9c7fcc6693bfa23b87fb0c2d75dea81477e930eff5f1d8e263364209cb596d2995b41054516a632636b354960df04c95f8f4489f270368448c13fed3941d22acbfdf1859391d897f0dbfd6a022b7e0fa33a800ab64de0093f6251cd8b9a5059ca1def9f0c4d587f0021499b117a173fad903d519a947f457c3713eee65da120d299ffce5edf6527aedea74c8b099a1e792fc5a4f52365414aba5d771535fc9503e4dab9a115228d8fcb5e954dd5f62fc0b150e359bd6f1112cd2dfe0cec119f7523cdf8d6cdf2ca7e134212ec713031e4c2efae5538168d5df9863e58350f8f98b354b0aba673e754fe1948f586dd583d2439f6d024719c5ec0eee6747b41d1e5579fbd680fcebe024f0456bd878dac25cbc4b75b6e2dbcb0b7ff80c57caa753ccb877d9c31af51454bb92c20bea9ec6acbed394f88d3a613c6d4bb4b543a01059c2a9e83b01dca256ead3676a8b156a5e9e27699179370a60fae97e2ac1b726dcdd86d76b265398b462c7a56e546720675656d20da16d0f7b30a662fc321202328641c1b1ee2254a670a6ba987689f4d18b40b792556fbb379219d3bf6cffb63ff77410717629498ffdd8349bb6bef7284fc951842551ccd04fc3723e944798e555d53926ad76f7a5abb4f6175940b0b0eb83c8e15677bd4834787abff79b88a2c650550757fe9cdb9629aa4987a3902ed6a6b1dd512fbd3da33ee1fe5eded3764b4dfeaccdf3af75fabecdfde105f671eb46a0fa99999fadaaee73ad0495a5774bd7ccf167fdd6b7742a04642df4e62f3928393578c4c5b03a645b5dffa2c44f6bbf53ce4b8ec5a75ad6bf5195bccb3c7dd23aa7f782c07457bc2d3a1a7a991e8642d5050f7d9c07a2e6461d041f3ae8dffa97769fd465a7e6488e100d59633c430f929d540d959345053a387df317fe7d002320a32274f4272fbb0872ff21b381422bea550d17a79ff84a4359a251beb3f59de606d19449e9027a42ab0ea214fb9006166fe03c6af01b3ac7b3c0a1e2178fe4ab67958ea294a366878b887e169a49c71d93edda9e5bed64cd78a4490d408ed93118b7b4805cad40cbc9fafd6cae3541851a8c0cab6b3281f39db46744d1643c19f6fd61a729481b7b420f41d92b2ecebbf178024ffeb9ce225ef186d1e6f9c0b020f2d52ad87f512c703895726da81c519ae8d20f5b0a79729f14ff4cea3c52239e3b77611cbc418450ded85b0b3cf02336f927f113b6b0cdd6040c021519e225f06fdada06aff07ee3bf8156b41325b879f692d27825eb2a286c429aa6b2e3ebdb514f888a70d303eb1e907c387a4411bed474faa6a9b03ea39e32d33ed7c2a693cfcc9918ece16b6e527b228915763ca0bb9296ede4d6197bc9dac85c4e3c1aa7cb207d501dd44986354477e3fc2cb93121d41c481e3e556be354b8c7b1a4b5fa7badc4bae44a64d8ec4189e237e8ca6ae40a7b951da67c5475b059195a6e57f4f0350a2bb2a77a241ce66cdc04f20dc7b36cdde2f98db233c13b2dddd2390d725b26d891368ae4e02087fc2be512f25905ff32496630acfb14c3522f0213d04048e5a6b6b680aaef650a71572936909f40bba9c02a005de64565e711cf5dd88691ba14cac350dd66257407f2cc978143606ca2ebfc89d43c1abb8503d1450c4193e3be31a40fa7a80442933b545fab163fa799e3e6c543f04bc089"
},
{
"path": "m/44'/397'/0'",
"node_secret": "e82fe9d0fd2a6af0798736fc2ed44907faf66d4a7a543499e7f1c04f18a7d3c4",
"chain_code": "c95feddbe3025baf6bc5b448b4c8f5c81817b653d594fe554f80c2da8e1a431d",
"public_key": "d90973ab0e1e7f6d2d9ce05347ac9375898061bab6948c0ebbed3465a066a0e56a389676c53df14b905a64d7ce650d479f26f40665eb09b705632c176eabed5d7bbe48fcd9c566d1ec2bc2be6126e3e9e22928ccd87e31c5c74e3485f57a323d777592fd679da23c9291edc5679bd212d5f232d76991c798c331ed2cdcd1ee460e75326792fc53af609bcb42c6265528645e937618ddcb1972545957b1a2816e212f08576b2412c94925e060a7f4f5afa4bde06d29e2245013604883fa28a966e214f9203a40181ec37f27980d1f95c4871dfbbf188fc51e80b560184928bb73feeadf53e9e6ae1e3bdbd1b6ce7c9e7201cb481f3269a263c043b0b016afe455a9e4c542b5f167aadad9e9e5fc96bc3dd04931c587010d10c3d64020b2b23f97b5d60fe64bdaa91a480cb3e946ab2a2391bb5b398b0c8fbcd8c9cf28d234318fa7574ba8ca5cd55fd6e0fb7fd190213f0983026257965b4bd74c7a0368242b02fadcbe75631f458bb0284c00f772da3388124cbc69b22acd356fb6d2488bad92d2a4e92d1b568fe8608962b238881b8ae4ae88f5faa14c721a1032b6b7f60cad6b0862a8656373e7dc0fd28899f2a05feab633f8d6ceb94be5f54fa2ee1477bba01fe1ef874b8ae9fab846f94f9f35ebe3443b7fb7e85347bc8fd9d2ee6df69d932729583b6ef92b0d7620fcbafc49a1b12ec4850fda1d4f6c0378398af0a41ec5f459b84718a5125e8e5e497a44feb702ed1561b81757d8601cfec28b0dcd1e62e2f17b75bf7d284fd7e0870a36477b0ee28651fe48a94f5163ae908f3e538e95c63b9852c672fdf2fba9f5e312c0d6bb113fcd95e290eaf067fbda6a9dd5019328805d05a0e652a3bf0bf8d9c7af41a0cd36fda690b66a1198c2343292736e6b81f885046e59d51cf774650bfb0887fb10a419202ba3e78c8b8942024977e0649d18a5d62568d076e54f4848d13c14c55607ac1d9fd1930b70d4ef0a2f6f932dd11bac2b7766306cd97323ab399852b68e7c930162872b7a19f18727d76413f032de1198b312989261d8d8882162c986ea046cfd1bd0d2640ae50c2fcbcd859ad4c04b0dbc46ef6946db30ba58c0d692e9b42f8a62e05f175c8f4ce67c7344dad6c5a993b8d0721f4a1292dbbbc419cb2de7c47a0ca21216488e13eb5d4ca17b1d2c903bf39efc18f48581d1f392b8cc9e7656e68d70765c16ce0ca914a2bdd7992582c3251e62209d7db0891afcd34707bab8546de6305afe9bbf9d22f271e9e769658c33bc5487a3c4542c42f3a8a8f2c03c9d557b442baa755abccb6a5e681b6b107902bac63336819a9ae1d5d36e5219d01cccab3fa4ca6023ef31358d5d5d14e36e3716ab116563c9fe3fe1cd7fe824cdaceeeb0b5857515513fc212487ded2293cd84da07bd967b041f0a823bea5d8fd3c3fc3f7d3dd249a0ad937688ba7fe8f7f9dbe25bc9dd9994f5607fded2d349cb10e11e7ae3f8bae778c602f1a8ef2a99ce3a1c61ed8736d5905b418de65d5de0b5ae14795e8dbd1dd60e060db88ddbbbbacc8bcb34c5f7ee97bb84a5102cc0df818504c8f2ae750c2e8005ce7f8a1ccc940e92d9e75d6324ccb823c917c36d0982c70b033855d9c874ec44ada4968e942429aa7fb329026cf31961df7dd902fe8b197c93c32217a4c6c92833c372db8e1df8d1e5a2497f0f06a9d94062ebe6e718c7fbbdb178ea37cea887959fb78c1d0ffb37caec235298c02202e1887f9049aae9c227d57f3d07cf9a4148b924624b004cdcc90826260d92e11bb1c31dbb51048f34ba48968c1229373d52b64c5ef4a8912afc6a70497167ceecec0cdbce0d66b6650bdfabe11af9cfa6bc9a671f694fa773505f53fb21069997170442d3b296a856f447a601fab59e888925f3c23e0a2521180c31dfc51241071de01813ba52da744df4dc577c49b5f10b4faf15948fd6138993faf6128b763d956ae379d9284fc2d88f43cb0d45b8dbb825c55c2aedb172d32162cfd90ad0448640c2754a875f5834e6f28eea18dc068e9ceb04dec12c9691c7866a605f91cc0efe6426ccb0b1c9df3cf8c7be81dcf49d2963a61bf985eb07bb7d96d578adedd99115533d5ca4431e9c87d697d3a9f3533c1d0b8974cb4b63d4f2aeb96a7332563c934e58d2f3cd45542f85dc5aabf8c4c50ea88b1df191c6a09b672fe5c222891e21a69fe63a082c23da55186899a1fd8cb4545897da2d445b2ac501e1072a23956579fef95a623116ef63b87ddfdfe0410c0f88c2e6c33c31b7974e1139e748d7c7892db85e2141816a121539d7c7ac4fc298006207717ee051e931cbe94a372e2d1719531aa77dea002185dce0b726be6f76ca0e29b886b1c499ac1ab32a68df778b2202b528ab4a07315639a783c9588d336a9a25d619574421295ff0b6432978b94afb19e50e58f396d15097dfce8479c67392b405098b901eee45e5d990d41563ab2958d84f8dc84c9d5eaaf16ef4004089254890c6ea82c5b248c38169b704ac2eef4200f9da8b09b9f559bdbd22b48762fec17b3e1e1c0ab1d6319acfc5054f9a55c8833ca03fc91c07f7423a78c95473d93ff4f91d2e8d26421540d51e204618b553c39c4a904dd6b6e6894d5143b5d7f108d8dda2c54236096f0ccb3ef0c94452998ff3a6c8d108d25a99697d36c05254617841eb8d6395fdbd387e5cd754ed02f420fd29387d825337d466359a512afbe3245924d89b18e5039a9da381fcd6b1cd320af62d9eb3f71eb3c"
},
{
"path": "m/44'/397'/0'/0'",
"node_secret": "eed5785ee50dadf05c45472ff26bad88f458ce400d8e4b2b54168f406c40d4fa",
"chain_code": "d5e782cddf38253d0f9aebb168d69cd3d1ec09dc21d7dc5163e5553ef86b78dc",
"public_key": "6bcdc06ece6ff469432a0d798fb46138fadb7eeeda88fe6eeb25e1b69bfacf8a36390be2374fd0e80ab1741c26994205dc78e327589bb49f7ad74b1864c60d202ed93699c853e998397ef7a98f65e93faaf7d115bba5a81e22453cde9eaf858a21f3f1bfb17f39d3bd4b2f0642a0e90575011f0220e0aa8c46afb76b46bd782a4ff6b7b10162a9eb98d1cfdf58b3d0753bb59d67e466ae46c38942b37555f70a1c29e0e5c8ac33c0b414d19e6c7575214e4a48832b37c5dd3918965e73cce7830d970376206ae16df96839eb488927219906d28eea6bcfe94741f38f5a3ca38a596707752122cc40c0df416991115a7319071a706c5657f9c1bc0958b982d9a946288640a73c8ef353740a0e28c165b882344952645ef8f717c266e222381863c1efe9f4d174511b1f1f56bfb6f5d12333a96d78e54d3d1a5dbd861e8df1c503aa25a20c3eb3e3e3f87f6fede00af2706cb9e29b34394b7987b955a9522e8328fa9df2a1c7a3626b1ef5cc17687be4d13467a2b56d1684ea6ff849c5ce555f608a3a2d686db18e585a0de1ba5eca86927b0121095ee4a230e703bdf7ff77f75262089c2d545c8b5be712857494ce2b3ad7eca0c5c0b6813dea76acad186bdda66bfb58683504fe996335b96aae9d0a8984e6e29e11fc92d23d56dcf34ccdba048d88e6302956a7627395941b48649bc7eb3045963118dd1d6d2dfc25bc99e5eb8288b79cbe41111c2e4a221fe22357b69178b7fc0a15d4929e8ec858b8470d9f53457c65d9b95eb1951eb226a7daef80df51f004a4030f27601a415b659135ad95e9c65b7a384e411a322d1617d98f15cc48736db5523db4113faba6226466425c56354ffc27fa652ad674856c1b657a19d330133d9fde4d31abff996f1f4049b3a13edc28ae6ea9d5fb15530bc79954e1f85421e5d397feee5bccd658cd22eab341f22b9991d41424407e13dd9bf3a65a312b9fa923b2568a230be5422daff36c4c51257d452bc709881cc327ecedaca6d1475f2bbf08352fb6c7350a802fd1988bd29517722229ec83da8af1aeca9aa8ac511ecce25881a0b2f7f0bbb028adbece7d826586afd348846add791e3186bd54687d61e755348a5b604704cf156bd5513d45c549ef80ca7ec638da67e18fd819988994460e0ab09cc07e919694bee169b59d52ba047c9ac82c78f92e620decfafe5e40b010c881365fa160c48c1534ddada15cc35ce77b44f03d5a425e981a076d0ee1117f39cb6b028f617ccbf757f8602be63cee0bd06103b7ea8c3e6304ebe74281819c1e9862018451e68983b32e9420162d5d496e67cf92dcf0abcab7f2147f07dbe9ef5b337d70bf57bfee7d976fc2ccdc63e40cd4382a4e943da00f80e45345381426a755077b4785e44429cc7b729016950222d4d988d88f27503db6447320590e6d9f9c15c0958f02a4378594ac2b3f71803f4bb1ad35ff3e3c5ffe3cd87188a0aa83e19fc065a7f4c8210abc5772fb7114029e18c0fb0b2aef3670a106524a5e9bb599aada5e1e0a4f5b4c5ca9481be4fda6235ede0f332c3aa069dc51b81b6c5ad06be2c1b40efec60140caa52662b724d2e247f8356d1eec4573541261edfe1d3e9bcf8443b7fbcc1c5bdd0149272d09c545b041344771c19cf8e2cb175f0cf1fac02915bf7ef9da96629dfa1d17b673e7fcd0576fc799bcea01997f96dece30b2248a2280561e3cea2fb409d40125fa5f9a759f063d52be7f07bcb6abb4be0ab50ad247737ffa10cb3d4cd8594e24c804efda53b57eb2fdeb3fae4da2660368e72834d660a0619cbff98d19c6a8c488e92e7505c522e719e64e2e34597938a930cef443673f26fad47a4b582f1336ce0425fd76d1ff62a693476c26c93163d35ca31993c17d367f261cc908bfe08e4ceabfca1cdc815952dd009e5c8506ef4fb8568a7b8f65234af86bf3cdd639966d1cc3d61e44c55871b86a222319da7efe6368277d31ccfb44f984b4a3c2e623bb1b50453ecdb554f9dc32845523339b04c1e74ce8d9c3b9f88fd5125a1e02ca6a9a15b484f3cb9dbb64afb7de36541f5f0062b2e5acf1a045b5fe6c42a40e74a6e004f6e1e59ba985e4c7c7cc12f3f6c91dbb1c5a4d19e51c8b0e4dda38a4859e71f6575f3461bf21e872b874cfc488d9d6b36aa79b2fceb3f229e17ea31f85578461a1ea3a39894518824b995b5d3ead432e8b3c9296ef9ec65e6f40c42c0cf1cbd7e0a2a696aa23c35174b4e9aebe748993b4baa8f0c519ce6169f8bec7ccd840f6f9771b5b04c0dfdccef6104b74c45ec07aa7a66ac1b41e4ef1b25afa9efbaa9baa0dd20af0d2c28d5a754c1f0bf91b4f397247c38d3d7fd9dd53732917edf984188d0dae9a07e1380a72bb14cface1cf71fd7a0dc5224b119710641fd71cfda47b3fcf81dd3216655b5865c1a19371cd8147c3184498ba66eff2dd7c47b37c8f749a20abf393cd13e265754de40e9f4bc6881d176ec5d2c81759ba063041492e5cdfaa6cd3ccaf05ed536b72caeb5847deddeb2bdbd76f2342208bc7620b7fcb6e44a6ea083678a37e0017fd3491929082b9e2152acaf154ac158dd144cdaf0c8a0d9bc018e372c0ee380d9ed1250779beecc330973f620492c569c7bff57468186a1ffebdb8d32a5f7e57381afa8fd2b42b17cd20e39a8e2acd5601b7fccf42a2d1e69947a6cc41282259741d53a74c40608c9298409faf7a6d1a064681746aa24871da6dafba0755c2384870be652ded5d9432d85d"
},
{
"path": "m/44'/397'/0'/0'/1'",
"node_secret": "908dd1a9a4bf8147e0ca25e95ac532827347a5f70eb7f7a3fc802c31448d6917",
"chain_code": "60d267dc0590b7c7ff6cf137514f7b098fc4c219f75d4cdfa532f258719293c2",
"public_key": "0bb0a31bacc0d30e0f315e7c022be2ce1e47562cd8da53e4236083beacd132bac22d9b1d1459bbab3a366e66e004302957544741d4304d1c96934069ca3a24a070871ea36fa62f9b6be66ca7acdf13a58099f5f8e745c47668c48bc15da97a149592072ab7de45b8f1b25e03132514e9026cb33ab5a815b3452cce783918e3fe90f688e19a490f5b0c7aed55bab0fc844c876e8ce464e70fe88e025778956fa52e57b2015c809e8a51ab0bcdbe35b4fbb149271a322162524c66ef6acfe3cb20c7d554ab4c517a28a4d7e9de897dd750e36d3c4691d14e57e6df0c982b18646fc6a8890a361493da09aa238c45c0cb11ce2012b2e435e3e496e13e491af921582747bbc10a68737eda754f84298e47f659926ab1a48927f2c661cbe58cbd5ec47f5a15a75e05ca343a0540b28b66974bc1b42c14e99ed84b2a8c443671d4d7dbbee61b8da0e5c00d41f2f41a5b9e2fa95a586bab1c6380d6e3130793a3eea94bf29efabb99f0c870137c1ff30ec2528694310bc7933cf3e1cfc76df8f09e4a8152f6512e3761e2ec54fedeec29e43ec0eb8070c040d087ff93f2ff2c063bd866f10bc586eee7f9cc052905a4b8a17f74106e98843cc987b38495307ec41b2f6a2b39b4efc986560034ddaf32fd5dda3a7cf3b7fc78da83794df056560f50c856326ddb3939ad60aaaf6ef162bc05c9a8c73d806b1d9238c154855a81f007a7e46e93f6111334d32e9b58c6767a3f55fb8fff8ad63c849581d9d14dca102e99184dd9c8470834e7761ea69e6b68198ef5d75738fc17e3b928a45ac2c0c7ee818691d2e2d7d7662d32e71f2e34a12960e45a3752a869f2352597d33f91f93125b513fc998afeb9a3fa1e89d511fef76217266d8be0f4429499a56ba4b3128c8c4a9c9d52177e900e75deb3b909698b28c76387d394806ebaa0ad59c903e50c556556e8ced0fd790e321ab9cf7f93dce22463f7c24dbe5b7f46a72b161484d167b714d167f84379307e712eea308be28e51132a17ce8dc4c72f397fda55553e855206c0bd81cc5933b1a71efef0c8b708d2dc0b400081454dd44a46457f85164e2bfbefcf9b6cf87a048ea7f71995038e996ca9830676444c21f102e77340de46752f58ae65e7e308f61cdaaf6eeb40f6b99b8dec2dbb2e815aed6beb4212931fadfc096ff8d1f6fd3aa9c45edbf013d2a2fa40956c242ac7e0f77c8fa2fbd0e912937c34cfbf3cb2528f6e8e46892d4c3f5b81b8fc5636b3a809637351aeae2934b9dd4eab0c4ebc43b20b4ee2ad84a5803533995f3fa51c198adcd39ff818f4b59409b18f8b183c967c7e610fcbdd8067e4574f1f46c6f8f129129f6067235ddaa1cf8319905f7aadb078c3a09f0fd913e1b1b8e77306a55048feb994de78350d35e26c4e3fdc9d6fd0fe06142465ee5aca8a460c9c0ae9f9f9b5b10f5ea4d4a7bb4be46faff216de6b49c05a588621b89e2e68d5030d21f0bcca3643c0664e6265bd3c9d51b9ef639c189c8da0e85b25651b73d90d6257f15bf1778049560e90d27fb1e551c256937bdac6ae0e1a35a2202994f69508251fb1056d7e43fd0c36f2e58b1fb93cfe78a92aacd54f3bd19ed5013e30f76ec8b0edb6f9619b8424134007a175b7d3ea105dc32d9ced025a49e7e265409f794fce7fc2a69f2c238af0ab99cf50d20ccc651b67e9f3f07b3218259be0520eb90ec3d7cb2d1d754703c5d5c070bfb3a513dc35213cadd43ebbf6f5e11c7ef81f4296744ace049465b601925fcd0f84ba9a24b2c2da1d8fa72719170efbb04298338d52fa9c51efaca0bb9a73ebf58039105898d5583cf90ef5037b87a0328d59e5fe3548d941dc55500319cb18f692d590242157e11f28a7a41ea90f098bb90f665408a79addc344070f7386ac22e7d145a9cf7a6bdc342ef81da22bde923ee7a18050623b9883c5c1b30eca31a3772568d382e66a7b14152f918a403d085ce6deacc59231f31bb58844b43522baa7b801e66c523b14f0daf5e44fc6440ff160830877e553dc30ac7c4d90a5baa75fb91aa0245c9910ea2900f12070faa186e0e8a2a340d1455fec63a0818fb45bf827857cbbc9c920357e24554c262f0d296e5de25eaa42e752ebe9c41d6230b214deeeb06ae947da7bd9c8abd04e8e578676719f32f5066819b470e30dd419b7ae5bc46f75af4a5c02b919059af1d72b6f20f2d9deda7f912effdf87e8e058cb1bef5d051cb97a63c7e38267db9cf915649a86226ce556fa32e9041d3879df3acb7e92da6c76cc6e5f4b8dc962b923f3cde89eee3d9aefbb7b2e2b1d50b5a5267f304e0bcc903178dc4de96a1776139c3d098200cd1763fdd14e343b614c8a88ecc8105c425e85d199ac56b95547e54247b88d310142b30187f59bcace3aabc35b931d201e89139aaa6d7d871e986f1ee2339ef5fc0827ee569bc7f187588eb7ba7b3f7a9f8652c99ff33eda7f5ef4b7e06906229f4be5121b4b9ce1278b2a7715143adc001039db74857f8a57806361a7fa54eef7220a8e40353d562c46a1a8705b5e82a5acef1e165fed11c19acf30ed99131303b36e1e20a40b5d285cd05dbde67bc716f84e46a2e3a072e4d224845269ece211fd47f6858951f08211d945e4eb3d34a9cf447b652d308ec9eacedc24603f935c77e2d73bba619e46bae15a0d1e1cd97af3072a93143493f0034b4799cffd68c8e2fdd7c24d87c911426d428d819723bfc423ea6d7cf1a5a356c61563b909524a4bff4f1fa6c75e"
}
]
}
]
+28
View File
@@ -123,6 +123,34 @@ what was signed (the review is over local bytes), and cannot take funds:
the worst is a stale or false view, and the UI shows the finalized
height beside the best so the lag is visible.
### The wormhole: what may never reach a node
Mining rewards and other wormhole deposits are leaves in `ZkTree::Leaves`,
readable by anyone with the recipient and amount in the clear, and an exit
publishes the nullifier of the leaf it spends. So the link between a
wallet and its exits is exactly what a node must not be handed.
- **Nullifiers never leave the client** (#68). The spent check reads whole
buckets of `Wormhole::UsedNullifiers` (keys sharing the first byte of
`blake2_128(n)`), at least 16 of them with random padding, in shuffled
order, and decides membership in Rust
(`crates/wallet-data/src/substrate/wormhole.rs`, `spent_among`,
`MIN_NULLIFIER_BUCKETS`). The test `no_request_carries_a_nullifier`
guards it. 0.1.0 and 0.2.0 looked each nullifier up by key, which put the
nullifier in the request; 0.2.1 fixed it.
- **The wallet's leaves are not named either.** The leaf scan reads every
index of `ZkTree::Leaves` in pages (`leaves_paying`), so the node cannot
tell which leaves pay the wallet, and no indexer is asked about wormhole
addresses.
- **Spending (#46, #47) must keep the rule**: the Merkle path to the leaf
being spent may not be requested for that leaf alone
(`zkTree_getMerkleProof(leaf_index)` names it). The secret and the
proof's private inputs stay in Rust; the proof is generated locally.
A node that is compromised or simply curious learns which buckets were
read and the ordinary accounts' balances, not which deposits are the
wallet's or which exits it will make.
### The indexer
`blackbeard.observer`'s `/v1` (`crates/wallet-data/src/history/observer.rs`)
+12 -4
View File
@@ -30,11 +30,17 @@ rather than a rewrite:
metadata plus a small per-chain profile (ss58 prefix, decimals, signature
enum, transaction extensions, signing-context rule, coin type).
- `SwapProvider` is a swap venue. NEAR Intents first.
- `NearRpc` is NEAR, the chain under the wallet's intents account, routed to
a node that tracks the shard of the account each call is about.
- `FiatRamp` builds a provider session through a relay on our infrastructure,
which holds the provider secrets, and opens it in the system browser.
The wallet is **post-quantum only**. Classical chains appear only as transit
legs of a swap or ramp; the wallet never derives a secp256k1 or ed25519 key.
That includes NEAR: the intents account is a NEAR account whose only key is
ML-DSA-65 (NEP-645), derived from the wallet's phrase as NEP-649 specifies.
NEAR verifies it under the empty FIPS 204 context, where Quantus requires
`QUANTUS_EXTRINSIC`.
## Why these choices
@@ -45,10 +51,12 @@ else, so the core is Rust. Tauri is the house desktop convention
(`~/git/architecture/generic.md` §4) and the only shell that packages rpm, deb,
msi and dmg with a Rust-owned IPC boundary out of the box.
NEAR's post-quantum work (their MPC fork with threshold ML-DSA-87) is not yet
able to sign a Quantus extrinsic, so the swap module is built and tested
against chains NEAR Intents already lists, with Quantus as a registry entry
that lights up when the bridge does.
QTC reaches NEAR as `qtc.omft.near` through a bridge with a deposit address
per account, and its liquidity is in Ref pools rather than with 1Click's
solvers. So swaps run through an intents account the wallet holds (#71),
using only calls `intents.near` authorises by the calling account. Its
signed intents (`execute_intents`) accept classical signatures only, and the
wallet never makes one. #72 ran the whole route that way on mainnet.
## Building
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wallet-ui",
"private": true,
"version": "0.2.0",
"version": "0.4.0",
"type": "module",
"packageManager": "pnpm@10.30.3",
"scripts": {
+15
View File
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SessionStatusInfo } from "./SessionStatusInfo";
/**
* What a scan for a phrase's used accounts found.
*/
export type AccountScan = {
/**
* Account numbers opened that were not already known.
*/
added: number,
/**
* Account numbers looked at.
*/
scanned: number, session: SessionStatusInfo, };
@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Somewhere the intents account can pay out to (#77).
*/
export type CashoutDestination = {
/**
* 1Click's asset id.
*/
id: string, chain: string, symbol: string, };
@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Which intents account this installation uses, and which wallet's
* phrase controls it.
*/
export type IntentsAccountRecord = {
/**
* The wallet whose phrase derives the account's key.
*/
wallet: string, account_id: string,
/**
* `ml-dsa-65:<base58>`.
*/
public_key: string, };
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
/**
* One token held inside NEAR Intents.
*/
export type IntentsBalance = {
/**
* The Intents token id, `nep141:<contract>`.
*/
token: string, symbol: string, amount: Amount, };
+29
View File
@@ -0,0 +1,29 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
/**
* A swap inside the intents account, priced both ways and the better
* route chosen (#76). Amounts are the tokens' own base units.
*/
export type IntentsSwapQuote = {
/**
* `pool` (a Ref DCL pool) or `1click`.
*/
route: string,
/**
* The pool, or `1Click`.
*/
venue: string, token_in: string, symbol_in: string, token_out: string, symbol_out: string, amount_in: Amount,
/**
* The swap fee (#82) in basis points, and in the input token for a
* pool swap. On 1Click it is inside the quote.
*/
fee_bps: number, fee: Amount, amount_out: Amount,
/**
* The floor: a pool refuses and refunds below it, 1Click refunds.
*/
min_out: Amount,
/**
* The other route's output, when it quoted, for comparison.
*/
alternative_out: Amount | null, };
@@ -0,0 +1,63 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Amount } from "./Amount";
import type { IntentsBalance } from "./IntentsBalance";
/**
* The open wallet's NEAR Intents account (blackbeard/wallet #74): the
* NEAR account its phrase's ML-DSA-65 key (NEP-649) controls, named by the
* sponsor from that key (#81), and what it holds. No secret: the key is
* public, and the account is derived again from the phrase on every look.
*/
export type NearAccountSummary = {
/**
* The wallet whose phrase controls the account (or would, once made).
*/
wallet: string,
/**
* Whether that wallet is open with its phrase, so the account can sign.
*/
owner_open: boolean,
/**
* Open wallets with a phrase that could own it, when none does yet.
*/
candidates: Array<string>,
/**
* `b<…>.blackbeard-observer.near`, whether or not it exists yet.
*/
account_id: string,
/**
* `ml-dsa-65:<base58>`: the key that controls it.
*/
public_key: string,
/**
* The NEP-649 path the key is derived at.
*/
derivation_path: string,
/**
* Whether the account exists on NEAR.
*/
exists: boolean,
/**
* Whether this wallet's key is one of the account's access keys. False
* for an existing account means it is not this wallet's to use.
*/
key_on_account: boolean,
/**
* Native NEAR on the account. The sponsor pays its gas (#81), so this
* is only the storage the sponsor deposited; `None` until it exists.
*/
gas: Amount | null,
/**
* Whether the sponsor will create it now: it does not exist, and the
* sponsor is running on terms the wallet accepts.
*/
can_create: boolean,
/**
* Why it cannot be created, when it does not exist and cannot be.
*/
sponsor_note: string | null,
/**
* What the account holds inside NEAR Intents, by the assets the wallet
* knows; nonzero balances only.
*/
intents: Array<IntentsBalance>, };
+8 -1
View File
@@ -1,5 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ChainId } from "./ChainId";
import type { IntentsAccountRecord } from "./IntentsAccountRecord";
/**
* Persisted preferences. Nothing here is secret.
@@ -34,4 +35,10 @@ preview_swaps: boolean,
* every unlock. Kept here because the keystore file is quantus-cli's
* and holds one keypair.
*/
wallet_accounts: { [key in string]?: Array<number> }, };
wallet_accounts: { [key in string]?: Array<number> },
/**
* The installation's one NEAR Intents account (#74, #81): every swap
* routes through it, whichever wallet's QTC goes in or out. Recorded
* when it is created or first found; not secret.
*/
intents_account: IntentsAccountRecord | null, };
+14
View File
@@ -20,6 +20,7 @@ import {
settingsSet,
unlock,
walletAddAccount,
walletDiscoverAccounts,
walletsList,
} from './wallet'
@@ -82,6 +83,19 @@ export function useAddAccount() {
})
}
/** Scan a wallet's phrase for account numbers already used on `chain`. */
export function useDiscoverAccounts() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ chain, wallet }: { chain: ChainId; wallet: string }) =>
walletDiscoverAccounts(chain, wallet),
onSuccess: (scan) => {
qc.setQueryData(queryKeys.session, scan.session)
qc.invalidateQueries({ queryKey: queryKeys.settings })
},
})
}
/** Lock one wallet (pass its name) or every open wallet (pass nothing). */
export function useLock() {
const qc = useQueryClient()
+2 -2
View File
@@ -1,5 +1,5 @@
// Open a page in the system browser. Only the explorer hosts allow-listed
// in the capability file will open; anything else is refused by Rust.
// Open a page in the system browser. Only the explorer hosts and the
// wallet's own repository, allow-listed in the capability file, will open; anything else is refused by Rust.
import { openUrl } from '@tauri-apps/plugin-opener'
+77
View File
@@ -14,6 +14,7 @@ import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
import type { CreationStart } from './generated/CreationStart'
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
import type { Settings } from './generated/Settings'
import type { AccountScan } from './generated/AccountScan'
import type { AssetInfo } from './generated/AssetInfo'
import type { HistoryCursor } from './generated/HistoryCursor'
import type { Order } from './generated/Order'
@@ -21,6 +22,9 @@ import type { SwapAsk } from './generated/SwapAsk'
import type { SwapPreview } from './generated/SwapPreview'
import type { HistoryPage } from './generated/HistoryPage'
import type { WalletSummary } from './generated/WalletSummary'
import type { CashoutDestination } from './generated/CashoutDestination'
import type { IntentsSwapQuote } from './generated/IntentsSwapQuote'
import type { NearAccountSummary } from './generated/NearAccountSummary'
import type { WormholeSummary } from './generated/WormholeSummary'
import type { WalletError as WalletErrorShape } from './generated/WalletError'
@@ -116,6 +120,15 @@ export function walletAddAccount(wallet: string): Promise<SessionStatusInfo> {
return command<SessionStatusInfo>('wallet_add_account', { wallet })
}
/**
* Find the account numbers a wallet's phrase already uses on `chain`: the
* chain is asked which derived addresses exist, and the ones found are
* opened and remembered.
*/
export function walletDiscoverAccounts(chain: ChainId, wallet: string): Promise<AccountScan> {
return command<AccountScan>('wallet_discover_accounts', { chain, wallet })
}
/** Lock one open wallet by name, or all of them when none is named. */
export function lock(wallet?: string): Promise<void> {
return command<void>('lock', { wallet: wallet ?? null })
@@ -211,6 +224,70 @@ export function wormholeSummary(chain: ChainId, wallet: string): Promise<Wormhol
return command<WormholeSummary>('wormhole_summary', { chain, wallet })
}
// The installation's one NEAR Intents account: controlled by one wallet's
// phrase, named by the sponsor from its key, read from NEAR. Every swap
// routes through it. Only public fields come back.
export function nearIntents(): Promise<NearAccountSummary> {
return command<NearAccountSummary>('near_intents')
}
// QTC between Quantus and the intents account, over the PoA bridge. The
// deposit address is permanent per account; a withdrawal is signed in Rust
// and relayed by the sponsor, and returns its NEAR transaction hash.
export function nearQtcDepositAddress(): Promise<string> {
return command<string>('near_qtc_deposit_address')
}
export function nearWithdrawQtc(amount: string, to: string): Promise<string> {
return command<string>('near_withdraw_qtc', { amount, to })
}
// A swap inside the intents account: priced on a Ref pool and on 1Click,
// the better taken. Tokens are contract ids (qtc.omft.near, wrap.near, …);
// the amount is in the input token's own units, as typed.
export function nearSwapQuote(
tokenIn: string,
tokenOut: string,
amount: string,
): Promise<IntentsSwapQuote> {
return command<IntentsSwapQuote>('near_swap_quote', { tokenIn, tokenOut, amount })
}
export function nearSwap(tokenIn: string, tokenOut: string, amount: string): Promise<string> {
return command<string>('near_swap', { tokenIn, tokenOut, amount })
}
// Cash-out: pay a token out of the intents account to an address on
// another chain, through 1Click. Rust checks the address by the
// destination chain's form before anything is priced or signed.
export function nearCashoutDestinations(): Promise<CashoutDestination[]> {
return command<CashoutDestination[]>('near_cashout_destinations')
}
export function nearCashoutQuote(
tokenIn: string,
destination: string,
amount: string,
payTo: string,
): Promise<IntentsSwapQuote> {
return command<IntentsSwapQuote>('near_cashout_quote', { tokenIn, destination, amount, payTo })
}
export function nearCashout(
tokenIn: string,
destination: string,
amount: string,
payTo: string,
): Promise<string> {
return command<string>('near_cashout', { tokenIn, destination, amount, payTo })
}
// Ask the sponsor to create it, controlled by `wallet`'s phrase. The wallet
// makes one, and only one. Returns it, read back.
export function nearCreateAccount(wallet: string): Promise<NearAccountSummary> {
return command<NearAccountSummary>('near_create_account', { wallet })
}
// Swaps, in preview: the assets the provider and the registry agree on, a
// dry quote, an order (a wet quote persisted with its deposit address),
// and the list. Rust follows open orders and emits `orders:updated`.
+596
View File
@@ -0,0 +1,596 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { copyText } from '../api/clipboard'
import { useSession } from '../api/hooks'
import {
nearCashout,
nearCashoutDestinations,
nearCashoutQuote,
nearCreateAccount,
nearIntents,
nearQtcDepositAddress,
nearSwap,
nearSwapQuote,
nearWithdrawQtc,
} from '../api/wallet'
import { formatAmount } from '../lib/format'
/**
* The installation's NEAR Intents account: one NEAR account, controlled by
* one wallet's post-quantum key, that every swap routes through whichever
* wallet's QTC goes in or out. Named by the sponsor from the key, so it is
* known before it exists; everything here is read and decided on the Rust
* side.
*/
export function NearIntentsPanel() {
const qc = useQueryClient()
const account = useQuery({
queryKey: ['near-intents'],
queryFn: nearIntents,
staleTime: 30_000,
retry: 1,
})
const [owner, setOwner] = useState('')
const create = useMutation({
mutationFn: (wallet: string) => nearCreateAccount(wallet),
onSuccess: (made) => qc.setQueryData(['near-intents'], made),
})
const a = account.data
const active = !!a?.exists && a.key_on_account
return (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">NEAR Intents account</h2>
{a && (
<span className="muted" style={{ fontSize: 12 }}>
{a.exists
? a.key_on_account
? a.owner_open
? `active · ${a.wallet}`
: `${a.wallet} is locked`
: 'not this wallet’s key'
: 'not created yet'}
</span>
)}
</div>
<div className="panel-body">
{account.isPending && <p className="muted">Reading NEAR…</p>}
{account.isError && <p className="error">{account.error.message}</p>}
{a && (
<div data-testid="near-account">
<div className="field">
<label>Account</label>
<div className="mono address">{a.account_id}</div>
<span className="muted" style={{ fontSize: 12 }}>
Every swap runs through this one account. It is controlled only by {a.wallet}’s
post-quantum key (ML-DSA-65, derived from its phrase at {a.derivation_path}); the
same phrase finds it again anywhere.
</span>
</div>
{!a.exists && (
<div className="field">
<p className="muted">
The wallet’s sponsor creates it and pays its NEAR fees, so you never need NEAR. It
is made once: choose the wallet whose phrase will control it.
</p>
{a.can_create ? (
<div className="row">
{a.candidates.length > 1 && (
<select
className="input"
value={owner || a.wallet}
onChange={(e) => setOwner(e.target.value)}
aria-label="Controlled by"
>
{a.candidates.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
)}
<button
className="button"
type="button"
onClick={() => create.mutate(owner || a.wallet)}
disabled={create.isPending}
>
{create.isPending ? 'Creating…' : 'Create account'}
</button>
</div>
) : (
<p className="error">{a.sponsor_note ?? 'The sponsor cannot create it now.'}</p>
)}
{create.isError && <p className="error">{create.error.message}</p>}
</div>
)}
{a.exists && !a.key_on_account && (
<p className="error">
This account exists but {a.wallet}’s key is not on it, so the wallet cannot use it.
</p>
)}
{a.exists && (
<table className="table review">
<tbody>
{a.intents.length === 0 && (
<tr>
<th>Held</th>
<td className="muted">Nothing in NEAR Intents yet.</td>
</tr>
)}
{a.intents.map((b) => (
<tr key={b.token}>
<th>{b.symbol}</th>
<td className="numeral">
{formatAmount(b.amount)} {b.symbol}
</td>
</tr>
))}
</tbody>
</table>
)}
{active && !a.owner_open && (
<p className="muted">Unlock {a.wallet} to move anything in or out of it.</p>
)}
{active && <QtcBridgeLegs canSign={a.owner_open} onMoved={() => account.refetch()} />}
{active && a.owner_open && a.intents.length > 0 && (
<SwapLeg held={a.intents} onMoved={() => account.refetch()} />
)}
{active && a.owner_open && a.intents.length > 0 && (
<CashoutLeg held={a.intents} onMoved={() => account.refetch()} />
)}
<div className="row">
<button className="button" type="button" onClick={() => copyText(a.account_id)}>
Copy account
</button>
<button
className="button"
type="button"
onClick={() => account.refetch()}
disabled={account.isFetching}
>
{account.isFetching ? 'Reading…' : 'Refresh'}
</button>
</div>
</div>
)}
</div>
</section>
)
}
/**
* QTC in and out of the intents account over the PoA bridge (#75). In: the
* account's permanent Quantus deposit address, paid with an ordinary send
* from any of your accounts. Out: a withdrawal to any of your open
* accounts, relayed by the sponsor; the bridge keeps 0.002 QTC.
*/
function QtcBridgeLegs({ canSign, onMoved }: { canSign: boolean; onMoved: () => void }) {
const navigate = useNavigate()
const session = useSession()
const mine =
session.data?.wallets.flatMap((w) =>
w.accounts.filter((x) => x.current_scheme).map((x) => ({ ...x, walletName: w.wallet.name })),
) ?? []
const [showDeposit, setShowDeposit] = useState(false)
const deposit = useQuery({
queryKey: ['qtc-deposit-address'],
queryFn: nearQtcDepositAddress,
enabled: showDeposit,
staleTime: Infinity,
})
const [amount, setAmount] = useState('')
const [to, setTo] = useState('')
const withdraw = useMutation({
mutationFn: () => nearWithdrawQtc(amount, to || mine[0]?.address || ''),
onSuccess: () => {
setAmount('')
// The bridge pays out on Quantus within about a minute; the balance
// here drops as soon as the withdrawal is final.
setTimeout(onMoved, 3000)
},
})
return (
<div className="field" data-testid="qtc-bridge">
<label>QTC in and out</label>
{!showDeposit ? (
<button className="button" type="button" onClick={() => setShowDeposit(true)}>
Deposit QTC
</button>
) : (
<div>
{deposit.isPending && <p className="muted">Asking the bridge…</p>}
{deposit.isError && <p className="error">{deposit.error.message}</p>}
{deposit.data && (
<>
<div className="mono address">{deposit.data}</div>
<span className="muted" style={{ fontSize: 12 }}>
The account’s own deposit address on Quantus; it does not change. QTC sent here from
any of your accounts arrives in NEAR Intents a few minutes later. At least 0.01 QTC.
</span>
<div className="row">
<button
className="button"
type="button"
onClick={() => navigate(`/send?to=${encodeURIComponent(deposit.data)}`)}
>
Send QTC here
</button>
<button className="button" type="button" onClick={() => copyText(deposit.data)}>
Copy
</button>
</div>
</>
)}
</div>
)}
{canSign && (
<>
<form
className="row"
onSubmit={(e) => {
e.preventDefault()
withdraw.mutate()
}}
>
<input
className="input numeral"
placeholder="QTC to withdraw"
value={amount}
onChange={(e) => setAmount(e.target.value)}
inputMode="decimal"
aria-label="QTC to withdraw"
/>
<select
className="input"
value={to || mine[0]?.address || ''}
onChange={(e) => setTo(e.target.value)}
aria-label="Withdraw to"
>
{mine.map((x) => (
<option key={x.address} value={x.address}>
{x.walletName} · account {(x.account ?? 0) + 1} · {x.address.slice(0, 10)}…
</option>
))}
</select>
<button className="button" type="submit" disabled={!amount || withdraw.isPending}>
{withdraw.isPending ? 'Withdrawing…' : 'Withdraw to Quantus'}
</button>
</form>
<span className="muted" style={{ fontSize: 12 }}>
The bridge keeps 0.002 QTC of each withdrawal; at least 0.01 QTC. The wallet’s sponsor
pays the NEAR fee.
</span>
</>
)}
{withdraw.isError && <p className="error">{withdraw.error.message}</p>}
{withdraw.data && (
<p className="muted">
Withdrawal relayed (NEAR transaction{' '}
<span className="mono">{withdraw.data.slice(0, 12)}…</span>). The QTC reaches Quantus in
about a minute.
</p>
)}
</div>
)
}
/** What the intents account can swap into (contract ids). */
const SWAP_TARGETS: { token: string; symbol: string }[] = [
{ token: 'qtc.omft.near', symbol: 'QTC' },
{ token: 'wrap.near', symbol: 'wNEAR' },
{
token: '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1',
symbol: 'USDC',
},
]
/**
* A swap inside the intents account (#76): priced on a Ref pool and on
* 1Click, the better route taken, the fee shown on its own line. Rust
* prices it again when it is confirmed; nothing is signed on a stale quote.
*/
function SwapLeg({
held,
onMoved,
}: {
held: { token: string; symbol: string }[]
onMoved: () => void
}) {
const strip = (t: string) => t.replace(/^nep141:/, '')
const [from, setFrom] = useState(strip(held[0]?.token ?? ''))
const targets = SWAP_TARGETS.filter((t) => t.token !== from)
const [to, setTo] = useState('')
const target = to && to !== from ? to : (targets[0]?.token ?? '')
const [amount, setAmount] = useState('')
const quote = useMutation({ mutationFn: () => nearSwapQuote(from, target, amount) })
const swap = useMutation({
mutationFn: () => nearSwap(from, target, amount),
onSuccess: () => {
quote.reset()
setAmount('')
setTimeout(onMoved, 4000)
},
})
const q = quote.data
return (
<div className="field" data-testid="intents-swap">
<label>Swap inside NEAR Intents</label>
<form
className="row"
onSubmit={(e) => {
e.preventDefault()
swap.reset()
quote.mutate()
}}
>
<input
className="input numeral"
placeholder="amount"
value={amount}
onChange={(e) => {
setAmount(e.target.value)
quote.reset()
}}
inputMode="decimal"
aria-label="Amount to swap"
/>
<select
className="input"
value={from}
onChange={(e) => {
setFrom(e.target.value)
quote.reset()
}}
aria-label="Swap from"
>
{held.map((h) => (
<option key={h.token} value={strip(h.token)}>
{h.symbol}
</option>
))}
</select>
<span className="muted">→</span>
<select
className="input"
value={target}
onChange={(e) => {
setTo(e.target.value)
quote.reset()
}}
aria-label="Swap to"
>
{targets.map((t) => (
<option key={t.token} value={t.token}>
{t.symbol}
</option>
))}
</select>
<button className="button" type="submit" disabled={!amount || quote.isPending}>
{quote.isPending ? 'Pricing…' : 'Quote'}
</button>
</form>
{quote.isError && <p className="error">{quote.error.message}</p>}
{q && (
<>
<table className="table review">
<tbody>
<tr>
<th>Route</th>
<td>{q.venue}</td>
</tr>
<tr>
<th>You swap</th>
<td className="numeral">
{formatAmount(q.amount_in)} {q.symbol_in}
</td>
</tr>
<tr>
<th>Fee</th>
<td className="numeral">
{q.route === 'pool' ? `${formatAmount(q.fee)} ${q.symbol_in} ` : ''}(
{(q.fee_bps / 100).toFixed(2)}%)
</td>
</tr>
<tr>
<th>You get</th>
<td className="numeral">
{formatAmount(q.amount_out)} {q.symbol_out}
<span className="muted">
{' '}
at least {formatAmount(q.min_out)}
{q.alternative_out && ` · the other route: ${formatAmount(q.alternative_out)}`}
</span>
</td>
</tr>
</tbody>
</table>
<button
className="button"
type="button"
onClick={() => swap.mutate()}
disabled={swap.isPending}
>
{swap.isPending ? 'Swapping…' : `Swap ${q.symbol_in} for ${q.symbol_out}`}
</button>
</>
)}
{swap.isError && <p className="error">{swap.error.message}</p>}
{swap.data && (
<p className="muted">
Swap relayed (NEAR transaction <span className="mono">{swap.data.slice(0, 12)}…</span>
). The result lands in this account within seconds.
</p>
)}
</div>
)
}
/**
* Cash-out (#77): a token in the intents account paid to your address on
* another chain through 1Click. The address is checked by the destination
* chain's form on the Rust side before anything is priced; a payout sent
* to a wrong address cannot be refunded.
*/
function CashoutLeg({
held,
onMoved,
}: {
held: { token: string; symbol: string }[]
onMoved: () => void
}) {
const strip = (t: string) => t.replace(/^nep141:/, '')
const destinations = useQuery({
queryKey: ['cashout-destinations'],
queryFn: nearCashoutDestinations,
staleTime: Infinity,
})
const [from, setFrom] = useState(strip(held[0]?.token ?? ''))
const [dest, setDest] = useState('')
const destination = dest || destinations.data?.[0]?.id || ''
const [amount, setAmount] = useState('')
const [payTo, setPayTo] = useState('')
const quote = useMutation({
mutationFn: () => nearCashoutQuote(from, destination, amount, payTo),
})
const cashout = useMutation({
mutationFn: () => nearCashout(from, destination, amount, payTo),
onSuccess: () => {
quote.reset()
setAmount('')
setTimeout(onMoved, 4000)
},
})
const q = quote.data
return (
<div className="field" data-testid="intents-cashout">
<label>Cash out to another chain</label>
<form
className="row"
onSubmit={(e) => {
e.preventDefault()
cashout.reset()
quote.mutate()
}}
>
<input
className="input numeral"
placeholder="amount"
value={amount}
onChange={(e) => {
setAmount(e.target.value)
quote.reset()
}}
inputMode="decimal"
aria-label="Amount to cash out"
/>
<select
className="input"
value={from}
onChange={(e) => {
setFrom(e.target.value)
quote.reset()
}}
aria-label="Cash out from"
>
{held.map((h) => (
<option key={h.token} value={strip(h.token)}>
{h.symbol}
</option>
))}
</select>
<span className="muted">→</span>
<select
className="input"
value={destination}
onChange={(e) => {
setDest(e.target.value)
quote.reset()
}}
aria-label="Cash out as"
>
{destinations.data?.map((d) => (
<option key={d.id} value={d.id}>
{d.symbol} on {d.chain}
</option>
))}
</select>
</form>
<div className="row">
<input
className="input mono"
placeholder="your address on that chain"
value={payTo}
onChange={(e) => {
setPayTo(e.target.value)
quote.reset()
}}
aria-label="Pay to"
style={{ flex: 1 }}
/>
<button
className="button"
type="button"
onClick={() => {
cashout.reset()
quote.mutate()
}}
disabled={!amount || !payTo || quote.isPending}
>
{quote.isPending ? 'Pricing…' : 'Quote'}
</button>
</div>
{quote.isError && <p className="error">{quote.error.message}</p>}
{q && (
<>
<table className="table review">
<tbody>
<tr>
<th>Route</th>
<td>{q.venue}</td>
</tr>
<tr>
<th>You send</th>
<td className="numeral">
{formatAmount(q.amount_in)} {q.symbol_in}
</td>
</tr>
<tr>
<th>Fee</th>
<td className="numeral">{(q.fee_bps / 100).toFixed(2)}%, inside the quote</td>
</tr>
<tr>
<th>Paid to</th>
<td className="mono address">{payTo.trim()}</td>
</tr>
<tr>
<th>You get</th>
<td className="numeral">
{formatAmount(q.amount_out)} {q.symbol_out}
<span className="muted"> at least {formatAmount(q.min_out)}</span>
</td>
</tr>
</tbody>
</table>
<button
className="button"
type="button"
onClick={() => cashout.mutate()}
disabled={cashout.isPending}
>
{cashout.isPending ? 'Sending…' : `Cash out to ${q.symbol_out}`}
</button>
</>
)}
{cashout.isError && <p className="error">{cashout.error.message}</p>}
{cashout.data && (
<p className="muted">
Cash-out relayed (NEAR transaction{' '}
<span className="mono">{cashout.data.slice(0, 12)}…</span>). 1Click pays out once it sees
the transfer, usually within minutes.
</p>
)}
</div>
)
}
+21 -2
View File
@@ -1,5 +1,6 @@
import { NavLink } from 'react-router-dom'
import { useLock, useSession } from '../api/hooks'
import { useAppInfo, useLock, useSession } from '../api/hooks'
import { openExternal } from '../api/opener'
import { NetworkSwitcher } from './NetworkSwitcher'
const SECTIONS = [
@@ -11,7 +12,13 @@ const SECTIONS = [
{ to: '/settings', label: 'Settings' },
]
/** The release tag this build was cut from, on the repository. */
function tagUrl(version: string): string {
return `https://git.lair.cafe/blackbeard/wallet/releases/tag/v${version}`
}
export function Sidebar() {
const info = useAppInfo()
const session = useSession()
const lock = useLock()
return (
@@ -20,7 +27,19 @@ export function Sidebar() {
<div className="mark-name">
black<span>beard</span>
</div>
<div className="mark-tagline">wallet</div>
<div className="mark-tagline">
wallet
{info.data && (
<button
className="linkish mark-version numeral"
type="button"
title={`Release v${info.data.version} on git.lair.cafe`}
onClick={() => void openExternal(tagUrl(info.data.version))}
>
v{info.data.version}
</button>
)}
</div>
</div>
<nav className="sections" aria-label="Section">
{SECTIONS.map((s) => (
+9
View File
@@ -105,6 +105,15 @@ select {
color: var(--text-muted);
margin-top: 6px;
}
.mark-version {
margin-left: 8px;
font-size: 11px;
letter-spacing: 0.02em;
text-transform: none;
}
.mark-version:hover {
color: var(--text-primary);
}
.sections {
display: flex;
flex-direction: column;
+25
View File
@@ -1,5 +1,6 @@
import {
useAddAccount,
useDiscoverAccounts,
useBalances,
useChains,
useLock,
@@ -11,6 +12,7 @@ import { AccountName } from '../components/AccountName'
import { ChainCaveat } from '../components/ChainCaveat'
import { NetworkOverview } from '../components/NetworkOverview'
import { PendingReversibleList } from '../components/PendingReversible'
import { NearIntentsPanel } from '../components/NearIntentsPanel'
import { WormholePanel } from '../components/WormholePanel'
import { schemeLabel } from '../lib/accounts'
import { formatAmount } from '../lib/format'
@@ -28,6 +30,7 @@ export function Accounts() {
const session = useSession()
const lock = useLock()
const addAccount = useAddAccount()
const discover = useDiscoverAccounts()
const unlocked = !!session.data && !session.data.locked
const balances = useBalances(settings.data?.network, unlocked)
const chains = useChains()
@@ -63,6 +66,14 @@ export function Accounts() {
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
{balances.isError && <p className="error">{balances.error.message}</p>}
{addAccount.isError && <p className="error">{addAccount.error.message}</p>}
{discover.isError && <p className="error">{discover.error.message}</p>}
{discover.isSuccess && (
<p className="panel-body muted" style={{ paddingTop: 0 }}>
{discover.data.added > 0
? `Found ${discover.data.added} more ${discover.data.added === 1 ? 'account' : 'accounts'} in ${discover.data.scanned} looked at.`
: `Nothing beyond the accounts already open, in ${discover.data.scanned} account numbers looked at.`}
</p>
)}
{session.data && (
<table className="table" data-testid="accounts">
<thead>
@@ -88,6 +99,19 @@ export function Accounts() {
</th>
<th className="num">
<span className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
{w.has_seed && chain && (
<button
className="button"
type="button"
disabled={discover.isPending}
title="Ask the chain which of this phrase's accounts have been used"
onClick={() => discover.mutate({ chain, wallet: w.wallet.name })}
>
{discover.isPending && discover.variables?.wallet === w.wallet.name
? 'Looking…'
: 'Find accounts'}
</button>
)}
{w.has_seed && (
<button
className="button"
@@ -154,6 +178,7 @@ export function Accounts() {
wallet={w.wallet.name}
/>
))}
{session.data?.wallets.some((w) => w.has_seed) && <NearIntentsPanel />}
<NetworkOverview />
</>
)
+20 -3
View File
@@ -3,14 +3,16 @@ import { Link, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import type { CreationStart } from '../api/generated/CreationStart'
import { pickWalletFile } from '../api/dialog'
import { queryKeys } from '../api/hooks'
import { queryKeys, useSettings } from '../api/hooks'
import {
chainConnect,
checkphrase,
unlock,
walletCreateBegin,
walletCreateCancel,
walletCreateConfirm,
walletImportFile,
walletDiscoverAccounts,
walletImportPhrase,
} from '../api/wallet'
@@ -43,12 +45,27 @@ export function OnboardingIndex() {
)
}
function useFinish() {
function useFinish(discover = false) {
const qc = useQueryClient()
const navigate = useNavigate()
const settings = useSettings()
const discoverOn = discover ? settings.data?.network : undefined
return async (name: string, password: string) => {
await qc.invalidateQueries({ queryKey: queryKeys.wallets })
const status = await unlock(name, password)
// A restored phrase may already use accounts beyond the first. Best
// effort: the chain has to be reachable, and a failure here must not
// stand between the person and their wallet.
if (discoverOn) {
try {
await chainConnect(discoverOn)
const scan = await walletDiscoverAccounts(discoverOn, name)
qc.setQueryData(queryKeys.session, scan.session)
qc.invalidateQueries({ queryKey: queryKeys.settings })
} catch {
// the wallet is open; Find accounts on the accounts page retries
}
}
// Leave onboarding before the session flips, so the shell mounts on the
// accounts page rather than on a route that no longer applies.
navigate('/', { replace: true })
@@ -254,7 +271,7 @@ export function Create() {
}
export function Restore() {
const finish = useFinish()
const finish = useFinish(true)
const [phrase, setPhrase] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
+5 -1
View File
@@ -1,4 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useBalances, useChains, useSession, useSettings } from '../api/hooks'
import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo'
import { transferPrepare } from '../api/wallet'
@@ -27,7 +28,10 @@ export function Send() {
const accounts = session.data?.accounts ?? []
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
// `?to=` prefills the recipient: the intents panel sends here to deposit
// QTC at the bridge's address for the account.
const [params] = useSearchParams()
const [to, setTo] = useState(params.get('to') ?? '')
const [amount, setAmount] = useState('')
const [window, setWindow] = useState<'none' | 'custom' | number>('none')
const [customBlocks, setCustomBlocks] = useState('')