feat(near): QTC into the intents account and back out to Quantus
ci / gate (push) Failing after 1m35s
ci / gate (push) Failing after 1m35s
The intents panel now moves QTC across the PoA bridge (#75). In: the bridge's permanent Quantus deposit address for the account, checked as a Quantus address before it is shown. "Send QTC here" opens Send with it filled in (Send takes ?to= now), so a deposit is an ordinary transfer reviewed and signed like any other. Out: a withdrawal to one of the wallet's own Quantus accounts. It is the ft_withdraw #72 proved, qtc.omft.near to itself with the WITHDRAW_TO: memo: - qtc_withdraw_call in wallet-core builds it and refuses anything under the bridge's 0.01 QTC minimum. - The command checks the Intents balance and the sponsor's terms, signs a delegate action with the phrase's NEAR key (nonce one past the key's, valid for about 120 blocks), and hands it to the sponsor, which relays it and pays the gas. - The bridge keeps 0.002 QTC, and the panel says so. NearRpc::final_block now returns the height with the hash, for the delegate's expiry. QtcBridge is the new port and PoaBridge its adapter. Checked in a dev run with the dev-phrase account: - the deposit address appeared (qzkgR6o1…); - Send opened with it filled in; - a 0.05 QTC withdrawal was refused because the account holds no QTC. A live test (ignored) confirms the bridge gives #72's account the address it used on mainnet. A full round trip with real QTC is still to do before #75 closes. Refs #75 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
This commit is contained in:
@@ -32,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
|
||||
|
||||
@@ -1093,6 +1093,126 @@ pub async fn near_account(
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
/// The open wallet's NEAR account name and key, derived from its phrase.
|
||||
fn near_identity(state: &AppState, wallet: &str) -> Result<(String, String), WalletError> {
|
||||
use wallet_near::{NEAR_DEFAULT_PATH, NearKey, sponsored_account};
|
||||
state
|
||||
.session
|
||||
.with_seed(wallet, |seed| {
|
||||
NearKey::derive(seed.as_bytes(), NEAR_DEFAULT_PATH).map(|k| {
|
||||
(
|
||||
sponsored_account(&k.handle_digest(), &state.near_network.sponsor),
|
||||
k.key_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.map_err(session_error)?
|
||||
.ok_or_else(|| WalletError::Refused("this wallet has no phrase, so no NEAR key".into()))?
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Where to send QTC from Quantus to land in the open wallet's intents
|
||||
/// account (#75): the account's permanent bridge deposit address, checked
|
||||
/// as a Quantus address.
|
||||
#[tauri::command]
|
||||
pub async fn near_qtc_deposit_address(
|
||||
state: State<'_, AppState>,
|
||||
wallet: String,
|
||||
) -> Result<String, WalletError> {
|
||||
let (account, _) = near_identity(&state, &wallet)?;
|
||||
state
|
||||
.qtc_bridge
|
||||
.deposit_address(&account)
|
||||
.await
|
||||
.map_err(|e| WalletError::Internal(format!("bridge: {e}")))
|
||||
}
|
||||
|
||||
/// QTC out of the intents account and back to a Quantus address (#75): an
|
||||
/// `ft_withdraw` with the bridge's memo, signed here as a delegate action
|
||||
/// and relayed by the sponsor, which pays the gas. Returns the sponsor's
|
||||
/// transaction hash. The bridge keeps 0.002 QTC.
|
||||
#[tauri::command]
|
||||
pub async fn near_withdraw_qtc(
|
||||
state: State<'_, AppState>,
|
||||
wallet: String,
|
||||
amount: String,
|
||||
to: String,
|
||||
) -> Result<String, WalletError> {
|
||||
use wallet_core::near::{INTENTS, QTC_DECIMALS, QTC_TOKEN, qtc_withdraw_call, usable_terms};
|
||||
use wallet_near::delegate::DelegateAction;
|
||||
use wallet_near::{NEAR_DEFAULT_PATH, NearKey};
|
||||
|
||||
let raw = Amount::parse(&amount, QTC_DECIMALS)
|
||||
.map_err(WalletError::InvalidInput)?
|
||||
.raw;
|
||||
match ss58::decode(to.trim()) {
|
||||
Ok((189, _)) => {}
|
||||
_ => {
|
||||
return Err(WalletError::InvalidInput(format!(
|
||||
"{to} is not a Quantus address"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let call = qtc_withdraw_call(to.trim(), raw).map_err(WalletError::InvalidInput)?;
|
||||
let (account, key) = near_identity(&state, &wallet)?;
|
||||
let near = |e: wallet_core::ports::NearError| WalletError::Internal(format!("NEAR: {e}"));
|
||||
|
||||
let held = state
|
||||
.near
|
||||
.view(
|
||||
INTENTS,
|
||||
"mt_batch_balance_of",
|
||||
&serde_json::json!({ "account_id": account, "token_ids": [format!("nep141:{QTC_TOKEN}")] }),
|
||||
)
|
||||
.await
|
||||
.map_err(near)?;
|
||||
let held: u128 = held[0].as_str().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
if held < raw {
|
||||
return Err(WalletError::InvalidInput(format!(
|
||||
"the intents account holds {} QTC",
|
||||
Amount::new(held, QTC_DECIMALS).to_decimal_string()
|
||||
)));
|
||||
}
|
||||
let terms = state.near_sponsor.terms().await.map_err(near)?;
|
||||
usable_terms(&terms, &state.near_network.sponsor).map_err(WalletError::Refused)?;
|
||||
let nonce = state
|
||||
.near
|
||||
.access_key_nonce(&account, &key)
|
||||
.await
|
||||
.map_err(near)?
|
||||
.ok_or_else(|| {
|
||||
WalletError::Refused(format!("{account} does not carry this wallet's key"))
|
||||
})?;
|
||||
let (_, height) = state.near.final_block().await.map_err(near)?;
|
||||
|
||||
let signed = state
|
||||
.session
|
||||
.with_seed(&wallet, |seed| {
|
||||
let k = NearKey::derive(seed.as_bytes(), NEAR_DEFAULT_PATH)?;
|
||||
DelegateAction {
|
||||
sender_id: account.clone(),
|
||||
receiver_id: INTENTS.into(),
|
||||
actions: vec![call],
|
||||
nonce: nonce + 1,
|
||||
// About two minutes of blocks for the sponsor to include it.
|
||||
max_block_height: height + 120,
|
||||
public_key: k.public_key().to_vec(),
|
||||
}
|
||||
.sign(&k)
|
||||
})
|
||||
.map_err(session_error)?
|
||||
.ok_or_else(|| WalletError::Refused("this wallet has no phrase".into()))?
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let hash = state
|
||||
.near_sponsor
|
||||
.relay(&signed.encode())
|
||||
.await
|
||||
.map_err(|e| WalletError::Internal(format!("sponsor: {e}")))?;
|
||||
let hash = bs58::encode(hash).into_string();
|
||||
tracing::info!(%account, tx = %hash, "QTC withdrawal relayed");
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Ask the sponsor to create the open wallet's NEAR Intents account (#81),
|
||||
/// then read it back. The sponsor names the account from the key; a name
|
||||
/// other than the one the wallet derived is refused rather than used.
|
||||
|
||||
@@ -78,6 +78,8 @@ pub fn run() {
|
||||
commands::wormhole_summary,
|
||||
commands::near_account,
|
||||
commands::near_create_account,
|
||||
commands::near_qtc_deposit_address,
|
||||
commands::near_withdraw_qtc,
|
||||
swap::swap_assets,
|
||||
swap::swap_preview,
|
||||
swap::swap_order_create,
|
||||
|
||||
@@ -12,13 +12,14 @@ use wallet_core::assets::AssetRegistry;
|
||||
use wallet_core::backup::BackupChallenge;
|
||||
use wallet_core::near::NearNetwork;
|
||||
use wallet_core::ports::{
|
||||
FiatRamp, HistoryCache, HistorySource, NearRpc, NearSponsor, OrderStore, ShieldedCache,
|
||||
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};
|
||||
use wallet_data::near::{HttpNearRpc, HttpSponsor, PoaBridge};
|
||||
use wallet_data::ramp::StubRamp;
|
||||
use wallet_data::settings::SettingsStore;
|
||||
use wallet_data::substrate::ChainManager;
|
||||
@@ -66,6 +67,8 @@ pub struct AppState {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -138,6 +141,7 @@ impl AppState {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct NearNetwork {
|
||||
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 {
|
||||
@@ -112,6 +114,41 @@ pub fn usable_terms(
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
||||
/// The NEAR Intents verifier contract.
|
||||
pub const INTENTS: &str = "intents.near";
|
||||
|
||||
@@ -348,7 +385,7 @@ mod tests {
|
||||
b.resize(n, "0");
|
||||
Ok(serde_json::json!(b))
|
||||
}
|
||||
async fn final_block_hash(&self) -> Result<[u8; 32], NearError> {
|
||||
async fn final_block(&self) -> Result<([u8; 32], u64), NearError> {
|
||||
unreachable!()
|
||||
}
|
||||
async fn send(&self, _: &str, _: &[u8]) -> Result<[u8; 32], NearError> {
|
||||
@@ -426,6 +463,37 @@ mod tests {
|
||||
assert!(s.exists && !s.key_on_account);
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -471,7 +539,7 @@ mod tests {
|
||||
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\""
|
||||
"shard_url = \"https://x\"\nserved_shards = []\nfallback = [\"https://y\"]\nsponsor = \"s.near\"\nsponsor_api = \"https://z\"\nbridge_rpc = \"https://w\""
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -21,3 +21,6 @@ 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"
|
||||
|
||||
@@ -352,8 +352,9 @@ pub trait NearRpc: Send + Sync {
|
||||
args: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, NearError>;
|
||||
|
||||
/// A recent final block's hash, which a transaction must name.
|
||||
async fn final_block_hash(&self) -> Result<[u8; 32], 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.
|
||||
@@ -376,6 +377,14 @@ pub trait NearSponsor: Send + Sync {
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +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;
|
||||
|
||||
@@ -274,7 +274,7 @@ impl NearRpc for HttpNearRpc {
|
||||
})
|
||||
}
|
||||
|
||||
async fn final_block_hash(&self) -> Result<[u8; 32], NearError> {
|
||||
async fn final_block(&self) -> Result<([u8; 32], u64), NearError> {
|
||||
let v = self
|
||||
.call(
|
||||
&self.net.any_endpoints(),
|
||||
@@ -282,11 +282,15 @@ impl NearRpc for HttpNearRpc {
|
||||
json!({ "finality": "final" }),
|
||||
)
|
||||
.await?;
|
||||
hash32(
|
||||
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> {
|
||||
@@ -372,6 +376,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(v.as_array().is_some_and(|a| a.len() == 1));
|
||||
assert_eq!(rpc().final_block_hash().await.unwrap().len(), 32);
|
||||
assert!(rpc().final_block().await.unwrap().1 > 219_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,17 @@ export function nearAccount(wallet: string): Promise<NearAccountSummary> {
|
||||
return command<NearAccountSummary>('near_account', { wallet })
|
||||
}
|
||||
|
||||
// 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(wallet: string): Promise<string> {
|
||||
return command<string>('near_qtc_deposit_address', { wallet })
|
||||
}
|
||||
|
||||
export function nearWithdrawQtc(wallet: string, amount: string, to: string): Promise<string> {
|
||||
return command<string>('near_withdraw_qtc', { wallet, amount, to })
|
||||
}
|
||||
|
||||
// Ask the sponsor to create that account. Returns it, read back.
|
||||
export function nearCreateAccount(wallet: string): Promise<NearAccountSummary> {
|
||||
return command<NearAccountSummary>('near_create_account', { wallet })
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { copyText } from '../api/clipboard'
|
||||
import { nearAccount, nearCreateAccount } from '../api/wallet'
|
||||
import { useSession } from '../api/hooks'
|
||||
import {
|
||||
nearAccount,
|
||||
nearCreateAccount,
|
||||
nearQtcDepositAddress,
|
||||
nearWithdrawQtc,
|
||||
} from '../api/wallet'
|
||||
import { formatAmount } from '../lib/format'
|
||||
|
||||
/**
|
||||
@@ -22,6 +30,7 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
|
||||
onSuccess: (made) => qc.setQueryData(['near-account', wallet], made),
|
||||
})
|
||||
const a = account.data
|
||||
const active = !!a?.exists && a.key_on_account
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
@@ -68,8 +77,7 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
|
||||
)}
|
||||
{a.exists && !a.key_on_account && (
|
||||
<p className="error">
|
||||
This account exists but this wallet’s key is not on it, so the wallet cannot use
|
||||
it.
|
||||
This account exists but this wallet’s key is not on it, so the wallet cannot use it.
|
||||
</p>
|
||||
)}
|
||||
{a.exists && (
|
||||
@@ -92,6 +100,7 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{active && <QtcBridgeLegs wallet={wallet} onMoved={() => account.refetch()} />}
|
||||
<div className="row">
|
||||
<button className="button" type="button" onClick={() => copyText(a.account_id)}>
|
||||
Copy account
|
||||
@@ -111,3 +120,115 @@ export function NearIntentsPanel({ wallet }: { wallet: string }) {
|
||||
</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.
|
||||
* Out: a withdrawal to one of this wallet's Quantus accounts, relayed by
|
||||
* the sponsor; the bridge keeps 0.002 QTC.
|
||||
*/
|
||||
function QtcBridgeLegs({ wallet, onMoved }: { wallet: string; onMoved: () => void }) {
|
||||
const navigate = useNavigate()
|
||||
const session = useSession()
|
||||
const mine =
|
||||
session.data?.wallets
|
||||
.find((w) => w.wallet.name === wallet)
|
||||
?.accounts.filter((x) => x.current_scheme) ?? []
|
||||
const [showDeposit, setShowDeposit] = useState(false)
|
||||
const deposit = useQuery({
|
||||
queryKey: ['qtc-deposit-address', wallet],
|
||||
queryFn: () => nearQtcDepositAddress(wallet),
|
||||
enabled: showDeposit,
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const [amount, setAmount] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const withdraw = useMutation({
|
||||
mutationFn: () => nearWithdrawQtc(wallet, 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 }}>
|
||||
This account’s own deposit address on Quantus; it does not change. QTC sent here
|
||||
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>
|
||||
)}
|
||||
<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}>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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