Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
038f09774f
|
||
|
|
4f3b446cc3
|
||
|
|
814355c9f3
|
||
|
|
57ac504b93
|
||
|
|
4b407de277
|
||
|
|
5d5baf1c21
|
||
|
|
52ac684661
|
||
|
|
384c79446b
|
||
|
|
f5c214e4bf
|
||
|
|
913155d48e
|
||
|
|
7ddab3ebda
|
||
|
|
f4cc7a1605
|
||
|
|
cde186b3fd
|
||
|
|
1c9467c38d
|
||
|
|
b059a8d5d0
|
||
|
|
bfb016d76f
|
||
|
|
fb42138651
|
||
|
|
70dc6ea650
|
||
|
|
b454e4424f
|
||
|
|
dff158e457
|
||
|
|
91ec5fbadb
|
||
|
|
42d226cf9e
|
||
|
|
cb0fe536cd
|
@@ -37,6 +37,16 @@ valuable half of the history.
|
||||
- Derivation is `m/44'/189189'/account'/change'/index'`, every level hardened.
|
||||
Convention in the other Quantus wallets: index `0'` is ML-DSA-87, `1'` is
|
||||
ML-DSA-65. Match it or an imported mnemonic shows an empty wallet.
|
||||
- There is no scheme choice anywhere (#65). A wallet that carries a phrase
|
||||
opens with both schemes' accounts, ML-DSA-65 first (what quantus-cli, the
|
||||
mobile wallet and the extension call current) and ML-DSA-87 labelled
|
||||
legacy, plus the file's own keypair if it sits at another path. The file
|
||||
records the ML-DSA-65 keypair (`paths::CURRENT_SCHEME`); the rest is
|
||||
derived on every open (`keystore::accounts_for_phrase`).
|
||||
- Any number of wallets are open at once (#66). Nothing may read "the"
|
||||
wallet: session status lists `wallets`, each account names its wallet,
|
||||
`lock` takes an optional wallet, `with_seed` and `wormhole_summary` take
|
||||
one. The idle rule locks them all.
|
||||
- The keystore file format is quantus-cli's (Argon2id + AES-256-GCM). It is
|
||||
shared with the CLI and the browser extension; do not invent a second one.
|
||||
- `cargo test` regenerates `ui/src/api/generated` via ts-rs; commit the result
|
||||
|
||||
Generated
+28
-4
@@ -6956,9 +6956,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.1.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.1.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.1.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.1.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
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.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.1.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.1.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.1.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1314
-61
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,8 @@ pub fn run() {
|
||||
commands::wallet_import_file,
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
commands::lock,
|
||||
commands::session_status,
|
||||
commands::chains_list,
|
||||
@@ -74,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,
|
||||
|
||||
@@ -10,17 +10,21 @@ 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;
|
||||
use wallet_data::swap::OneClick;
|
||||
use wallet_entities::ChainId;
|
||||
use wallet_entities::SignatureScheme;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// A wallet being created: the phrase has been shown, the challenge not yet
|
||||
@@ -28,7 +32,6 @@ use zeroize::Zeroizing;
|
||||
pub struct PendingCreation {
|
||||
pub phrase: Zeroizing<String>,
|
||||
pub challenge: BackupChallenge,
|
||||
pub scheme: SignatureScheme,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
@@ -53,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.
|
||||
@@ -60,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 {
|
||||
@@ -67,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).
|
||||
@@ -99,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"))
|
||||
@@ -116,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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "cafe.lair.blackbeard.wallet",
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -8,9 +8,63 @@ pub const QUANTUS_COIN_TYPE: u32 = 189_189;
|
||||
/// The path the CLI, the extension fork and the mobile app use for a fresh
|
||||
/// account of each scheme: ML-DSA-87 at index `0'`, ML-DSA-65 at `1'`, so the
|
||||
/// two schemes never collide under one phrase.
|
||||
/// The path of account `account` under `scheme`, as Quantus's clients
|
||||
/// number them: `m/44'/189189'/<account>'/0'/<scheme>'`, where the last
|
||||
/// segment is 0' for ML-DSA-87 and 1' for ML-DSA-65 (the mobile SDK's
|
||||
/// `HdWalletService.pathForIndex`). Account 0 is the default path.
|
||||
pub fn derivation_path(scheme: SignatureScheme, account: u32) -> String {
|
||||
let leaf = match scheme {
|
||||
SignatureScheme::MlDsa87 => 0,
|
||||
SignatureScheme::MlDsa65 => 1,
|
||||
};
|
||||
format!("m/44'/{QUANTUS_COIN_TYPE}'/{account}'/0'/{leaf}'")
|
||||
}
|
||||
|
||||
/// The account number in a path this wallet derives, or `None` for any
|
||||
/// other path (a raw key's "m/", a CLI import at a custom path).
|
||||
pub fn account_of(path: &str) -> Option<u32> {
|
||||
let rest = path.strip_prefix(&format!("m/44'/{QUANTUS_COIN_TYPE}'/"))?;
|
||||
let mut parts = rest.split('/');
|
||||
let account = parts.next()?.strip_suffix('\'')?.parse().ok()?;
|
||||
let (change, leaf) = (parts.next()?, parts.next()?);
|
||||
(change == "0'" && (leaf == "0'" || leaf == "1'") && parts.next().is_none()).then_some(account)
|
||||
}
|
||||
|
||||
pub fn default_derivation_path(scheme: SignatureScheme) -> &'static str {
|
||||
match scheme {
|
||||
SignatureScheme::MlDsa87 => "m/44'/189189'/0'/0'/0'",
|
||||
SignatureScheme::MlDsa65 => "m/44'/189189'/0'/0'/1'",
|
||||
}
|
||||
}
|
||||
|
||||
/// The scheme Quantus's own clients create new accounts with: quantus-cli's
|
||||
/// `--scheme` default, the mobile SDK's `DilithiumSchemeExtension.current`,
|
||||
/// the browser extension's `DEFAULT_TYPE`. ML-DSA-87 is their "legacy" and
|
||||
/// stays fully supported. Written to new keystore files and listed first.
|
||||
pub const CURRENT_SCHEME: SignatureScheme = SignatureScheme::MlDsa65;
|
||||
|
||||
/// Every scheme an HD wallet is opened under, current first. A phrase gives
|
||||
/// a different account under each, so a wallet derives all of them rather
|
||||
/// than making the person pick and risk seeing an empty balance.
|
||||
pub const SCHEMES: [SignatureScheme; 2] = [SignatureScheme::MlDsa65, SignatureScheme::MlDsa87];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn numbered_paths_match_the_defaults_and_read_back() {
|
||||
for scheme in SCHEMES {
|
||||
assert_eq!(derivation_path(scheme, 0), default_derivation_path(scheme));
|
||||
}
|
||||
assert_eq!(
|
||||
derivation_path(SignatureScheme::MlDsa65, 2),
|
||||
"m/44'/189189'/2'/0'/1'"
|
||||
);
|
||||
assert_eq!(account_of("m/44'/189189'/2'/0'/1'"), Some(2));
|
||||
assert_eq!(account_of("m/44'/189189'/0'/0'/0'"), Some(0));
|
||||
assert_eq!(account_of("m/"), None);
|
||||
assert_eq!(account_of("m/44'/189189'/0'/0'/5'"), None);
|
||||
assert_eq!(account_of("m/44'/189189'/1'/0'/1'/7'"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +95,28 @@ pub trait Keystore: Send + Sync {
|
||||
fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>;
|
||||
}
|
||||
|
||||
/// A wallet the keystore has just decrypted.
|
||||
pub struct OpenedWallet {
|
||||
pub summary: WalletSummary,
|
||||
/// One account an opened wallet can sign with, and where it came from.
|
||||
pub struct OpenedAccount {
|
||||
pub signer: Box<dyn Signer>,
|
||||
pub derivation_path: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OpenedAccount {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OpenedAccount")
|
||||
.field("signer", &self.signer)
|
||||
.field("derivation_path", &self.derivation_path)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A wallet the keystore has just decrypted, with every account it holds:
|
||||
/// for a file that carries a phrase, one per scheme at its conventional
|
||||
/// path plus the file's own keypair if it sits elsewhere; for a raw key,
|
||||
/// that one key. Current scheme first; no two share an account id.
|
||||
pub struct OpenedWallet {
|
||||
pub summary: WalletSummary,
|
||||
pub accounts: Vec<OpenedAccount>,
|
||||
/// Present when the file carried the phrase. Wiped on drop.
|
||||
pub mnemonic: Option<Zeroizing<String>>,
|
||||
}
|
||||
@@ -110,8 +127,7 @@ impl std::fmt::Debug for OpenedWallet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OpenedWallet")
|
||||
.field("summary", &self.summary)
|
||||
.field("signer", &self.signer)
|
||||
.field("derivation_path", &self.derivation_path)
|
||||
.field("accounts", &self.accounts)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -312,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.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! The unlock session: the only place decrypted key material lives between
|
||||
//! commands. One wallet is open at a time. Every signing operation goes
|
||||
//! through [`Session::sign`], which refuses when locked, and the session
|
||||
//! locks itself after an idle interval. Time is passed in so the idle rule
|
||||
//! can be tested without waiting.
|
||||
//! commands. Any number of wallets can be open at once, each with its own
|
||||
//! accounts and seed. Every signing operation goes through
|
||||
//! [`Session::sign`], which finds the account in whichever wallet holds it
|
||||
//! and refuses when none does. One idle clock covers the whole session:
|
||||
//! using any wallet keeps them all open, and the idle rule locks them all.
|
||||
//! Time is passed in so the rule can be tested without waiting.
|
||||
//!
|
||||
//! Locking drops the signers and the seed; both wipe themselves on drop.
|
||||
|
||||
@@ -11,39 +13,65 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use wallet_entities::{SignatureScheme, WalletSummary};
|
||||
|
||||
use crate::{KeyError, OpenedWallet, Seed, Signer};
|
||||
use crate::{KeyError, OpenedAccount, OpenedWallet, Seed};
|
||||
|
||||
/// What is open right now: the wallet's public summary, its signers by
|
||||
/// account id, and the seed when the file carried a phrase (for deriving
|
||||
/// further accounts later). Dropped whole on lock.
|
||||
/// One open wallet: its public summary, its accounts, and the seed when
|
||||
/// the file carried a phrase (for deriving the wormhole keys and further
|
||||
/// accounts). Dropped whole when that wallet or the session locks.
|
||||
struct Unlocked {
|
||||
wallet: WalletSummary,
|
||||
signers: Vec<Box<dyn Signer>>,
|
||||
accounts: Vec<OpenedAccount>,
|
||||
seed: Option<Seed>,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
/// In the order they were opened.
|
||||
wallets: Vec<Unlocked>,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionAccount {
|
||||
/// The wallet this account belongs to, by name.
|
||||
pub wallet: String,
|
||||
pub account_id: [u8; 32],
|
||||
pub scheme: SignatureScheme,
|
||||
pub public_key: Vec<u8>,
|
||||
pub derivation_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionWallet {
|
||||
pub summary: WalletSummary,
|
||||
pub accounts: Vec<SessionAccount>,
|
||||
/// Whether the wallet carries a phrase, and so has a wormhole address.
|
||||
pub has_seed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionStatus {
|
||||
pub wallet: Option<WalletSummary>,
|
||||
/// Every open wallet, in the order they were opened; empty when locked.
|
||||
pub wallets: Vec<SessionWallet>,
|
||||
/// Every account of every open wallet, in the same order.
|
||||
pub accounts: Vec<SessionAccount>,
|
||||
/// Seconds until the idle rule locks the session; `None` when locked.
|
||||
pub locks_in: Option<u64>,
|
||||
}
|
||||
|
||||
impl SessionStatus {
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.wallets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("the wallet is locked")]
|
||||
#[error("no wallet is open")]
|
||||
Locked,
|
||||
#[error("no such account in the open wallet")]
|
||||
#[error("no open wallet holds that account")]
|
||||
NoSuchAccount,
|
||||
#[error("the wallet {0} is not open")]
|
||||
NoSuchWallet(String),
|
||||
#[error(transparent)]
|
||||
Key(#[from] KeyError),
|
||||
#[error("session state is poisoned")]
|
||||
@@ -52,14 +80,17 @@ pub enum SessionError {
|
||||
|
||||
pub struct Session {
|
||||
idle: Mutex<Duration>,
|
||||
inner: Mutex<Option<Unlocked>>,
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(idle: Duration) -> Self {
|
||||
Self {
|
||||
idle: Mutex::new(idle),
|
||||
inner: Mutex::new(None),
|
||||
inner: Mutex::new(Inner {
|
||||
wallets: Vec::new(),
|
||||
last_used: Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,92 +103,145 @@ impl Session {
|
||||
*self.idle.lock().unwrap_or_else(|p| p.into_inner()) = idle;
|
||||
}
|
||||
|
||||
/// Open a wallet. Anything previously open is dropped first. The
|
||||
/// mnemonic, if present, becomes a seed and the string is dropped.
|
||||
fn inner(&self) -> Result<std::sync::MutexGuard<'_, Inner>, SessionError> {
|
||||
self.inner.lock().map_err(|_| SessionError::Poisoned)
|
||||
}
|
||||
|
||||
/// Open a wallet alongside any already open. Opening a wallet that is
|
||||
/// already open replaces it, so a second unlock with the right password
|
||||
/// is harmless. Counts as activity for the idle rule.
|
||||
pub fn unlock(
|
||||
&self,
|
||||
opened: OpenedWallet,
|
||||
seed: Option<Seed>,
|
||||
now: Instant,
|
||||
) -> Result<(), SessionError> {
|
||||
let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
*guard = None;
|
||||
*guard = Some(Unlocked {
|
||||
let mut inner = self.inner()?;
|
||||
// An idle session is locked before anything new is added to it, so a
|
||||
// stale wallet is not kept alive by opening a fresh one.
|
||||
if !inner.wallets.is_empty()
|
||||
&& now.saturating_duration_since(inner.last_used) >= self.idle_interval()
|
||||
{
|
||||
inner.wallets.clear();
|
||||
}
|
||||
let name = opened.summary.name.clone();
|
||||
inner.wallets.retain(|w| w.wallet.name != name);
|
||||
inner.wallets.push(Unlocked {
|
||||
wallet: opened.summary,
|
||||
signers: vec![opened.signer],
|
||||
accounts: opened.accounts,
|
||||
seed,
|
||||
last_used: now,
|
||||
});
|
||||
inner.last_used = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add accounts to a wallet that is already open; ones it already holds
|
||||
/// (same account id) are skipped. Returns how many were added.
|
||||
pub fn add_accounts(
|
||||
&self,
|
||||
wallet: &str,
|
||||
accounts: Vec<OpenedAccount>,
|
||||
) -> Result<usize, SessionError> {
|
||||
let mut inner = self.inner()?;
|
||||
let w = inner
|
||||
.wallets
|
||||
.iter_mut()
|
||||
.find(|w| w.wallet.name == wallet)
|
||||
.ok_or_else(|| SessionError::NoSuchWallet(wallet.to_owned()))?;
|
||||
let mut added = 0;
|
||||
for a in accounts {
|
||||
let id = a.signer.account_id();
|
||||
if w.accounts.iter().all(|b| b.signer.account_id() != id) {
|
||||
w.accounts.push(a);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
/// Lock every open wallet.
|
||||
pub fn lock(&self) -> Result<(), SessionError> {
|
||||
let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
*guard = None;
|
||||
self.inner()?.wallets.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock one wallet; the others stay open.
|
||||
pub fn lock_wallet(&self, name: &str) -> Result<(), SessionError> {
|
||||
let mut inner = self.inner()?;
|
||||
let before = inner.wallets.len();
|
||||
inner.wallets.retain(|w| w.wallet.name != name);
|
||||
if inner.wallets.len() == before {
|
||||
return Err(SessionError::NoSuchWallet(name.to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply the idle rule. Returns `true` if this call locked the session.
|
||||
pub fn expire_if_idle(&self, now: Instant) -> Result<bool, SessionError> {
|
||||
let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
let idle_for = guard
|
||||
.as_ref()
|
||||
.map(|u| now.saturating_duration_since(u.last_used));
|
||||
match idle_for {
|
||||
Some(d) if d >= self.idle_interval() => {
|
||||
*guard = None;
|
||||
Ok(true)
|
||||
}
|
||||
_ => Ok(false),
|
||||
let mut inner = self.inner()?;
|
||||
if !inner.wallets.is_empty()
|
||||
&& now.saturating_duration_since(inner.last_used) >= self.idle_interval()
|
||||
{
|
||||
inner.wallets.clear();
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn is_locked(&self) -> bool {
|
||||
self.inner.lock().map(|g| g.is_none()).unwrap_or(true)
|
||||
self.inner
|
||||
.lock()
|
||||
.map(|g| g.wallets.is_empty())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn status(&self, now: Instant) -> Result<SessionStatus, SessionError> {
|
||||
let guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
Ok(match guard.as_ref() {
|
||||
None => SessionStatus {
|
||||
wallet: None,
|
||||
accounts: vec![],
|
||||
locks_in: None,
|
||||
},
|
||||
Some(u) => SessionStatus {
|
||||
wallet: Some(u.wallet.clone()),
|
||||
accounts: u
|
||||
.signers
|
||||
let inner = self.inner()?;
|
||||
let wallets: Vec<SessionWallet> = inner
|
||||
.wallets
|
||||
.iter()
|
||||
.map(|w| SessionWallet {
|
||||
summary: w.wallet.clone(),
|
||||
accounts: w
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|s| SessionAccount {
|
||||
account_id: s.account_id(),
|
||||
scheme: s.scheme(),
|
||||
public_key: s.public_key().to_vec(),
|
||||
.map(|a| SessionAccount {
|
||||
wallet: w.wallet.name.clone(),
|
||||
account_id: a.signer.account_id(),
|
||||
scheme: a.signer.scheme(),
|
||||
public_key: a.signer.public_key().to_vec(),
|
||||
derivation_path: a.derivation_path.clone(),
|
||||
})
|
||||
.collect(),
|
||||
locks_in: Some(
|
||||
self.idle_interval()
|
||||
.saturating_sub(now.saturating_duration_since(u.last_used))
|
||||
.as_secs(),
|
||||
),
|
||||
},
|
||||
has_seed: w.seed.is_some(),
|
||||
})
|
||||
.collect();
|
||||
let accounts = wallets.iter().flat_map(|w| w.accounts.clone()).collect();
|
||||
let locks_in = (!wallets.is_empty()).then(|| {
|
||||
self.idle_interval()
|
||||
.saturating_sub(now.saturating_duration_since(inner.last_used))
|
||||
.as_secs()
|
||||
});
|
||||
Ok(SessionStatus {
|
||||
wallets,
|
||||
accounts,
|
||||
locks_in,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bump the idle clock without doing anything else.
|
||||
pub fn touch(&self, now: Instant) -> Result<(), SessionError> {
|
||||
let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
match guard.as_mut() {
|
||||
Some(u) => {
|
||||
u.last_used = now;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(SessionError::Locked),
|
||||
let mut inner = self.inner()?;
|
||||
if inner.wallets.is_empty() {
|
||||
return Err(SessionError::Locked);
|
||||
}
|
||||
inner.last_used = now;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sign with the open wallet's account. The idle rule is applied first,
|
||||
/// so a stale session refuses rather than signing one last time.
|
||||
/// Sign with whichever open wallet holds the account. The idle rule is
|
||||
/// applied first, so a stale session refuses rather than signing one
|
||||
/// last time.
|
||||
pub fn sign(
|
||||
&self,
|
||||
account_id: &[u8; 32],
|
||||
@@ -168,35 +252,54 @@ impl Session {
|
||||
if self.expire_if_idle(now)? {
|
||||
return Err(SessionError::Locked);
|
||||
}
|
||||
let mut guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
let u = guard.as_mut().ok_or(SessionError::Locked)?;
|
||||
let signer = u
|
||||
.signers
|
||||
let mut inner = self.inner()?;
|
||||
if inner.wallets.is_empty() {
|
||||
return Err(SessionError::Locked);
|
||||
}
|
||||
let account = inner
|
||||
.wallets
|
||||
.iter()
|
||||
.find(|s| &s.account_id() == account_id)
|
||||
.flat_map(|w| w.accounts.iter())
|
||||
.find(|a| &a.signer.account_id() == account_id)
|
||||
.ok_or(SessionError::NoSuchAccount)?;
|
||||
let sig = signer.sign(payload, context)?;
|
||||
u.last_used = now;
|
||||
let sig = account.signer.sign(payload, context)?;
|
||||
inner.last_used = now;
|
||||
Ok(sig)
|
||||
}
|
||||
|
||||
/// Run `f` with the seed, if the open wallet has one. For deriving
|
||||
/// further accounts; the seed never leaves the closure.
|
||||
pub fn with_seed<T>(&self, f: impl FnOnce(&Seed) -> T) -> Result<Option<T>, SessionError> {
|
||||
let guard = self.inner.lock().map_err(|_| SessionError::Poisoned)?;
|
||||
let u = guard.as_ref().ok_or(SessionError::Locked)?;
|
||||
Ok(u.seed.as_ref().map(f))
|
||||
/// Run `f` with the named wallet's seed, if it has one. For deriving
|
||||
/// further keys; the seed never leaves the closure.
|
||||
pub fn with_seed<T>(
|
||||
&self,
|
||||
wallet: &str,
|
||||
f: impl FnOnce(&Seed) -> T,
|
||||
) -> Result<Option<T>, SessionError> {
|
||||
let inner = self.inner()?;
|
||||
if inner.wallets.is_empty() {
|
||||
return Err(SessionError::Locked);
|
||||
}
|
||||
let w = inner
|
||||
.wallets
|
||||
.iter()
|
||||
.find(|w| w.wallet.name == wallet)
|
||||
.ok_or_else(|| SessionError::NoSuchWallet(wallet.to_owned()))?;
|
||||
Ok(w.seed.as_ref().map(f))
|
||||
}
|
||||
}
|
||||
|
||||
/// Locked or unlocked and for whom; never the seed or a signer.
|
||||
/// Which wallets are open; never a seed or a signer.
|
||||
impl std::fmt::Debug for Session {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = match self.inner.lock() {
|
||||
Ok(g) => match g.as_ref() {
|
||||
Some(u) => format!("unlocked: {}", u.wallet.name),
|
||||
None => "locked".to_owned(),
|
||||
},
|
||||
Ok(g) if g.wallets.is_empty() => "locked".to_owned(),
|
||||
Ok(g) => format!(
|
||||
"open: {}",
|
||||
g.wallets
|
||||
.iter()
|
||||
.map(|w| w.wallet.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
Err(_) => "poisoned".to_owned(),
|
||||
};
|
||||
f.debug_struct("Session")
|
||||
@@ -209,6 +312,7 @@ impl std::fmt::Debug for Session {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Signer;
|
||||
use wallet_entities::WalletKind;
|
||||
|
||||
/// A signer that records what it signed; the "secret" is a marker
|
||||
@@ -238,18 +342,28 @@ mod tests {
|
||||
}
|
||||
|
||||
fn opened(public: u8) -> OpenedWallet {
|
||||
named(&format!("w{public}"), &[public])
|
||||
}
|
||||
|
||||
/// A wallet with one fake account per byte in `publics`.
|
||||
fn named(name: &str, publics: &[u8]) -> OpenedWallet {
|
||||
OpenedWallet {
|
||||
summary: WalletSummary {
|
||||
name: "w".into(),
|
||||
name: name.into(),
|
||||
address: "qz".into(),
|
||||
kind: WalletKind::Hot,
|
||||
created_at: 0,
|
||||
},
|
||||
signer: Box::new(FakeSigner {
|
||||
secret: zeroize::Zeroizing::new(vec![public; 64]),
|
||||
public: vec![public; 4],
|
||||
}),
|
||||
derivation_path: "m/".into(),
|
||||
accounts: publics
|
||||
.iter()
|
||||
.map(|&p| OpenedAccount {
|
||||
signer: Box::new(FakeSigner {
|
||||
secret: zeroize::Zeroizing::new(vec![p; 64]),
|
||||
public: vec![p; 4],
|
||||
}),
|
||||
derivation_path: "m/".into(),
|
||||
})
|
||||
.collect(),
|
||||
mnemonic: None,
|
||||
}
|
||||
}
|
||||
@@ -353,15 +467,17 @@ mod tests {
|
||||
kind: WalletKind::Hot,
|
||||
created_at: 0,
|
||||
},
|
||||
signer: Box::new(signer),
|
||||
derivation_path: "m/".into(),
|
||||
accounts: vec![OpenedAccount {
|
||||
signer: Box::new(signer),
|
||||
derivation_path: "m/".into(),
|
||||
}],
|
||||
mnemonic: None,
|
||||
};
|
||||
s.unlock(opened, Some(Seed::copy_from(&seed_bytes)), Instant::now())
|
||||
.unwrap();
|
||||
// Wipe the test's own copy of the seed bytes before scanning.
|
||||
zeroize::Zeroize::zeroize(&mut seed_bytes);
|
||||
assert!(s.with_seed(|_| ()).unwrap().is_some());
|
||||
assert!(s.with_seed("w", |_| ()).unwrap().is_some());
|
||||
s.lock().unwrap();
|
||||
|
||||
let maps = std::fs::read_to_string("/proc/self/maps").unwrap();
|
||||
@@ -405,4 +521,77 @@ mod tests {
|
||||
);
|
||||
assert_eq!(hits, 0, "a secret survived lock() in process memory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_wallets_stay_open_side_by_side_and_lock_one_at_a_time() {
|
||||
let s = Session::new(Duration::from_secs(60));
|
||||
let t0 = Instant::now();
|
||||
s.unlock(named("spending", &[1, 2]), None, t0).unwrap();
|
||||
s.unlock(named("savings", &[3]), Some(Seed::copy_from(&[9; 64])), t0)
|
||||
.unwrap();
|
||||
let st = s.status(t0).unwrap();
|
||||
assert_eq!(
|
||||
st.wallets
|
||||
.iter()
|
||||
.map(|w| w.summary.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["spending", "savings"]
|
||||
);
|
||||
assert_eq!(st.accounts.len(), 3);
|
||||
assert_eq!(st.accounts[2].wallet, "savings");
|
||||
assert!(!st.wallets[0].has_seed && st.wallets[1].has_seed);
|
||||
let (a1, a3) = (st.accounts[0].account_id, st.accounts[2].account_id);
|
||||
|
||||
// Each wallet signs with its own accounts.
|
||||
assert_eq!(s.sign(&a1, b"m", b"", t0).unwrap()[..4], [1; 4]);
|
||||
assert_eq!(s.sign(&a3, b"m", b"", t0).unwrap()[..4], [3; 4]);
|
||||
|
||||
// Re-opening a wallet replaces it rather than doubling it.
|
||||
s.unlock(named("spending", &[1, 2]), None, t0).unwrap();
|
||||
assert_eq!(s.status(t0).unwrap().wallets.len(), 2);
|
||||
|
||||
// Locking one leaves the other signing.
|
||||
s.lock_wallet("spending").unwrap();
|
||||
assert!(matches!(
|
||||
s.sign(&a1, b"m", b"", t0),
|
||||
Err(SessionError::NoSuchAccount)
|
||||
));
|
||||
assert!(s.sign(&a3, b"m", b"", t0).is_ok());
|
||||
assert!(matches!(
|
||||
s.lock_wallet("spending"),
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
assert!(s.with_seed("savings", |_| ()).unwrap().is_some());
|
||||
assert!(matches!(
|
||||
s.with_seed("spending", |_| ()),
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
|
||||
// Accounts added to an open wallet sign; duplicates are skipped.
|
||||
assert_eq!(
|
||||
s.add_accounts("savings", named("x", &[4, 3]).accounts)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let st = s.status(t0).unwrap();
|
||||
assert_eq!(
|
||||
st.wallets[0].accounts.len(),
|
||||
2,
|
||||
"savings is the only one open here"
|
||||
);
|
||||
assert!(
|
||||
s.sign(&st.wallets[0].accounts[1].account_id, b"m", b"", t0)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(matches!(
|
||||
s.add_accounts("nobody", vec![]),
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
|
||||
// The idle rule locks everything that is open.
|
||||
s.unlock(named("spending", &[1, 2]), None, t0).unwrap();
|
||||
assert!(s.expire_if_idle(t0 + Duration::from_secs(60)).unwrap());
|
||||
assert!(s.is_locked());
|
||||
assert!(s.status(t0).unwrap().is_locked());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,5 +349,12 @@
|
||||
"symbol": "SOL",
|
||||
"contractAddress": "0x0d829a23c3a3760e2fed60ca24d83cee8943f7c61859608ae0726fdb2e4f2784",
|
||||
"coingeckoId": "solana"
|
||||
},
|
||||
{
|
||||
"assetId": "nep141:qtc.omft.near",
|
||||
"decimals": 12,
|
||||
"blockchain": "qtc",
|
||||
"symbol": "QTC",
|
||||
"coingeckoId": "custom:qtc"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,9 @@ use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
use argon2::{Algorithm, Argon2, Params, PasswordHash, Version};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wallet_core::ss58;
|
||||
use wallet_core::{KeyError, Keystore, KeystoreError, NewWallet, OpenedWallet, Signer};
|
||||
use wallet_core::{
|
||||
KeyError, Keystore, KeystoreError, NewWallet, OpenedAccount, OpenedWallet, Signer,
|
||||
};
|
||||
use wallet_entities::{SignatureScheme, WalletKind, WalletSummary};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
@@ -412,18 +414,63 @@ impl FileKeystore {
|
||||
}
|
||||
other => KeystoreError::Key(other),
|
||||
})?;
|
||||
Ok(OpenedWallet {
|
||||
summary: encrypted.summary(),
|
||||
let mnemonic = data
|
||||
.mnemonic
|
||||
.as_deref()
|
||||
.map(|m| Zeroizing::new(m.to_owned()));
|
||||
let stored = OpenedAccount {
|
||||
signer: Box::new(signer),
|
||||
derivation_path: data.derivation_path.clone(),
|
||||
mnemonic: data
|
||||
.mnemonic
|
||||
.as_deref()
|
||||
.map(|m| Zeroizing::new(m.to_owned())),
|
||||
};
|
||||
let accounts = match mnemonic.as_deref() {
|
||||
Some(phrase) => accounts_for_phrase(phrase, stored)?,
|
||||
None => vec![stored],
|
||||
};
|
||||
Ok(OpenedWallet {
|
||||
summary: encrypted.summary(),
|
||||
accounts,
|
||||
mnemonic,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Account `account` under every scheme, current scheme first.
|
||||
pub fn accounts_at(seed: &wallet_core::Seed, account: u32) -> Result<Vec<OpenedAccount>, KeyError> {
|
||||
wallet_core::paths::SCHEMES
|
||||
.iter()
|
||||
.map(|&scheme| {
|
||||
let path = wallet_core::paths::derivation_path(scheme, account);
|
||||
let signer = crate::keys::MlDsa::new(scheme).derive_signer(seed, &path)?;
|
||||
Ok(OpenedAccount {
|
||||
signer: Box::new(signer) as Box<dyn Signer>,
|
||||
derivation_path: path,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every account a phrase-bearing wallet holds when it opens: account 0
|
||||
/// under every scheme, current scheme first, then the file's own keypair if
|
||||
/// it sits anywhere else (a CLI wallet imported at a custom path). The same
|
||||
/// phrase gives a different account under each scheme, so deriving all of
|
||||
/// them is what keeps a balance from hiding behind a scheme the person did
|
||||
/// not pick. Further account numbers are added after unlock
|
||||
/// ([`accounts_at`]). De-duplicated by account id, the file's own keypair
|
||||
/// standing in for the derived one it equals.
|
||||
fn accounts_for_phrase(
|
||||
phrase: &str,
|
||||
stored: OpenedAccount,
|
||||
) -> Result<Vec<OpenedAccount>, KeystoreError> {
|
||||
let seed = crate::keys::seed_from_mnemonic(phrase, None)?;
|
||||
let mut out = accounts_at(&seed, 0)?;
|
||||
let stored_id = stored.signer.account_id();
|
||||
match out.iter().position(|a| a.signer.account_id() == stored_id) {
|
||||
Some(i) => out[i] = stored,
|
||||
None => out.push(stored),
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Keystore for FileKeystore {
|
||||
fn list(&self) -> Result<Vec<WalletSummary>, KeystoreError> {
|
||||
if !self.dir.exists() {
|
||||
@@ -550,24 +597,58 @@ mod tests {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
for name in fixture_names() {
|
||||
let opened = ks.open(&name, "").unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let derived = ss58::encode(189, &opened.signer.account_id());
|
||||
assert_eq!(derived, opened.summary.address, "{name}");
|
||||
let expected_scheme = if name.contains("65") {
|
||||
SignatureScheme::MlDsa65
|
||||
} else {
|
||||
SignatureScheme::MlDsa87
|
||||
};
|
||||
assert_eq!(opened.signer.scheme(), expected_scheme, "{name}");
|
||||
// The file's own keypair is among the accounts and reproduces
|
||||
// the stored address.
|
||||
let own = opened
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|a| ss58::encode(189, &a.signer.account_id()) == opened.summary.address)
|
||||
.unwrap_or_else(|| panic!("{name}: the stored keypair is not among the accounts"));
|
||||
assert_eq!(own.signer.scheme(), expected_scheme, "{name}");
|
||||
if name.starts_with("crystal_") {
|
||||
// Dev accounts: raw-seed keys, no phrase, path "m/".
|
||||
assert_eq!(opened.derivation_path, "m/", "{name}");
|
||||
// Dev accounts: raw-seed keys, no phrase, path "m/", and so
|
||||
// exactly one account.
|
||||
assert_eq!(own.derivation_path, "m/", "{name}");
|
||||
assert!(opened.mnemonic.is_none(), "{name}");
|
||||
assert_eq!(opened.accounts.len(), 1, "{name}");
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
opened.derivation_path.starts_with("m/44'/189189'/"),
|
||||
own.derivation_path.starts_with("m/44'/189189'/"),
|
||||
"{name}: {}",
|
||||
opened.derivation_path
|
||||
own.derivation_path
|
||||
);
|
||||
// Both schemes at their conventional paths, current first.
|
||||
assert_eq!(
|
||||
opened.accounts[0].signer.scheme(),
|
||||
SignatureScheme::MlDsa65,
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
opened.accounts[0].derivation_path, "m/44'/189189'/0'/0'/1'",
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
opened.accounts[1].signer.scheme(),
|
||||
SignatureScheme::MlDsa87,
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
opened.accounts[1].derivation_path, "m/44'/189189'/0'/0'/0'",
|
||||
"{name}"
|
||||
);
|
||||
// Two when the file's keypair is one of those, three otherwise.
|
||||
let conventional =
|
||||
own.derivation_path == wallet_core::paths::default_derivation_path(expected_scheme);
|
||||
assert_eq!(
|
||||
opened.accounts.len(),
|
||||
if conventional { 2 } else { 3 },
|
||||
"{name}"
|
||||
);
|
||||
let phrase = opened
|
||||
.mnemonic
|
||||
@@ -581,13 +662,14 @@ mod tests {
|
||||
fn a_fixture_signer_signs_like_a_derived_one_verifies() {
|
||||
let ks = FileKeystore::in_dir(fixtures());
|
||||
let opened = ks.open("v-ml-dsa-65-89001", "").unwrap();
|
||||
let sig = opened
|
||||
let own = &opened.accounts[0];
|
||||
let sig = own
|
||||
.signer
|
||||
.sign(b"payload", QUANTUS_EXTRINSIC_CONTEXT)
|
||||
.unwrap();
|
||||
assert!(crate::keys::verify(
|
||||
SignatureScheme::MlDsa65,
|
||||
opened.signer.public_key(),
|
||||
own.signer.public_key(),
|
||||
b"payload",
|
||||
&sig,
|
||||
QUANTUS_EXTRINSIC_CONTEXT
|
||||
@@ -595,9 +677,34 @@ mod tests {
|
||||
// And it is the same key the seed derives at that path.
|
||||
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
|
||||
let derived = MlDsa::ml_dsa_65()
|
||||
.derive(&seed, &opened.derivation_path)
|
||||
.derive(&seed, &own.derivation_path)
|
||||
.unwrap();
|
||||
assert_eq!(derived.public_key(), opened.signer.public_key());
|
||||
assert_eq!(derived.public_key(), own.signer.public_key());
|
||||
}
|
||||
|
||||
/// Account numbers beyond 0, against `quantus wallet import
|
||||
/// --derivation-path` (CLI 2.2.2) with the dev phrase.
|
||||
#[test]
|
||||
fn numbered_accounts_match_quantus_cli() {
|
||||
let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap();
|
||||
let addr = |a: &OpenedAccount| ss58::encode(189, &a.signer.account_id());
|
||||
let one = accounts_at(&seed, 1).unwrap();
|
||||
assert_eq!(one[0].signer.scheme(), SignatureScheme::MlDsa65);
|
||||
assert_eq!(one[0].derivation_path, "m/44'/189189'/1'/0'/1'");
|
||||
assert_eq!(
|
||||
addr(&one[0]),
|
||||
"qzm41eg2p6HpsWJSyRiMteyt9ah6KwP4UZtGRE4oZKjmLZXCx"
|
||||
);
|
||||
assert_eq!(one[1].derivation_path, "m/44'/189189'/1'/0'/0'");
|
||||
assert_eq!(
|
||||
addr(&one[1]),
|
||||
"qzn2nAa4jVFNYaTTsWnbyhY3Q6z9fxTFWNXWCvkGmYbD4eVwn"
|
||||
);
|
||||
let two = accounts_at(&seed, 2).unwrap();
|
||||
assert_eq!(
|
||||
addr(&two[0]),
|
||||
"qzohziq6S3uHdmh4nXfzKtx8en4tQ3NTstGr4jKE8JLkGkQvz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -693,7 +800,12 @@ mod tests {
|
||||
}
|
||||
|
||||
let opened = ks.open("roundtrip", "hunter2").unwrap();
|
||||
assert_eq!(opened.signer.public_key(), signer.public_key());
|
||||
assert_eq!(opened.accounts[0].signer.public_key(), signer.public_key());
|
||||
assert_eq!(
|
||||
opened.accounts.len(),
|
||||
2,
|
||||
"the ML-DSA-87 account is derived beside it"
|
||||
);
|
||||
assert_eq!(
|
||||
opened.mnemonic.as_deref().map(|m| m.as_str()),
|
||||
Some(DEV_PHRASE)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@ mod tests {
|
||||
show_testnets: true,
|
||||
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],
|
||||
|
||||
@@ -888,7 +888,7 @@ mod tests {
|
||||
let alice_ref = AccountRef {
|
||||
chain: profile.id.clone(),
|
||||
address: alice.summary.address.clone(),
|
||||
scheme: alice.signer.scheme(),
|
||||
scheme: alice.accounts[0].signer.scheme(),
|
||||
};
|
||||
let native = AssetId {
|
||||
chain: profile.id.clone(),
|
||||
@@ -897,9 +897,9 @@ mod tests {
|
||||
let bob_ref = AccountRef {
|
||||
chain: profile.id.clone(),
|
||||
address: bob.summary.address.clone(),
|
||||
scheme: bob.signer.scheme(),
|
||||
scheme: bob.accounts[0].signer.scheme(),
|
||||
};
|
||||
let alice_id = alice.signer.account_id();
|
||||
let alice_id = alice.accounts[0].signer.account_id();
|
||||
|
||||
let sign = |p: &PreparedTransaction, signer: &dyn wallet_core::Signer| SignedBy {
|
||||
scheme: signer.scheme(),
|
||||
@@ -948,7 +948,10 @@ mod tests {
|
||||
prepared.fee.to_decimal_string()
|
||||
);
|
||||
let (hash, rx) = adapter
|
||||
.submit(&prepared, sign(&prepared, alice.signer.as_ref()))
|
||||
.submit(
|
||||
&prepared,
|
||||
sign(&prepared, alice.accounts[0].signer.as_ref()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let seen = until_included(rx).await;
|
||||
@@ -992,7 +995,10 @@ mod tests {
|
||||
("ReversibleTransfers", "schedule_transfer_with_delay")
|
||||
);
|
||||
let (_, rx) = adapter
|
||||
.submit(&prepared, sign(&prepared, alice.signer.as_ref()))
|
||||
.submit(
|
||||
&prepared,
|
||||
sign(&prepared, alice.accounts[0].signer.as_ref()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let seen = until_included(rx).await;
|
||||
@@ -1024,7 +1030,10 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(prepared.summary.call, "cancel");
|
||||
let (_, rx) = adapter
|
||||
.submit(&prepared, sign(&prepared, alice.signer.as_ref()))
|
||||
.submit(
|
||||
&prepared,
|
||||
sign(&prepared, alice.accounts[0].signer.as_ref()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let seen = until_included(rx).await;
|
||||
@@ -1061,7 +1070,10 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let (_, rx) = adapter
|
||||
.submit(&prepared, sign(&prepared, alice.signer.as_ref()))
|
||||
.submit(
|
||||
&prepared,
|
||||
sign(&prepared, alice.accounts[0].signer.as_ref()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let seen = until_included(rx).await;
|
||||
@@ -1127,7 +1139,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let signers: Vec<(&str, &dyn wallet_core::Signer)> = vec![
|
||||
("alice-87", alice.signer.as_ref()),
|
||||
("alice-87", alice.accounts[0].signer.as_ref()),
|
||||
("dev-65", dev65.as_ref()),
|
||||
("dev-87", dev87.as_ref()),
|
||||
];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -171,11 +172,23 @@ fn the_flow_leaves_no_secret_in_logs_files_or_debug_output() {
|
||||
.unwrap();
|
||||
tracing::trace!(sig_len = sig.len(), "signed");
|
||||
let keys = session
|
||||
.with_seed(|s| WormholeKeys::derive(s, 0))
|
||||
.with_seed("audit", |s| WormholeKeys::derive(s, 0))
|
||||
.unwrap()
|
||||
.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}");
|
||||
|
||||
|
||||
@@ -408,21 +408,42 @@ pub struct CreationStart {
|
||||
pub challenge: CreationChallenge,
|
||||
}
|
||||
|
||||
/// An account the open wallet can sign with, as the UI sees it.
|
||||
/// An account an open wallet can sign with, as the UI sees it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SessionAccountInfo {
|
||||
/// The wallet it belongs to, by name.
|
||||
pub wallet: String,
|
||||
pub address: String,
|
||||
pub scheme: SignatureScheme,
|
||||
pub derivation_path: String,
|
||||
/// The account number in the wallet (0 is the first), when the path is
|
||||
/// one of the numbered Quantus paths; `None` for a raw key.
|
||||
pub account: Option<u32>,
|
||||
/// The scheme Quantus's clients create accounts with today (ML-DSA-65).
|
||||
/// The other is shown beside it and labelled legacy, never hidden.
|
||||
pub current_scheme: bool,
|
||||
}
|
||||
|
||||
/// Whether a wallet is open, which one, and how long until the idle rule
|
||||
/// locks it again.
|
||||
/// One open wallet and its accounts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct OpenWalletInfo {
|
||||
pub wallet: WalletSummary,
|
||||
pub accounts: Vec<SessionAccountInfo>,
|
||||
/// Whether the wallet carries a phrase, and so has a wormhole address.
|
||||
pub has_seed: bool,
|
||||
}
|
||||
|
||||
/// Which wallets are open, their accounts, and how long until the idle
|
||||
/// rule locks them all. `locked` means no wallet is open.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct SessionStatusInfo {
|
||||
pub locked: bool,
|
||||
pub wallet: Option<WalletSummary>,
|
||||
/// In the order they were opened.
|
||||
pub wallets: Vec<OpenWalletInfo>,
|
||||
/// Every account of every open wallet, in the same order.
|
||||
pub accounts: Vec<SessionAccountInfo>,
|
||||
#[ts(type = "number | null")]
|
||||
pub locks_in_seconds: Option<u64>,
|
||||
@@ -447,6 +468,27 @@ pub struct Settings {
|
||||
/// post-quantum asset is on NEAR Intents yet, so every swap is between
|
||||
/// transit assets paid from an external wallet.
|
||||
pub preview_swaps: bool,
|
||||
/// Account numbers added to each wallet beyond account 0, by wallet
|
||||
/// name. Not secret: the keys are re-derived from the wallet's phrase on
|
||||
/// 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 {
|
||||
@@ -457,6 +499,8 @@ impl Default for Settings {
|
||||
show_testnets: false,
|
||||
account_names: BTreeMap::new(),
|
||||
preview_swaps: false,
|
||||
wallet_accounts: BTreeMap::new(),
|
||||
intents_account: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -679,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)]
|
||||
@@ -770,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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[..]);
|
||||
}
|
||||
}
|
||||
@@ -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:"));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ if [ "${1:-}" != "" ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# The workspace's own crates pin each other exactly; keep the pins on the
|
||||
# workspace version, or a bump leaves Cargo.lock unresolvable.
|
||||
sed -i -E "s/^(wallet-[a-z]+ = \{ path = \"crates\/wallet-[a-z]+\", version = \")=[^\"]+(\" \})/\1=${version}\2/" "${here}/Cargo.toml"
|
||||
python3 - "${version}" "${here}/crates/wallet-app/tauri.conf.json" "${here}/ui/package.json" <<'PY'
|
||||
import json, sys
|
||||
version, *files = sys.argv[1:]
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { useSession, useSessionLockedListener } from './api/hooks'
|
||||
import { LockScreen } from './components/LockScreen'
|
||||
import { LockScreen, OpenAnotherWallet } from './components/LockScreen'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { UpdateOffer } from './components/UpdateOffer'
|
||||
@@ -53,6 +53,7 @@ function Gate() {
|
||||
<Route path="/send" element={<Send />} />
|
||||
<Route path="/receive" element={<Receive />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/wallets" element={<OpenAnotherWallet />} />
|
||||
<Route path="/swap" element={<Swap />} />
|
||||
<Route path="/settings" element={<SettingsRoute />} />
|
||||
<Route path="/onboarding/*" element={<OnboardingRoutes />} />
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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>, };
|
||||
@@ -0,0 +1,12 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { SessionAccountInfo } from "./SessionAccountInfo";
|
||||
import type { WalletSummary } from "./WalletSummary";
|
||||
|
||||
/**
|
||||
* One open wallet and its accounts.
|
||||
*/
|
||||
export type OpenWalletInfo = { wallet: WalletSummary, accounts: Array<SessionAccountInfo>,
|
||||
/**
|
||||
* Whether the wallet carries a phrase, and so has a wormhole address.
|
||||
*/
|
||||
has_seed: boolean, };
|
||||
@@ -2,6 +2,20 @@
|
||||
import type { SignatureScheme } from "./SignatureScheme";
|
||||
|
||||
/**
|
||||
* An account the open wallet can sign with, as the UI sees it.
|
||||
* An account an open wallet can sign with, as the UI sees it.
|
||||
*/
|
||||
export type SessionAccountInfo = { address: string, scheme: SignatureScheme, };
|
||||
export type SessionAccountInfo = {
|
||||
/**
|
||||
* The wallet it belongs to, by name.
|
||||
*/
|
||||
wallet: string, address: string, scheme: SignatureScheme, derivation_path: string,
|
||||
/**
|
||||
* The account number in the wallet (0 is the first), when the path is
|
||||
* one of the numbered Quantus paths; `None` for a raw key.
|
||||
*/
|
||||
account: number | null,
|
||||
/**
|
||||
* The scheme Quantus's clients create accounts with today (ML-DSA-65).
|
||||
* The other is shown beside it and labelled legacy, never hidden.
|
||||
*/
|
||||
current_scheme: boolean, };
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { OpenWalletInfo } from "./OpenWalletInfo";
|
||||
import type { SessionAccountInfo } from "./SessionAccountInfo";
|
||||
import type { WalletSummary } from "./WalletSummary";
|
||||
|
||||
/**
|
||||
* Whether a wallet is open, which one, and how long until the idle rule
|
||||
* locks it again.
|
||||
* Which wallets are open, their accounts, and how long until the idle
|
||||
* rule locks them all. `locked` means no wallet is open.
|
||||
*/
|
||||
export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array<SessionAccountInfo>, locks_in_seconds: number | null, };
|
||||
export type SessionStatusInfo = { locked: boolean,
|
||||
/**
|
||||
* In the order they were opened.
|
||||
*/
|
||||
wallets: Array<OpenWalletInfo>,
|
||||
/**
|
||||
* Every account of every open wallet, in the same order.
|
||||
*/
|
||||
accounts: Array<SessionAccountInfo>, locks_in_seconds: number | null, };
|
||||
|
||||
@@ -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.
|
||||
@@ -27,4 +28,17 @@ account_names: { [key in string]?: string },
|
||||
* post-quantum asset is on NEAR Intents yet, so every swap is between
|
||||
* transit assets paid from an external wallet.
|
||||
*/
|
||||
preview_swaps: boolean, };
|
||||
preview_swaps: boolean,
|
||||
/**
|
||||
* Account numbers added to each wallet beyond account 0, by wallet
|
||||
* name. Not secret: the keys are re-derived from the wallet's phrase on
|
||||
* every unlock. Kept here because the keystore file is quantus-cli's
|
||||
* and holds one keypair.
|
||||
*/
|
||||
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, };
|
||||
|
||||
+29
-1
@@ -19,6 +19,8 @@ import {
|
||||
settingsGet,
|
||||
settingsSet,
|
||||
unlock,
|
||||
walletAddAccount,
|
||||
walletDiscoverAccounts,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
@@ -69,10 +71,36 @@ export function useUnlock() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Add the next account to an open wallet; the session and settings both change. */
|
||||
export function useAddAccount() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (wallet: string) => walletAddAccount(wallet),
|
||||
onSuccess: (s) => {
|
||||
qc.setQueryData(queryKeys.session, s)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.settings })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 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()
|
||||
return useMutation({
|
||||
mutationFn: lock,
|
||||
mutationFn: (wallet?: string) => lock(wallet),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.session }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
+94
-9
@@ -14,14 +14,17 @@ 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'
|
||||
import type { SwapAsk } from './generated/SwapAsk'
|
||||
import type { SwapPreview } from './generated/SwapPreview'
|
||||
import type { HistoryPage } from './generated/HistoryPage'
|
||||
import type { SignatureScheme } from './generated/SignatureScheme'
|
||||
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'
|
||||
|
||||
@@ -70,8 +73,8 @@ export function walletsList(): Promise<WalletSummary[]> {
|
||||
// Creation is three steps so the phrase is shown once and then gone: begin
|
||||
// returns the words and the challenge, confirm writes the wallet if the
|
||||
// challenge passes, cancel forgets the phrase.
|
||||
export function walletCreateBegin(scheme: SignatureScheme): Promise<CreationStart> {
|
||||
return command<CreationStart>('wallet_create_begin', { scheme })
|
||||
export function walletCreateBegin(): Promise<CreationStart> {
|
||||
return command<CreationStart>('wallet_create_begin')
|
||||
}
|
||||
|
||||
export function walletCreateConfirm(
|
||||
@@ -86,13 +89,13 @@ export function walletCreateCancel(): Promise<void> {
|
||||
return command<void>('wallet_create_cancel')
|
||||
}
|
||||
|
||||
// No scheme: a phrase opens with its ML-DSA-65 and ML-DSA-87 accounts both.
|
||||
export function walletImportPhrase(
|
||||
name: string,
|
||||
password: string,
|
||||
phrase: string,
|
||||
scheme: SignatureScheme,
|
||||
): Promise<WalletSummary> {
|
||||
return command<WalletSummary>('wallet_import_phrase', { name, password, phrase, scheme })
|
||||
return command<WalletSummary>('wallet_import_phrase', { name, password, phrase })
|
||||
}
|
||||
|
||||
export function walletImportFile(path: string, password: string): Promise<WalletSummary> {
|
||||
@@ -109,8 +112,26 @@ export function unlock(name: string, password: string): Promise<SessionStatusInf
|
||||
return command<SessionStatusInfo>('unlock', { name, password })
|
||||
}
|
||||
|
||||
export function lock(): Promise<void> {
|
||||
return command<void>('lock')
|
||||
/**
|
||||
* Add the next account number to an open wallet that carries a phrase,
|
||||
* under both schemes. Remembered, so the next unlock brings it back.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
export function sessionStatus(): Promise<SessionStatusInfo> {
|
||||
@@ -199,8 +220,72 @@ export function historyPage(
|
||||
// The wormhole address behind the wallet's mining rewards and what the
|
||||
// zk-tree holds for it. Scans on the Rust side; can take a while the first
|
||||
// time on a long tree.
|
||||
export function wormholeSummary(chain: ChainId): Promise<WormholeSummary> {
|
||||
return command<WormholeSummary>('wormhole_summary', { chain })
|
||||
export function wormholeSummary(chain: ChainId, wallet: string): Promise<WormholeSummary> {
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { useSetSettings, useSettings } from '../api/hooks'
|
||||
|
||||
/** An account's label from settings, editable in place. Cosmetic only. */
|
||||
export function AccountName({ address }: { address: string }) {
|
||||
export function AccountName({ address, fallback }: { address: string; fallback?: string }) {
|
||||
const settings = useSettings()
|
||||
const set = useSetSettings()
|
||||
const [editing, setEditing] = useState(false)
|
||||
@@ -45,7 +45,7 @@ export function AccountName({ address }: { address: string }) {
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{current || <span className="muted">unnamed</span>}
|
||||
{current || <span className="muted">{fallback ?? 'unnamed'}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { SessionStatusInfo } from '../api/generated/SessionStatusInfo'
|
||||
import { useSettings } from '../api/hooks'
|
||||
import { accountLabel } from '../lib/accounts'
|
||||
|
||||
/**
|
||||
* Pick an account from any open wallet, grouped by wallet. Every account of
|
||||
* every open wallet is offered; nothing assumes there is one wallet or one
|
||||
* scheme.
|
||||
*/
|
||||
export function AccountSelect({
|
||||
id,
|
||||
label,
|
||||
session,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
session: SessionStatusInfo
|
||||
value: string
|
||||
onChange: (address: string) => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
const names = settings.data?.account_names
|
||||
return (
|
||||
<div className="field">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<select id={id} className="select" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{session.wallets.map((w) => (
|
||||
<optgroup key={w.wallet.name} label={w.wallet.name}>
|
||||
{w.accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{accountLabel(a, names)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +1,124 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useUnlock, useWallets } from '../api/hooks'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useSession, useUnlock, useWallets } from '../api/hooks'
|
||||
|
||||
/**
|
||||
* Fronts everything while the session is locked. Picks a wallet file and
|
||||
* takes a password; the password goes to Rust and nowhere else.
|
||||
* Picks a wallet file and takes its password; the password goes to Rust
|
||||
* and nowhere else. Wallets already open are not offered again. Used by
|
||||
* the lock screen, when nothing is open, and by the page that opens another
|
||||
* wallet beside the ones already open.
|
||||
*/
|
||||
export function LockScreen() {
|
||||
export function UnlockForm({ onUnlocked }: { onUnlocked?: () => void }) {
|
||||
const wallets = useWallets()
|
||||
const session = useSession()
|
||||
const unlock = useUnlock()
|
||||
const [name, setName] = useState<string>('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const list = wallets.data ?? []
|
||||
const selected = name || list[0]?.name || ''
|
||||
const open = new Set(session.data?.wallets.map((w) => w.wallet.name) ?? [])
|
||||
const list = (wallets.data ?? []).filter((w) => !open.has(w.name))
|
||||
const selected = list.some((w) => w.name === name) ? name : (list[0]?.name ?? '')
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!selected) return
|
||||
unlock.mutate({ name: selected, password }, { onSettled: () => setPassword('') })
|
||||
unlock.mutate(
|
||||
{ name: selected, password },
|
||||
{
|
||||
onSettled: () => setPassword(''),
|
||||
onSuccess: () => onUnlocked?.(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{wallets.isSuccess && (wallets.data?.length ?? 0) === 0 && (
|
||||
<p className="muted">No wallets yet.</p>
|
||||
)}
|
||||
{wallets.isSuccess && (wallets.data?.length ?? 0) > 0 && list.length === 0 && (
|
||||
<p className="muted">Every wallet is already open.</p>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="wallet">Wallet</label>
|
||||
<select
|
||||
id="wallet"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
{list.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name} · {w.address.slice(0, 8)}…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{wallets.isSuccess && (
|
||||
<p className="muted" style={{ marginTop: 16 }}>
|
||||
<Link to="/onboarding">Create, restore or import a wallet</Link>
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fronts everything while no wallet is open. */
|
||||
export function LockScreen() {
|
||||
return (
|
||||
<div className="lock">
|
||||
<form className="panel" onSubmit={submit}>
|
||||
<div className="panel">
|
||||
<div className="panel-head">
|
||||
<h1 className="panel-title">Unlock</h1>
|
||||
<span className="eyebrow">blackbeard wallet</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{wallets.isPending && <p className="muted">Reading wallets…</p>}
|
||||
{wallets.isError && <p className="error">{wallets.error.message}</p>}
|
||||
{wallets.isSuccess && list.length === 0 && <p className="muted">No wallets yet.</p>}
|
||||
{list.length > 0 && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="wallet">Wallet</label>
|
||||
<select
|
||||
id="wallet"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
>
|
||||
{list.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name} · {w.address.slice(0, 8)}…
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{unlock.isError && <p className="error">{unlock.error.message}</p>}
|
||||
<div className="row">
|
||||
<button className="button primary" type="submit" disabled={unlock.isPending}>
|
||||
{unlock.isPending ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{wallets.isSuccess && (
|
||||
<p className="muted" style={{ marginTop: 16 }}>
|
||||
<Link to="/onboarding">Create, restore or import a wallet</Link>
|
||||
</p>
|
||||
)}
|
||||
<UnlockForm />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Open another wallet beside the ones already open. */
|
||||
export function OpenAnotherWallet() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Open another wallet</h2>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Wallets open side by side: their accounts all appear on the accounts page, and send and
|
||||
receive can use any of them. The idle lock closes them all together.
|
||||
</p>
|
||||
<UnlockForm onUnlocked={() => navigate('/')} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ChainProfile } from '../api/generated/ChainProfile'
|
||||
import { useBalances, useChainStatus, useChains, useSession, useSettings } from '../api/hooks'
|
||||
import { displayName, schemeLabel, shortAddress } from '../lib/accounts'
|
||||
import { formatAmount } from '../lib/format'
|
||||
|
||||
/**
|
||||
@@ -24,14 +25,14 @@ export function NetworkOverview() {
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Other networks</h2>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
one seed, the same addresses on every Quantus-family chain
|
||||
the same addresses on every Quantus-family chain
|
||||
</span>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Network</th>
|
||||
<th>Address</th>
|
||||
<th>Account</th>
|
||||
<th className="num">Spendable</th>
|
||||
<th className="num">Total</th>
|
||||
<th>State</th>
|
||||
@@ -48,6 +49,7 @@ export function NetworkOverview() {
|
||||
}
|
||||
|
||||
function ChainRows({ profile }: { profile: ChainProfile }) {
|
||||
const settings = useSettings()
|
||||
const status = useChainStatus(profile.id)
|
||||
const balances = useBalances(profile.id, true)
|
||||
const session = useSession()
|
||||
@@ -74,7 +76,17 @@ function ChainRows({ profile }: { profile: ChainProfile }) {
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{a.address}</td>
|
||||
<td>
|
||||
<span className="mono">{shortAddress(a.address)}</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{' '}
|
||||
· {displayName(a, settings.data?.account_names)}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{' '}
|
||||
· {a.wallet} · {schemeLabel(a)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num numeral">{b ? `${formatAmount(b.spendable)} ${b.symbol}` : '…'}</td>
|
||||
<td className="num numeral">{b ? `${formatAmount(b.total)} ${b.symbol}` : '…'}</td>
|
||||
<td className="muted" style={{ fontSize: 12 }}>
|
||||
|
||||
@@ -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) => (
|
||||
@@ -37,17 +56,34 @@ export function Sidebar() {
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<NetworkSwitcher />
|
||||
{session.data?.wallet && (
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{session.data.wallet.name}
|
||||
{session.data.locks_in_seconds != null && (
|
||||
<> · locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
|
||||
)}
|
||||
</span>
|
||||
<button className="button" onClick={() => lock.mutate()}>
|
||||
Lock
|
||||
</button>
|
||||
{session.data && session.data.wallets.length > 0 && (
|
||||
<div className="wallets" data-testid="open-wallets">
|
||||
<span className="eyebrow">Open wallets</span>
|
||||
{session.data.wallets.map((w) => (
|
||||
<div className="wallet-row" key={w.wallet.name}>
|
||||
<span title={w.wallet.address}>{w.wallet.name}</span>
|
||||
<button
|
||||
className="linkish muted"
|
||||
type="button"
|
||||
onClick={() => lock.mutate(w.wallet.name)}
|
||||
>
|
||||
lock
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<NavLink to="/wallets" className="linkish">
|
||||
+ open another
|
||||
</NavLink>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{session.data.locks_in_seconds != null && (
|
||||
<>locks in {Math.ceil(session.data.locks_in_seconds / 60)} min</>
|
||||
)}
|
||||
</span>
|
||||
<button className="button" type="button" onClick={() => lock.mutate(undefined)}>
|
||||
Lock all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,11 +12,19 @@ import { formatAmount } from '../lib/format'
|
||||
* unspent leaves the zk-tree holds for it. Read from chain state through
|
||||
* a scan that resumes from where it last stopped.
|
||||
*/
|
||||
export function WormholePanel({ chain, profile }: { chain: ChainId; profile: ChainProfile }) {
|
||||
export function WormholePanel({
|
||||
chain,
|
||||
profile,
|
||||
wallet,
|
||||
}: {
|
||||
chain: ChainId
|
||||
profile: ChainProfile
|
||||
wallet: string
|
||||
}) {
|
||||
const status = useChainStatus(chain)
|
||||
const summary = useQuery({
|
||||
queryKey: ['wormhole', chain],
|
||||
queryFn: () => wormholeSummary(chain),
|
||||
queryKey: ['wormhole', chain, wallet],
|
||||
queryFn: () => wormholeSummary(chain, wallet),
|
||||
enabled: !!status.data?.connected,
|
||||
staleTime: 60_000,
|
||||
retry: 1,
|
||||
@@ -24,7 +32,7 @@ export function WormholePanel({ chain, profile }: { chain: ChainId; profile: Cha
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Mining rewards (wormhole)</h2>
|
||||
<h2 className="panel-title">Mining rewards (wormhole) · {wallet}</h2>
|
||||
{summary.data && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
zk-tree scanned to leaf <span className="numeral">{summary.data.scanned_to}</span> of{' '}
|
||||
|
||||
@@ -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;
|
||||
@@ -389,3 +398,33 @@ textarea.input {
|
||||
background: var(--surface-2, var(--surface));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ---- accounts grouped by wallet ------------------------------------------- */
|
||||
.group-row th {
|
||||
background: var(--surface-2);
|
||||
font-family: var(--font-display);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 13px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ---- open wallets in the sidebar ------------------------------------------ */
|
||||
.wallets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wallet-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.wallet-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Naming accounts for people. Pure: no IPC here.
|
||||
|
||||
import type { SessionAccountInfo } from '../api/generated/SessionAccountInfo'
|
||||
|
||||
/**
|
||||
* The scheme as a label, never a choice. ML-DSA-65 is what Quantus's own
|
||||
* clients create accounts with; ML-DSA-87 is their legacy, still spendable,
|
||||
* and shown beside it so no balance hides behind a scheme.
|
||||
*/
|
||||
export function schemeLabel(a: Pick<SessionAccountInfo, 'scheme' | 'current_scheme'>): string {
|
||||
const name = a.scheme === 'ml-dsa65' ? 'ML-DSA-65' : 'ML-DSA-87'
|
||||
return a.current_scheme ? name : `${name} (legacy)`
|
||||
}
|
||||
|
||||
export function shortAddress(address: string): string {
|
||||
return address.length > 16 ? `${address.slice(0, 8)}…${address.slice(-6)}` : address
|
||||
}
|
||||
|
||||
/** "savings · ML-DSA-65 · qzk1Nxai…aJS2vSn7", with the person's name for it when they gave one. */
|
||||
export function accountLabel(
|
||||
a: SessionAccountInfo,
|
||||
names: Record<string, string | undefined> | undefined,
|
||||
): string {
|
||||
return [displayName(a, names), schemeLabel(a)].join(' · ')
|
||||
}
|
||||
|
||||
/** The person's name for an account, else its number, else its short address. */
|
||||
export function displayName(
|
||||
a: Pick<SessionAccountInfo, 'address' | 'account'>,
|
||||
names: Record<string, string | undefined> | undefined,
|
||||
): string {
|
||||
return (
|
||||
names?.[a.address] ?? (a.account != null ? `Account ${a.account + 1}` : shortAddress(a.address))
|
||||
)
|
||||
}
|
||||
+126
-30
@@ -1,40 +1,61 @@
|
||||
import { useBalances, useChains, useSession, useSettings } from '../api/hooks'
|
||||
import {
|
||||
useAddAccount,
|
||||
useDiscoverAccounts,
|
||||
useBalances,
|
||||
useChains,
|
||||
useLock,
|
||||
useSession,
|
||||
useSettings,
|
||||
} from '../api/hooks'
|
||||
import type { AccountBalance } from '../api/generated/AccountBalance'
|
||||
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'
|
||||
|
||||
/**
|
||||
* The open wallet's accounts on the selected network, with balances read
|
||||
* at the best block and refreshed by Rust on every head. Confirmations are
|
||||
* the distance to the finalized height.
|
||||
* Every account of every open wallet on the selected network, grouped by
|
||||
* wallet, with balances read at the best block and refreshed by Rust on
|
||||
* every head. A wallet opened from a phrase shows its ML-DSA-65 and its
|
||||
* ML-DSA-87 account both: a phrase gives a different account under each
|
||||
* scheme, and neither is hidden. Confirmations are the distance to the
|
||||
* finalized height.
|
||||
*/
|
||||
export function Accounts() {
|
||||
const settings = useSettings()
|
||||
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()
|
||||
const chain = settings.data?.network
|
||||
const profile = chains.data?.find((c) => c.id === chain)
|
||||
const addresses = session.data?.accounts.map((a) => a.address) ?? []
|
||||
const byAddress = new Map<string, AccountBalance>(
|
||||
(balances.data ?? []).map((b) => [b.address, b]),
|
||||
)
|
||||
const head = balances.data?.[0]
|
||||
return (
|
||||
<>
|
||||
<ChainCaveat />
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Accounts</h2>
|
||||
{balances.data?.[0] && (
|
||||
{head && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
at block <span className="numeral">{balances.data[0].at_block}</span>
|
||||
{balances.data[0].finalized_block != null && (
|
||||
at block <span className="numeral">{head.at_block}</span>
|
||||
{head.finalized_block != null && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span className="numeral">
|
||||
{Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)}
|
||||
{Math.max(0, head.at_block - head.finalized_block)}
|
||||
</span>{' '}
|
||||
from final
|
||||
</>
|
||||
@@ -44,8 +65,17 @@ export function Accounts() {
|
||||
</div>
|
||||
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
|
||||
{balances.isError && <p className="error">{balances.error.message}</p>}
|
||||
{balances.isSuccess && (
|
||||
<table className="table">
|
||||
{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>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -56,33 +86,99 @@ export function Accounts() {
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.data.map((b, i) => (
|
||||
<tr key={b.address}>
|
||||
<td>
|
||||
<AccountName address={b.address} />
|
||||
</td>
|
||||
<td className="mono">{b.address}</td>
|
||||
<td>{session.data?.accounts[i]?.scheme ?? ''}</td>
|
||||
<td className="num numeral">
|
||||
{b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol}
|
||||
</td>
|
||||
<td className="num numeral">
|
||||
{formatAmount(b.total)} {b.symbol}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{b.high_security ? 'high-security account: transfers are delayed' : ''}
|
||||
</td>
|
||||
{session.data.wallets.map((w) => (
|
||||
<tbody key={w.wallet.name} data-testid={`wallet-${w.wallet.name}`}>
|
||||
<tr className="group-row">
|
||||
<th colSpan={5}>
|
||||
{w.wallet.name}
|
||||
<span className="muted" style={{ fontWeight: 400 }}>
|
||||
{' '}
|
||||
· {w.accounts.length} {w.accounts.length === 1 ? 'account' : 'accounts'}
|
||||
{!w.has_seed && ' · a single key, no recovery phrase'}
|
||||
</span>
|
||||
</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"
|
||||
type="button"
|
||||
disabled={addAccount.isPending}
|
||||
onClick={() => addAccount.mutate(w.wallet.name)}
|
||||
>
|
||||
{addAccount.isPending && addAccount.variables === w.wallet.name
|
||||
? 'Adding…'
|
||||
: 'Add account'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={() => lock.mutate(w.wallet.name)}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{w.accounts.map((a) => {
|
||||
const b = byAddress.get(a.address)
|
||||
return (
|
||||
<tr key={a.address}>
|
||||
<td>
|
||||
<AccountName
|
||||
address={a.address}
|
||||
fallback={a.account != null ? `Account ${a.account + 1}` : undefined}
|
||||
/>
|
||||
</td>
|
||||
<td className="mono">{a.address}</td>
|
||||
<td className={a.current_scheme ? undefined : 'muted'}>{schemeLabel(a)}</td>
|
||||
<td className="num numeral">
|
||||
{b ? `${formatAmount(b.spendable)} ${b.symbol}` : '…'}
|
||||
</td>
|
||||
<td className="num numeral">
|
||||
{b ? `${formatAmount(b.total)} ${b.symbol}` : '…'}
|
||||
</td>
|
||||
<td className="muted">
|
||||
{b?.high_security ? 'high-security account: transfers are delayed' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
{chain && profile && unlocked && (
|
||||
<PendingReversibleList chain={chain} profile={profile} addresses={addresses} />
|
||||
)}
|
||||
{chain && profile && unlocked && <WormholePanel chain={chain} profile={profile} />}
|
||||
{chain &&
|
||||
profile &&
|
||||
session.data?.wallets
|
||||
.filter((w) => w.has_seed)
|
||||
.map((w) => (
|
||||
<WormholePanel
|
||||
key={w.wallet.name}
|
||||
chain={chain}
|
||||
profile={profile}
|
||||
wallet={w.wallet.name}
|
||||
/>
|
||||
))}
|
||||
{session.data?.wallets.some((w) => w.has_seed) && <NearIntentsPanel />}
|
||||
<NetworkOverview />
|
||||
</>
|
||||
)
|
||||
|
||||
+10
-17
@@ -5,6 +5,7 @@ import type { HistoryPage } from '../api/generated/HistoryPage'
|
||||
import { useChains, useSession, useSettings } from '../api/hooks'
|
||||
import { openExternal } from '../api/opener'
|
||||
import { historyPage } from '../api/wallet'
|
||||
import { AccountSelect } from '../components/AccountSelect'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { formatAmount } from '../lib/format'
|
||||
|
||||
@@ -21,7 +22,7 @@ export function History() {
|
||||
const profile = chains.data?.find((c) => c.id === chain)
|
||||
const accounts = session.data?.accounts ?? []
|
||||
const [picked, setPicked] = useState('')
|
||||
const address = picked || accounts[0]?.address || ''
|
||||
const address = accounts.find((a) => a.address === picked)?.address ?? accounts[0]?.address ?? ''
|
||||
|
||||
const pages = useInfiniteQuery({
|
||||
queryKey: ['history', chain, address],
|
||||
@@ -66,22 +67,14 @@ export function History() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{accounts.length > 1 && (
|
||||
<div className="field">
|
||||
<label htmlFor="account">Account</label>
|
||||
<select
|
||||
id="account"
|
||||
className="select"
|
||||
value={address}
|
||||
onChange={(e) => setPicked(e.target.value)}
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{settings.data?.account_names[a.address] ?? a.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{accounts.length > 1 && session.data && (
|
||||
<AccountSelect
|
||||
id="account"
|
||||
label="Account"
|
||||
session={session.data}
|
||||
value={address}
|
||||
onChange={setPicked}
|
||||
/>
|
||||
)}
|
||||
{!profile.indexer && (
|
||||
<p className="muted">
|
||||
|
||||
@@ -2,16 +2,17 @@ import { useState, type FormEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { CreationStart } from '../api/generated/CreationStart'
|
||||
import type { SignatureScheme } from '../api/generated/SignatureScheme'
|
||||
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'
|
||||
|
||||
@@ -44,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 })
|
||||
@@ -106,9 +122,8 @@ function NamePassword({
|
||||
|
||||
export function Create() {
|
||||
const finish = useFinish()
|
||||
const [scheme, setScheme] = useState<SignatureScheme>('ml-dsa65')
|
||||
const [start, setStart] = useState<CreationStart | null>(null)
|
||||
const [step, setStep] = useState<'scheme' | 'words' | 'challenge' | 'done'>('scheme')
|
||||
const [step, setStep] = useState<'start' | 'words' | 'challenge' | 'done'>('start')
|
||||
const [answers, setAnswers] = useState<string[]>([])
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -121,7 +136,7 @@ export function Create() {
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const s = await walletCreateBegin(scheme)
|
||||
const s = await walletCreateBegin()
|
||||
setStart(s)
|
||||
setAnswers(s.challenge.positions.map(() => ''))
|
||||
setStep('words')
|
||||
@@ -135,7 +150,7 @@ export function Create() {
|
||||
async function cancel() {
|
||||
await walletCreateCancel().catch(() => undefined)
|
||||
setStart(null)
|
||||
setStep('scheme')
|
||||
setStep('start')
|
||||
}
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
@@ -156,7 +171,7 @@ export function Create() {
|
||||
setError((err as Error).message)
|
||||
// A failed challenge dropped the phrase on the Rust side; start over.
|
||||
setStart(null)
|
||||
setStep('scheme')
|
||||
setStep('start')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -172,20 +187,8 @@ export function Create() {
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{error && <p className="error">{error}</p>}
|
||||
{step === 'scheme' && (
|
||||
{step === 'start' && (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="scheme">Signature scheme</label>
|
||||
<select
|
||||
id="scheme"
|
||||
className="select"
|
||||
value={scheme}
|
||||
onChange={(e) => setScheme(e.target.value as SignatureScheme)}
|
||||
>
|
||||
<option value="ml-dsa65">ML-DSA-65 (recommended)</option>
|
||||
<option value="ml-dsa87">ML-DSA-87 (larger, for older tooling)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="muted">
|
||||
A 24-word recovery phrase will be shown once. Write it on paper; the wallet cannot
|
||||
show it again.
|
||||
@@ -268,9 +271,8 @@ export function Create() {
|
||||
}
|
||||
|
||||
export function Restore() {
|
||||
const finish = useFinish()
|
||||
const finish = useFinish(true)
|
||||
const [phrase, setPhrase] = useState('')
|
||||
const [scheme, setScheme] = useState<SignatureScheme>('ml-dsa65')
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
@@ -286,7 +288,7 @@ export function Restore() {
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
await walletImportPhrase(name, password, phrase, scheme)
|
||||
await walletImportPhrase(name, password, phrase)
|
||||
setPhrase('')
|
||||
await finish(name, password)
|
||||
} catch (err) {
|
||||
@@ -318,22 +320,11 @@ export function Restore() {
|
||||
onChange={(e) => setPhrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="scheme">Signature scheme</label>
|
||||
<select
|
||||
id="scheme"
|
||||
className="select"
|
||||
value={scheme}
|
||||
onChange={(e) => setScheme(e.target.value as SignatureScheme)}
|
||||
>
|
||||
<option value="ml-dsa65">ML-DSA-65 (accounts made recently)</option>
|
||||
<option value="ml-dsa87">ML-DSA-87 (older accounts)</option>
|
||||
</select>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
The same phrase gives a different account under each scheme. If the balance is not where
|
||||
you expect, try the other.
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 12, marginTop: 0 }}>
|
||||
The wallet opens with both of the phrase's accounts: ML-DSA-65, what Quantus wallets use
|
||||
today, and ML-DSA-87, what older wallets and miners used. Whichever holds your balance, it
|
||||
will be there.
|
||||
</p>
|
||||
<NamePassword
|
||||
name={name}
|
||||
password={password}
|
||||
|
||||
+18
-17
@@ -3,8 +3,10 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { copyText } from '../api/clipboard'
|
||||
import { useSession, useSettings } from '../api/hooks'
|
||||
import { checkphrase } from '../api/wallet'
|
||||
import { AccountSelect } from '../components/AccountSelect'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { RampButtons } from '../components/RampButtons'
|
||||
import { schemeLabel } from '../lib/accounts'
|
||||
import { qrSvg } from '../lib/qr'
|
||||
|
||||
/**
|
||||
@@ -18,7 +20,8 @@ export function Receive() {
|
||||
const chain = settings.data?.network
|
||||
const accounts = session.data?.accounts ?? []
|
||||
const [address, setAddress] = useState('')
|
||||
const selected = address || accounts[0]?.address || ''
|
||||
const selected =
|
||||
accounts.find((a) => a.address === address)?.address ?? accounts[0]?.address ?? ''
|
||||
const phrase = useQuery({
|
||||
queryKey: ['checkphrase', selected],
|
||||
queryFn: () => checkphrase(selected),
|
||||
@@ -34,6 +37,7 @@ export function Receive() {
|
||||
|
||||
if (!selected) return null
|
||||
const name = settings.data?.account_names[selected]
|
||||
const account = accounts.find((a) => a.address === selected)
|
||||
return (
|
||||
<>
|
||||
<ChainCaveat />
|
||||
@@ -42,22 +46,14 @@ export function Receive() {
|
||||
<h2 className="panel-title">Receive</h2>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{accounts.length > 1 && (
|
||||
<div className="field">
|
||||
<label htmlFor="account">Account</label>
|
||||
<select
|
||||
id="account"
|
||||
className="select"
|
||||
value={selected}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{settings.data?.account_names[a.address] ?? a.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{accounts.length > 1 && session.data && (
|
||||
<AccountSelect
|
||||
id="account"
|
||||
label="Account"
|
||||
session={session.data}
|
||||
value={selected}
|
||||
onChange={setAddress}
|
||||
/>
|
||||
)}
|
||||
<div className="receive">
|
||||
<div
|
||||
@@ -67,6 +63,11 @@ export function Receive() {
|
||||
/>
|
||||
<div>
|
||||
{name && <div className="eyebrow">{name}</div>}
|
||||
{account && (
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 10 }}>
|
||||
{account.wallet} · {schemeLabel(account)} · {account.derivation_path}
|
||||
</div>
|
||||
)}
|
||||
<div className="field">
|
||||
<label>Address</label>
|
||||
<div className="mono address" data-testid="address">
|
||||
|
||||
+15
-18
@@ -1,7 +1,9 @@
|
||||
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'
|
||||
import { AccountSelect } from '../components/AccountSelect'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { ReviewAndSign } from '../components/ReviewAndSign'
|
||||
import { TxProgress } from '../components/TxProgress'
|
||||
@@ -26,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('')
|
||||
@@ -35,7 +40,7 @@ export function Send() {
|
||||
const [prepared, setPrepared] = useState<PreparedTransferInfo | null>(null)
|
||||
const [submitted, setSubmitted] = useState<{ id: string; hash: string } | null>(null)
|
||||
|
||||
const sender = from || accounts[0]?.address || ''
|
||||
const sender = accounts.find((a) => a.address === from)?.address ?? accounts[0]?.address ?? ''
|
||||
const senderBalance = balances.data?.find((b) => b.address === sender)
|
||||
const highSecurity = !!senderBalance?.high_security
|
||||
|
||||
@@ -107,22 +112,14 @@ export function Send() {
|
||||
</div>
|
||||
) : (
|
||||
<form className="panel-body" onSubmit={prepare}>
|
||||
{accounts.length > 1 && (
|
||||
<div className="field">
|
||||
<label htmlFor="from">From</label>
|
||||
<select
|
||||
id="from"
|
||||
className="select"
|
||||
value={sender}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.address} value={a.address}>
|
||||
{settings.data?.account_names[a.address] ?? a.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{accounts.length > 1 && session.data && (
|
||||
<AccountSelect
|
||||
id="from"
|
||||
label="From"
|
||||
session={session.data}
|
||||
value={sender}
|
||||
onChange={setFrom}
|
||||
/>
|
||||
)}
|
||||
<div className="field">
|
||||
<label htmlFor="to">To</label>
|
||||
|
||||
Reference in New Issue
Block a user