Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
038f09774f
|
||
|
|
4f3b446cc3
|
||
|
|
814355c9f3
|
||
|
|
57ac504b93
|
||
|
|
4b407de277
|
||
|
|
5d5baf1c21
|
||
|
|
52ac684661
|
||
|
|
384c79446b
|
||
|
|
f5c214e4bf
|
||
|
|
913155d48e
|
||
|
|
7ddab3ebda
|
||
|
|
f4cc7a1605
|
||
|
|
cde186b3fd
|
||
|
|
1c9467c38d
|
||
|
|
b059a8d5d0
|
||
|
|
bfb016d76f
|
||
|
|
fb42138651
|
||
|
|
70dc6ea650
|
||
|
|
b454e4424f
|
Generated
+28
-4
@@ -6956,9 +6956,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wallet-app"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"directories",
|
||||
"getrandom 0.3.4",
|
||||
@@ -6978,34 +6979,39 @@ dependencies = [
|
||||
"wallet-core",
|
||||
"wallet-data",
|
||||
"wallet-entities",
|
||||
"wallet-near",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wallet-core"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake2",
|
||||
"bs58",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"wallet-entities",
|
||||
"wallet-near",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wallet-data"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"bip39",
|
||||
"blake2",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"directories",
|
||||
"getrandom 0.3.4",
|
||||
@@ -7027,12 +7033,13 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"wallet-core",
|
||||
"wallet-entities",
|
||||
"wallet-near",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wallet-entities"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7040,6 +7047,23 @@ dependencies = [
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wallet-near"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bip39",
|
||||
"bs58",
|
||||
"getrandom 0.3.4",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
"qp-rusty-crystals-dilithium",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha3",
|
||||
"thiserror 2.0.18",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
|
||||
+12
-4
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -11,9 +11,10 @@ authors = ["Rob Thijssen <rob@lair.cafe>"]
|
||||
repository = "https://git.lair.cafe/blackbeard/wallet"
|
||||
|
||||
[workspace.dependencies]
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.2.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.2.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.2.0" }
|
||||
wallet-entities = { path = "crates/wallet-entities", version = "=0.4.0" }
|
||||
wallet-core = { path = "crates/wallet-core", version = "=0.4.0" }
|
||||
wallet-data = { path = "crates/wallet-data", version = "=0.4.0" }
|
||||
wallet-near = { path = "crates/wallet-near", version = "=0.4.0" }
|
||||
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
@@ -37,6 +38,13 @@ qp-poseidon-core = "3.1.0"
|
||||
# SS58: blake2b-512 checksum and base58. Nothing else from sp-core is needed.
|
||||
blake2 = { version = "0.10", default-features = false }
|
||||
bs58 = "0.5"
|
||||
# NEAR RPC carries transactions and view arguments as base64.
|
||||
base64 = "0.22"
|
||||
# NEAR (#74): NEP-649 is SLIP-10 over HMAC-SHA512; a transaction hash is
|
||||
# SHA-256; an ML-DSA-65 key's on-chain handle is SHA3-256.
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
sha3 = "0.10"
|
||||
getrandom = "0.3"
|
||||
# The quantus-cli keystore format: Argon2id (frozen m=19456,t=2,p=1) into
|
||||
# AES-256-GCM. The versions are pinned to what the CLI itself uses.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1175
-35
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,7 @@ pub fn run() {
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
commands::lock,
|
||||
commands::session_status,
|
||||
commands::chains_list,
|
||||
@@ -75,6 +76,15 @@ pub fn run() {
|
||||
commands::settings_set,
|
||||
commands::history_page,
|
||||
commands::wormhole_summary,
|
||||
commands::near_intents,
|
||||
commands::near_create_account,
|
||||
commands::near_qtc_deposit_address,
|
||||
commands::near_withdraw_qtc,
|
||||
commands::near_swap_quote,
|
||||
commands::near_swap,
|
||||
commands::near_cashout_destinations,
|
||||
commands::near_cashout_quote,
|
||||
commands::near_cashout,
|
||||
swap::swap_assets,
|
||||
swap::swap_preview,
|
||||
swap::swap_order_create,
|
||||
|
||||
@@ -10,11 +10,16 @@ use std::time::Duration;
|
||||
use wallet_core::PreparedTransaction;
|
||||
use wallet_core::assets::AssetRegistry;
|
||||
use wallet_core::backup::BackupChallenge;
|
||||
use wallet_core::ports::{FiatRamp, HistoryCache, HistorySource, OrderStore, ShieldedCache};
|
||||
use wallet_core::near::NearNetwork;
|
||||
use wallet_core::ports::{
|
||||
FiatRamp, HistoryCache, HistorySource, NearRpc, NearSponsor, OrderStore, QtcBridge,
|
||||
ShieldedCache,
|
||||
};
|
||||
use wallet_core::profiles::ProfileRegistry;
|
||||
use wallet_core::session::Session;
|
||||
use wallet_data::history::{ObserverHistory, SqliteHistoryCache};
|
||||
use wallet_data::keystore::FileKeystore;
|
||||
use wallet_data::near::{HttpNearRpc, HttpSponsor, PoaBridge};
|
||||
use wallet_data::ramp::StubRamp;
|
||||
use wallet_data::settings::SettingsStore;
|
||||
use wallet_data::substrate::ChainManager;
|
||||
@@ -51,6 +56,9 @@ pub struct AppState {
|
||||
pub history_cache: Arc<dyn HistoryCache>,
|
||||
/// The same SQLite file, for the zk-tree scan of the wormhole address.
|
||||
pub shielded_cache: Arc<dyn ShieldedCache>,
|
||||
/// One zk-tree scan at a time: a scan covers every open wallet, so a
|
||||
/// second one waiting here finds its wallet already caught up.
|
||||
pub zk_scan: tokio::sync::Mutex<()>,
|
||||
/// Swap orders, in the same file, polled while open.
|
||||
pub orders: Arc<dyn OrderStore>,
|
||||
/// The swap provider: NEAR Intents' 1Click, through the asset registry.
|
||||
@@ -58,6 +66,13 @@ pub struct AppState {
|
||||
pub assets: AssetRegistry,
|
||||
/// The fiat ramp: a stub until a provider is signed (#44).
|
||||
pub ramp: Arc<dyn FiatRamp>,
|
||||
/// NEAR, under the intents account (#71): RPC routed by shard (#73).
|
||||
pub near: Arc<dyn NearRpc>,
|
||||
/// The sponsor that creates the account and pays its gas (#81).
|
||||
pub near_sponsor: Arc<dyn NearSponsor>,
|
||||
/// The PoA bridge between Quantus and NEAR (#75).
|
||||
pub qtc_bridge: Arc<dyn QtcBridge>,
|
||||
pub near_network: NearNetwork,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -65,7 +80,7 @@ impl AppState {
|
||||
/// `WALLET_KEYSTORE_DIR` says otherwise (useful for a dev run against a
|
||||
/// throwaway directory, or for pointing at `~/.quantus/wallets`).
|
||||
/// Everything lives under one app-data directory: `<data>/wallets/`,
|
||||
/// `<data>/settings.json`, `<data>/profiles.toml`. `WALLET_DATA_DIR`
|
||||
/// `<data>/settings.json`, `<data>/profiles.toml`, `<data>/near.toml`. `WALLET_DATA_DIR`
|
||||
/// relocates the whole thing (a dev run against a throwaway directory);
|
||||
/// `WALLET_KEYSTORE_DIR` relocates only the wallets (pointing at
|
||||
/// `~/.quantus/wallets` shares them with the CLI in place).
|
||||
@@ -97,6 +112,15 @@ impl AppState {
|
||||
profiles = profiles.with_override(&text)?;
|
||||
tracing::info!(path = %override_path.display(), "applied profile overrides");
|
||||
}
|
||||
// NEAR endpoints and sponsor: shipped, or `<data>/near.toml` in their
|
||||
// place.
|
||||
let near_path = data_dir.join("near.toml");
|
||||
let near_network = if near_path.exists() {
|
||||
tracing::info!(path = %near_path.display(), "NEAR settings from the data directory");
|
||||
NearNetwork::from_toml(&std::fs::read_to_string(&near_path)?)?
|
||||
} else {
|
||||
NearNetwork::shipped()?
|
||||
};
|
||||
let assets = AssetRegistry::shipped().map_err(|e| anyhow::anyhow!("assets.toml: {e}"))?;
|
||||
let cache = Arc::new(
|
||||
SqliteHistoryCache::open(&data_dir.join("history.sqlite"))
|
||||
@@ -114,10 +138,15 @@ impl AppState {
|
||||
history_source: Arc::new(ObserverHistory::new()),
|
||||
history_cache: cache.clone(),
|
||||
shielded_cache: cache.clone(),
|
||||
zk_scan: tokio::sync::Mutex::new(()),
|
||||
orders: cache,
|
||||
swap: Arc::new(OneClick::new(assets.clone())),
|
||||
assets,
|
||||
ramp: Arc::new(StubRamp::new()),
|
||||
near: Arc::new(HttpNearRpc::new(near_network.clone())),
|
||||
near_sponsor: Arc::new(HttpSponsor::new(&near_network.sponsor_api)),
|
||||
qtc_bridge: Arc::new(PoaBridge::new(&near_network.bridge_rpc)),
|
||||
near_network,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "blackbeard-wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "cafe.lair.blackbeard.wallet",
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
|
||||
@@ -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"
|
||||
@@ -328,6 +328,93 @@ pub enum SwapError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// NEAR, the chain under the intents account (#71). Every call names the
|
||||
/// account it is about, so the adapter can send it to a node that tracks
|
||||
/// that account's shard: a NEAR node knows nothing of other shards. No
|
||||
/// call waits for a transaction's outcome, which no single shard node can
|
||||
/// assemble; the wallet judges each step by the state it leaves.
|
||||
#[async_trait]
|
||||
pub trait NearRpc: Send + Sync {
|
||||
/// The account, or `None` if it does not exist.
|
||||
async fn account(&self, account: &str) -> Result<Option<NearAccount>, NearError>;
|
||||
|
||||
/// The nonce of one access key, or `None` if the account has no such
|
||||
/// key. The key is named in full (`ml-dsa-65:<base58>` for ML-DSA-65):
|
||||
/// a node refuses the `ml-dsa-65-hash:` handle that key listings show,
|
||||
/// although the handle is all that state keeps.
|
||||
async fn access_key_nonce(&self, account: &str, key: &str) -> Result<Option<u64>, NearError>;
|
||||
|
||||
/// A view call on `contract`, with JSON arguments and a JSON answer.
|
||||
async fn view(
|
||||
&self,
|
||||
contract: &str,
|
||||
method: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, NearError>;
|
||||
|
||||
/// A recent final block's hash, which a transaction must name, and its
|
||||
/// height, from which a delegate action's expiry is set.
|
||||
async fn final_block(&self) -> Result<([u8; 32], u64), NearError>;
|
||||
|
||||
/// Submit a signed transaction from `signer`, returning once it is in a
|
||||
/// final block (or refused). Returns its hash.
|
||||
async fn send(&self, signer: &str, signed_tx: &[u8]) -> Result<[u8; 32], NearError>;
|
||||
}
|
||||
|
||||
/// The sponsor (#81): creates the wallet's NEAR account and relays its
|
||||
/// legs as NEP-366 meta-transactions, paying the gas. Its terms are read
|
||||
/// before anything is asked of it.
|
||||
#[async_trait]
|
||||
pub trait NearSponsor: Send + Sync {
|
||||
async fn terms(&self) -> Result<SponsorTerms, NearError>;
|
||||
|
||||
/// Create (or find) the account for an ML-DSA-65 public key
|
||||
/// (`ml-dsa-65:<base58>`). Returns its name.
|
||||
async fn create_account(&self, public_key: &str) -> Result<String, NearError>;
|
||||
|
||||
/// Relay a signed delegate action; returns the sponsor's transaction
|
||||
/// hash once it is in a final block.
|
||||
async fn relay(&self, signed_delegate: &[u8]) -> Result<[u8; 32], NearError>;
|
||||
}
|
||||
|
||||
/// The PoA bridge that carries QTC between Quantus and NEAR (#75). Each
|
||||
/// NEAR account has one permanent Quantus deposit address; QTC sent there
|
||||
/// is credited to the account inside Intents as `qtc.omft.near`.
|
||||
#[async_trait]
|
||||
pub trait QtcBridge: Send + Sync {
|
||||
async fn deposit_address(&self, near_account: &str) -> Result<String, NearError>;
|
||||
}
|
||||
|
||||
/// What the sponsor publishes about itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SponsorTerms {
|
||||
pub enabled: bool,
|
||||
pub account: Option<String>,
|
||||
/// The swap fee in basis points of a pool swap's input.
|
||||
pub fee_bps: Option<u32>,
|
||||
pub fee_recipient: Option<String>,
|
||||
}
|
||||
|
||||
/// What the wallet reads of a NEAR account: its native balance, for gas.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NearAccount {
|
||||
pub balance: Amount,
|
||||
pub locked: Amount,
|
||||
pub storage_usage: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum NearError {
|
||||
#[error("no NEAR endpoint answered: {0}")]
|
||||
Unreachable(String),
|
||||
/// The node answered with an error; `name` is NEAR's (`UNKNOWN_ACCOUNT`,
|
||||
/// `INVALID_TRANSACTION`, …) and `detail` the rest of it.
|
||||
#[error("NEAR refused it ({name}): {detail}")]
|
||||
Rpc { name: String, detail: String },
|
||||
#[error("unexpected answer from NEAR: {0}")]
|
||||
BadAnswer(String),
|
||||
}
|
||||
|
||||
/// A fiat on/off ramp. The wallet builds a session through the relay on our
|
||||
/// infrastructure (which holds the provider secrets) and opens it in the
|
||||
/// system browser; it never embeds a provider's page in the wallet's webview.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,11 @@ mod tests {
|
||||
account_names: [("qz1".to_string(), "savings".to_string())].into(),
|
||||
preview_swaps: true,
|
||||
wallet_accounts: [("w".to_string(), vec![1, 2])].into(),
|
||||
intents_account: Some(wallet_entities::IntentsAccountRecord {
|
||||
wallet: "w".into(),
|
||||
account_id: "bx.blackbeard-observer.near".into(),
|
||||
public_key: "ml-dsa-65:x".into(),
|
||||
}),
|
||||
};
|
||||
store.save(&changed).unwrap();
|
||||
assert_eq!(store.load().unwrap(), changed);
|
||||
|
||||
@@ -100,6 +100,42 @@ pub async fn storage_at_best(
|
||||
Ok((conn.client.storage().at(best_hash), at_block))
|
||||
}
|
||||
|
||||
/// Which of `accounts` exist on chain, in one request: a `System::Account`
|
||||
/// entry is present for an account that has ever been funded, and absent
|
||||
/// for one that has not (on Substrate an empty account is not an account
|
||||
/// with zero, it is no account at all). Used to find the account numbers a
|
||||
/// restored phrase already uses.
|
||||
pub async fn which_exist(
|
||||
conn: &ChainConnection,
|
||||
accounts: &[[u8; 32]],
|
||||
) -> Result<Vec<bool>, ChainError> {
|
||||
if accounts.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let keys: Vec<Vec<u8>> = accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let addr = subxt::dynamic::storage("System", "Account", vec![Value::from_bytes(*a)]);
|
||||
conn.client
|
||||
.storage()
|
||||
.address_bytes(&addr)
|
||||
.map_err(|e| ChainError::Other(e.to_string()))
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let sets = conn
|
||||
.rpc
|
||||
.state_query_storage_at(keys.iter().map(Vec::as_slice), None)
|
||||
.await
|
||||
.map_err(rpc_err)?;
|
||||
let present: std::collections::HashSet<Vec<u8>> = sets
|
||||
.iter()
|
||||
.flat_map(|s| s.changes.iter())
|
||||
.filter(|(_, v)| v.is_some())
|
||||
.map(|(k, _)| k.0.clone())
|
||||
.collect();
|
||||
Ok(keys.iter().map(|k| present.contains(k)).collect())
|
||||
}
|
||||
|
||||
pub async fn account_info(
|
||||
conn: &ChainConnection,
|
||||
account: [u8; 32],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -176,6 +177,18 @@ fn the_flow_leaves_no_secret_in_logs_files_or_debug_output() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tracing::trace!(?keys, "wormhole");
|
||||
let near = session
|
||||
.with_seed("audit", |s| {
|
||||
NearKey::derive(s.as_bytes(), NEAR_DEFAULT_PATH)
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tracing::trace!(
|
||||
?near,
|
||||
sig_len = near.sign(b"tx hash").unwrap().len(),
|
||||
"near"
|
||||
);
|
||||
|
||||
let cache = SqliteHistoryCache::open(&data.join("history.sqlite")).unwrap();
|
||||
let chain = ChainId("quantus".into());
|
||||
@@ -213,7 +226,7 @@ fn the_flow_leaves_no_secret_in_logs_files_or_debug_output() {
|
||||
drop(cache);
|
||||
|
||||
// Debug output of every secret-holding type, as a caller might log it.
|
||||
let debug = format!("{signer:?}\n{keys:?}\n{session:?}\n{wormhole:?}");
|
||||
let debug = format!("{signer:?}\n{keys:?}\n{session:?}\n{wormhole:?}\n{near:?}");
|
||||
assert_clean("Debug output", debug.as_bytes(), &forbidden);
|
||||
assert!(debug.contains("redacted"), "{debug}");
|
||||
|
||||
|
||||
@@ -473,6 +473,22 @@ pub struct Settings {
|
||||
/// every unlock. Kept here because the keystore file is quantus-cli's
|
||||
/// and holds one keypair.
|
||||
pub wallet_accounts: BTreeMap<String, Vec<u32>>,
|
||||
/// The installation's one NEAR Intents account (#74, #81): every swap
|
||||
/// routes through it, whichever wallet's QTC goes in or out. Recorded
|
||||
/// when it is created or first found; not secret.
|
||||
pub intents_account: Option<IntentsAccountRecord>,
|
||||
}
|
||||
|
||||
/// Which intents account this installation uses, and which wallet's
|
||||
/// phrase controls it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct IntentsAccountRecord {
|
||||
/// The wallet whose phrase derives the account's key.
|
||||
pub wallet: String,
|
||||
pub account_id: String,
|
||||
/// `ml-dsa-65:<base58>`.
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -484,6 +500,7 @@ impl Default for Settings {
|
||||
account_names: BTreeMap::new(),
|
||||
preview_swaps: false,
|
||||
wallet_accounts: BTreeMap::new(),
|
||||
intents_account: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,6 +723,88 @@ pub struct WormholeSummary {
|
||||
pub leaf_count: u64,
|
||||
}
|
||||
|
||||
/// The open wallet's NEAR Intents account (blackbeard/wallet #74): the
|
||||
/// NEAR account its phrase's ML-DSA-65 key (NEP-649) controls, named by the
|
||||
/// sponsor from that key (#81), and what it holds. No secret: the key is
|
||||
/// public, and the account is derived again from the phrase on every look.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct NearAccountSummary {
|
||||
/// The wallet whose phrase controls the account (or would, once made).
|
||||
pub wallet: String,
|
||||
/// Whether that wallet is open with its phrase, so the account can sign.
|
||||
pub owner_open: bool,
|
||||
/// Open wallets with a phrase that could own it, when none does yet.
|
||||
pub candidates: Vec<String>,
|
||||
/// `b<…>.blackbeard-observer.near`, whether or not it exists yet.
|
||||
pub account_id: String,
|
||||
/// `ml-dsa-65:<base58>`: the key that controls it.
|
||||
pub public_key: String,
|
||||
/// The NEP-649 path the key is derived at.
|
||||
pub derivation_path: String,
|
||||
/// Whether the account exists on NEAR.
|
||||
pub exists: bool,
|
||||
/// Whether this wallet's key is one of the account's access keys. False
|
||||
/// for an existing account means it is not this wallet's to use.
|
||||
pub key_on_account: bool,
|
||||
/// Native NEAR on the account. The sponsor pays its gas (#81), so this
|
||||
/// is only the storage the sponsor deposited; `None` until it exists.
|
||||
pub gas: Option<Amount>,
|
||||
/// Whether the sponsor will create it now: it does not exist, and the
|
||||
/// sponsor is running on terms the wallet accepts.
|
||||
pub can_create: bool,
|
||||
/// Why it cannot be created, when it does not exist and cannot be.
|
||||
pub sponsor_note: Option<String>,
|
||||
/// What the account holds inside NEAR Intents, by the assets the wallet
|
||||
/// knows; nonzero balances only.
|
||||
pub intents: Vec<IntentsBalance>,
|
||||
}
|
||||
|
||||
/// A swap inside the intents account, priced both ways and the better
|
||||
/// route chosen (#76). Amounts are the tokens' own base units.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct IntentsSwapQuote {
|
||||
/// `pool` (a Ref DCL pool) or `1click`.
|
||||
pub route: String,
|
||||
/// The pool, or `1Click`.
|
||||
pub venue: String,
|
||||
pub token_in: String,
|
||||
pub symbol_in: String,
|
||||
pub token_out: String,
|
||||
pub symbol_out: String,
|
||||
pub amount_in: Amount,
|
||||
/// The swap fee (#82) in basis points, and in the input token for a
|
||||
/// pool swap. On 1Click it is inside the quote.
|
||||
pub fee_bps: u32,
|
||||
pub fee: Amount,
|
||||
pub amount_out: Amount,
|
||||
/// The floor: a pool refuses and refunds below it, 1Click refunds.
|
||||
pub min_out: Amount,
|
||||
/// The other route's output, when it quoted, for comparison.
|
||||
pub alternative_out: Option<Amount>,
|
||||
}
|
||||
|
||||
/// Somewhere the intents account can pay out to (#77).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct CashoutDestination {
|
||||
/// 1Click's asset id.
|
||||
pub id: String,
|
||||
pub chain: String,
|
||||
pub symbol: String,
|
||||
}
|
||||
|
||||
/// One token held inside NEAR Intents.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct IntentsBalance {
|
||||
/// The Intents token id, `nep141:<contract>`.
|
||||
pub token: String,
|
||||
pub symbol: String,
|
||||
pub amount: Amount,
|
||||
}
|
||||
|
||||
/// Where a swap order is in its life (blackbeard/wallet #39). Terminal
|
||||
/// states are `Success`, `Refunded`, `Failed` and `Expired`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
@@ -797,3 +896,14 @@ pub struct SwapAsk {
|
||||
pub refund_to: String,
|
||||
pub slippage_bps: u16,
|
||||
}
|
||||
|
||||
/// What a scan for a phrase's used accounts found.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct AccountScan {
|
||||
/// Account numbers opened that were not already known.
|
||||
pub added: u32,
|
||||
/// Account numbers looked at.
|
||||
pub scanned: u32,
|
||||
pub session: SessionStatusInfo,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wallet-ui",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
|
||||
@@ -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>, };
|
||||
@@ -1,5 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { IntentsAccountRecord } from "./IntentsAccountRecord";
|
||||
|
||||
/**
|
||||
* Persisted preferences. Nothing here is secret.
|
||||
@@ -34,4 +35,10 @@ preview_swaps: boolean,
|
||||
* every unlock. Kept here because the keystore file is quantus-cli's
|
||||
* and holds one keypair.
|
||||
*/
|
||||
wallet_accounts: { [key in string]?: Array<number> }, };
|
||||
wallet_accounts: { [key in string]?: Array<number> },
|
||||
/**
|
||||
* The installation's one NEAR Intents account (#74, #81): every swap
|
||||
* routes through it, whichever wallet's QTC goes in or out. Recorded
|
||||
* when it is created or first found; not secret.
|
||||
*/
|
||||
intents_account: IntentsAccountRecord | null, };
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
settingsSet,
|
||||
unlock,
|
||||
walletAddAccount,
|
||||
walletDiscoverAccounts,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
@@ -82,6 +83,19 @@ export function useAddAccount() {
|
||||
})
|
||||
}
|
||||
|
||||
/** Scan a wallet's phrase for account numbers already used on `chain`. */
|
||||
export function useDiscoverAccounts() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ chain, wallet }: { chain: ChainId; wallet: string }) =>
|
||||
walletDiscoverAccounts(chain, wallet),
|
||||
onSuccess: (scan) => {
|
||||
qc.setQueryData(queryKeys.session, scan.session)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.settings })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Lock one wallet (pass its name) or every open wallet (pass nothing). */
|
||||
export function useLock() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
|
||||
import type { CreationStart } from './generated/CreationStart'
|
||||
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
|
||||
import type { Settings } from './generated/Settings'
|
||||
import type { AccountScan } from './generated/AccountScan'
|
||||
import type { AssetInfo } from './generated/AssetInfo'
|
||||
import type { HistoryCursor } from './generated/HistoryCursor'
|
||||
import type { Order } from './generated/Order'
|
||||
@@ -21,6 +22,9 @@ import type { SwapAsk } from './generated/SwapAsk'
|
||||
import type { SwapPreview } from './generated/SwapPreview'
|
||||
import type { HistoryPage } from './generated/HistoryPage'
|
||||
import type { WalletSummary } from './generated/WalletSummary'
|
||||
import type { CashoutDestination } from './generated/CashoutDestination'
|
||||
import type { IntentsSwapQuote } from './generated/IntentsSwapQuote'
|
||||
import type { NearAccountSummary } from './generated/NearAccountSummary'
|
||||
import type { WormholeSummary } from './generated/WormholeSummary'
|
||||
import type { WalletError as WalletErrorShape } from './generated/WalletError'
|
||||
|
||||
@@ -116,6 +120,15 @@ export function walletAddAccount(wallet: string): Promise<SessionStatusInfo> {
|
||||
return command<SessionStatusInfo>('wallet_add_account', { wallet })
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the account numbers a wallet's phrase already uses on `chain`: the
|
||||
* chain is asked which derived addresses exist, and the ones found are
|
||||
* opened and remembered.
|
||||
*/
|
||||
export function walletDiscoverAccounts(chain: ChainId, wallet: string): Promise<AccountScan> {
|
||||
return command<AccountScan>('wallet_discover_accounts', { chain, wallet })
|
||||
}
|
||||
|
||||
/** Lock one open wallet by name, or all of them when none is named. */
|
||||
export function lock(wallet?: string): Promise<void> {
|
||||
return command<void>('lock', { wallet: wallet ?? null })
|
||||
@@ -211,6 +224,70 @@ export function wormholeSummary(chain: ChainId, wallet: string): Promise<Wormhol
|
||||
return command<WormholeSummary>('wormhole_summary', { chain, wallet })
|
||||
}
|
||||
|
||||
// The installation's one NEAR Intents account: controlled by one wallet's
|
||||
// phrase, named by the sponsor from its key, read from NEAR. Every swap
|
||||
// routes through it. Only public fields come back.
|
||||
export function nearIntents(): Promise<NearAccountSummary> {
|
||||
return command<NearAccountSummary>('near_intents')
|
||||
}
|
||||
|
||||
// QTC between Quantus and the intents account, over the PoA bridge. The
|
||||
// deposit address is permanent per account; a withdrawal is signed in Rust
|
||||
// and relayed by the sponsor, and returns its NEAR transaction hash.
|
||||
export function nearQtcDepositAddress(): Promise<string> {
|
||||
return command<string>('near_qtc_deposit_address')
|
||||
}
|
||||
|
||||
export function nearWithdrawQtc(amount: string, to: string): Promise<string> {
|
||||
return command<string>('near_withdraw_qtc', { amount, to })
|
||||
}
|
||||
|
||||
// A swap inside the intents account: priced on a Ref pool and on 1Click,
|
||||
// the better taken. Tokens are contract ids (qtc.omft.near, wrap.near, …);
|
||||
// the amount is in the input token's own units, as typed.
|
||||
export function nearSwapQuote(
|
||||
tokenIn: string,
|
||||
tokenOut: string,
|
||||
amount: string,
|
||||
): Promise<IntentsSwapQuote> {
|
||||
return command<IntentsSwapQuote>('near_swap_quote', { tokenIn, tokenOut, amount })
|
||||
}
|
||||
|
||||
export function nearSwap(tokenIn: string, tokenOut: string, amount: string): Promise<string> {
|
||||
return command<string>('near_swap', { tokenIn, tokenOut, amount })
|
||||
}
|
||||
|
||||
// Cash-out: pay a token out of the intents account to an address on
|
||||
// another chain, through 1Click. Rust checks the address by the
|
||||
// destination chain's form before anything is priced or signed.
|
||||
export function nearCashoutDestinations(): Promise<CashoutDestination[]> {
|
||||
return command<CashoutDestination[]>('near_cashout_destinations')
|
||||
}
|
||||
|
||||
export function nearCashoutQuote(
|
||||
tokenIn: string,
|
||||
destination: string,
|
||||
amount: string,
|
||||
payTo: string,
|
||||
): Promise<IntentsSwapQuote> {
|
||||
return command<IntentsSwapQuote>('near_cashout_quote', { tokenIn, destination, amount, payTo })
|
||||
}
|
||||
|
||||
export function nearCashout(
|
||||
tokenIn: string,
|
||||
destination: string,
|
||||
amount: string,
|
||||
payTo: string,
|
||||
): Promise<string> {
|
||||
return command<string>('near_cashout', { tokenIn, destination, amount, payTo })
|
||||
}
|
||||
|
||||
// Ask the sponsor to create it, controlled by `wallet`'s phrase. The wallet
|
||||
// makes one, and only one. Returns it, read back.
|
||||
export function nearCreateAccount(wallet: string): Promise<NearAccountSummary> {
|
||||
return command<NearAccountSummary>('near_create_account', { wallet })
|
||||
}
|
||||
|
||||
// Swaps, in preview: the assets the provider and the registry agree on, a
|
||||
// dry quote, an order (a wet quote persisted with its deposit address),
|
||||
// and the list. Rust follows open orders and emits `orders:updated`.
|
||||
|
||||
@@ -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 { 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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useAddAccount,
|
||||
useDiscoverAccounts,
|
||||
useBalances,
|
||||
useChains,
|
||||
useLock,
|
||||
@@ -11,6 +12,7 @@ import { AccountName } from '../components/AccountName'
|
||||
import { ChainCaveat } from '../components/ChainCaveat'
|
||||
import { NetworkOverview } from '../components/NetworkOverview'
|
||||
import { PendingReversibleList } from '../components/PendingReversible'
|
||||
import { NearIntentsPanel } from '../components/NearIntentsPanel'
|
||||
import { WormholePanel } from '../components/WormholePanel'
|
||||
import { schemeLabel } from '../lib/accounts'
|
||||
import { formatAmount } from '../lib/format'
|
||||
@@ -28,6 +30,7 @@ export function Accounts() {
|
||||
const session = useSession()
|
||||
const lock = useLock()
|
||||
const addAccount = useAddAccount()
|
||||
const discover = useDiscoverAccounts()
|
||||
const unlocked = !!session.data && !session.data.locked
|
||||
const balances = useBalances(settings.data?.network, unlocked)
|
||||
const chains = useChains()
|
||||
@@ -63,6 +66,14 @@ export function Accounts() {
|
||||
{balances.isPending && <p className="panel-body muted">Reading balances from the chain…</p>}
|
||||
{balances.isError && <p className="error">{balances.error.message}</p>}
|
||||
{addAccount.isError && <p className="error">{addAccount.error.message}</p>}
|
||||
{discover.isError && <p className="error">{discover.error.message}</p>}
|
||||
{discover.isSuccess && (
|
||||
<p className="panel-body muted" style={{ paddingTop: 0 }}>
|
||||
{discover.data.added > 0
|
||||
? `Found ${discover.data.added} more ${discover.data.added === 1 ? 'account' : 'accounts'} in ${discover.data.scanned} looked at.`
|
||||
: `Nothing beyond the accounts already open, in ${discover.data.scanned} account numbers looked at.`}
|
||||
</p>
|
||||
)}
|
||||
{session.data && (
|
||||
<table className="table" data-testid="accounts">
|
||||
<thead>
|
||||
@@ -88,6 +99,19 @@ export function Accounts() {
|
||||
</th>
|
||||
<th className="num">
|
||||
<span className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
||||
{w.has_seed && chain && (
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={discover.isPending}
|
||||
title="Ask the chain which of this phrase's accounts have been used"
|
||||
onClick={() => discover.mutate({ chain, wallet: w.wallet.name })}
|
||||
>
|
||||
{discover.isPending && discover.variables?.wallet === w.wallet.name
|
||||
? 'Looking…'
|
||||
: 'Find accounts'}
|
||||
</button>
|
||||
)}
|
||||
{w.has_seed && (
|
||||
<button
|
||||
className="button"
|
||||
@@ -154,6 +178,7 @@ export function Accounts() {
|
||||
wallet={w.wallet.name}
|
||||
/>
|
||||
))}
|
||||
{session.data?.wallets.some((w) => w.has_seed) && <NearIntentsPanel />}
|
||||
<NetworkOverview />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,14 +3,16 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { CreationStart } from '../api/generated/CreationStart'
|
||||
import { pickWalletFile } from '../api/dialog'
|
||||
import { queryKeys } from '../api/hooks'
|
||||
import { queryKeys, useSettings } from '../api/hooks'
|
||||
import {
|
||||
chainConnect,
|
||||
checkphrase,
|
||||
unlock,
|
||||
walletCreateBegin,
|
||||
walletCreateCancel,
|
||||
walletCreateConfirm,
|
||||
walletImportFile,
|
||||
walletDiscoverAccounts,
|
||||
walletImportPhrase,
|
||||
} from '../api/wallet'
|
||||
|
||||
@@ -43,12 +45,27 @@ export function OnboardingIndex() {
|
||||
)
|
||||
}
|
||||
|
||||
function useFinish() {
|
||||
function useFinish(discover = false) {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const settings = useSettings()
|
||||
const discoverOn = discover ? settings.data?.network : undefined
|
||||
return async (name: string, password: string) => {
|
||||
await qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
const status = await unlock(name, password)
|
||||
// A restored phrase may already use accounts beyond the first. Best
|
||||
// effort: the chain has to be reachable, and a failure here must not
|
||||
// stand between the person and their wallet.
|
||||
if (discoverOn) {
|
||||
try {
|
||||
await chainConnect(discoverOn)
|
||||
const scan = await walletDiscoverAccounts(discoverOn, name)
|
||||
qc.setQueryData(queryKeys.session, scan.session)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.settings })
|
||||
} catch {
|
||||
// the wallet is open; Find accounts on the accounts page retries
|
||||
}
|
||||
}
|
||||
// Leave onboarding before the session flips, so the shell mounts on the
|
||||
// accounts page rather than on a route that no longer applies.
|
||||
navigate('/', { replace: true })
|
||||
@@ -254,7 +271,7 @@ export function Create() {
|
||||
}
|
||||
|
||||
export function Restore() {
|
||||
const finish = useFinish()
|
||||
const finish = useFinish(true)
|
||||
const [phrase, setPhrase] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useBalances, useChains, useSession, useSettings } from '../api/hooks'
|
||||
import type { PreparedTransferInfo } from '../api/generated/PreparedTransferInfo'
|
||||
import { transferPrepare } from '../api/wallet'
|
||||
@@ -27,7 +28,10 @@ export function Send() {
|
||||
const accounts = session.data?.accounts ?? []
|
||||
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
// `?to=` prefills the recipient: the intents panel sends here to deposit
|
||||
// QTC at the bridge's address for the account.
|
||||
const [params] = useSearchParams()
|
||||
const [to, setTo] = useState(params.get('to') ?? '')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [window, setWindow] = useState<'none' | 'custom' | number>('none')
|
||||
const [customBlocks, setCustomBlocks] = useState('')
|
||||
|
||||
Reference in New Issue
Block a user