From 747a72b516f3466314b419e081fc82368ffeaa14 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Tue, 29 Oct 2024 12:33:42 -0400 Subject: [PATCH] Add simplified and JSON bundle views --- .../src/records/transaction_summary.rs | 22 +- package.json | 1 + pnpm-lock.yaml | 32 ++ src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/transactions.rs | 125 ++++++-- src-tauri/src/error.rs | 9 + src-tauri/src/lib.rs | 8 +- src/bindings.ts | 17 +- src/components/ConfirmationDialog.tsx | 284 +++++++++++++----- src/components/ui/tabs.tsx | 53 ++++ 10 files changed, 449 insertions(+), 104 deletions(-) create mode 100644 src/components/ui/tabs.tsx diff --git a/crates/sage-api/src/records/transaction_summary.rs b/crates/sage-api/src/records/transaction_summary.rs index 46b0af54..a2103542 100644 --- a/crates/sage-api/src/records/transaction_summary.rs +++ b/crates/sage-api/src/records/transaction_summary.rs @@ -7,7 +7,27 @@ use crate::Amount; pub struct TransactionSummary { pub fee: Amount, pub inputs: Vec, - pub data: String, + pub coin_spends: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct SpendBundleJson { + pub coin_spends: Vec, + pub aggregated_signature: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct CoinSpendJson { + pub coin: CoinJson, + pub puzzle_reveal: String, + pub solution: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct CoinJson { + pub parent_coin_info: String, + pub puzzle_hash: String, + pub amount: u64, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/package.json b/package.json index 8fa7e561..b3faf0c9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", "@tanstack/react-table": "^8.20.5", "@tauri-apps/api": "2.0.0-rc.4", "@tauri-apps/plugin-clipboard-manager": "2.0.0-rc.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5140dba..29f3ffa0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@radix-ui/react-switch': specifier: ^1.1.0 version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tanstack/react-table': specifier: ^8.20.5 version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -858,6 +861,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tabs@1.1.1': + resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -3002,6 +3018,22 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-tabs@1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1e2647bb..859f77e2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,7 +47,7 @@ bigdecimal = { workspace = true } bech32 = { workspace = true } base64 = { workspace = true } tauri-plugin-shell = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["arbitrary_precision"] } # This is to ensure that the bindgen feature is enabled for the aws-lc-rs crate. # https://aws.github.io/aws-lc-rs/platform_support.html#tested-platforms diff --git a/src-tauri/src/commands/transactions.rs b/src-tauri/src/commands/transactions.rs index 6ae867c4..464bb020 100644 --- a/src-tauri/src/commands/transactions.rs +++ b/src-tauri/src/commands/transactions.rs @@ -2,15 +2,16 @@ use std::{collections::HashMap, time::Duration}; use base64::prelude::*; use chia::{ - protocol::{Bytes32, CoinSpend}, + bls::Signature, + protocol::{Bytes32, Coin, CoinSpend, SpendBundle}, puzzles::nft::NftMetadata, - traits::Streamable, }; use chia_wallet_sdk::{ decode_address, encode_address, AggSigConstants, MAINNET_CONSTANTS, TESTNET11_CONSTANTS, }; use sage_api::{ - Amount, BulkMintNfts, BulkMintNftsResponse, Input, InputKind, Output, TransactionSummary, + Amount, BulkMintNfts, BulkMintNftsResponse, CoinJson, CoinSpendJson, Input, InputKind, Output, + SpendBundleJson, TransactionSummary, }; use sage_database::{CatRow, Database}; use sage_wallet::{ @@ -523,25 +524,13 @@ pub async fn transfer_nft( #[command] #[specta] -pub async fn submit_transaction(state: State<'_, AppState>, data: String) -> Result<()> { +pub async fn sign_transaction( + state: State<'_, AppState>, + coin_spends: Vec, +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; - let data = hex::decode(data)?; - let coin_spends = Vec::::from_bytes_unchecked(&data)?; - submit(&state, &wallet, coin_spends).await -} - -#[derive(Default)] -struct ConfirmationInfo { - did_names: HashMap, - nft_data: HashMap, -} -async fn submit( - state: &MutexGuard<'_, AppStateInner>, - wallet: &Wallet, - coin_spends: Vec, -) -> Result<()> { let (_mnemonic, Some(master_sk)) = state.keychain.extract_secrets(wallet.fingerprint, b"")? else { return Err(Error::no_secret_key()); @@ -549,7 +538,7 @@ async fn submit( let spend_bundle = wallet .sign_transaction( - coin_spends, + coin_spends.iter().map(rust_spend).collect::>()?, &if state.config.network.network_id == "mainnet" { AggSigConstants::new(MAINNET_CONSTANTS.agg_sig_me_additional_data) } else { @@ -559,19 +548,39 @@ async fn submit( ) .await?; - wallet.insert_transaction(spend_bundle).await?; + Ok(json_bundle(&spend_bundle)) +} + +#[command] +#[specta] +pub async fn submit_transaction( + state: State<'_, AppState>, + spend_bundle: SpendBundleJson, +) -> Result<()> { + let state = state.lock().await; + let wallet = state.wallet()?; + + wallet + .insert_transaction(rust_bundle(&spend_bundle)?) + .await?; Ok(()) } +#[derive(Default)] +struct ConfirmationInfo { + did_names: HashMap, + nft_data: HashMap, +} + async fn summarize( state: &MutexGuard<'_, AppStateInner>, wallet: &Wallet, coin_spends: Vec, cache: ConfirmationInfo, ) -> Result { - let data = coin_spends.to_bytes()?; - let transaction = Transaction::from_coin_spends(coin_spends).map_err(WalletError::Parse)?; + let transaction = + Transaction::from_coin_spends(coin_spends.clone()).map_err(WalletError::Parse)?; let mut inputs = Vec::with_capacity(transaction.inputs.len()); @@ -670,7 +679,7 @@ async fn summarize( Ok(TransactionSummary { fee: Amount::from_mojos(transaction.fee as u128, state.unit.decimals), inputs, - data: hex::encode(data), + coin_spends: coin_spends.iter().map(json_spend).collect(), }) } @@ -724,3 +733,71 @@ async fn extract_nft_data( Ok(result) } + +fn json_bundle(spend_bundle: &SpendBundle) -> SpendBundleJson { + SpendBundleJson { + coin_spends: spend_bundle.coin_spends.iter().map(json_spend).collect(), + aggregated_signature: format!( + "0x{}", + hex::encode(spend_bundle.aggregated_signature.to_bytes()) + ), + } +} + +fn json_spend(coin_spend: &CoinSpend) -> CoinSpendJson { + CoinSpendJson { + coin: json_coin(&coin_spend.coin), + puzzle_reveal: hex::encode(&coin_spend.puzzle_reveal), + solution: hex::encode(&coin_spend.solution), + } +} + +fn json_coin(coin: &Coin) -> CoinJson { + CoinJson { + parent_coin_info: format!("0x{}", hex::encode(coin.parent_coin_info)), + puzzle_hash: format!("0x{}", hex::encode(coin.puzzle_hash)), + amount: coin.amount, + } +} + +fn rust_bundle(spend_bundle: &SpendBundleJson) -> Result { + Ok(SpendBundle { + coin_spends: spend_bundle + .coin_spends + .iter() + .map(rust_spend) + .collect::>()?, + aggregated_signature: Signature::from_bytes(&decode_hex_sized( + &spend_bundle.aggregated_signature, + )?)?, + }) +} + +fn rust_spend(coin_spend: &CoinSpendJson) -> Result { + Ok(CoinSpend { + coin: rust_coin(&coin_spend.coin)?, + puzzle_reveal: decode_hex(&coin_spend.puzzle_reveal)?.into(), + solution: decode_hex(&coin_spend.solution)?.into(), + }) +} + +fn rust_coin(coin: &CoinJson) -> Result { + Ok(Coin { + parent_coin_info: decode_hex_sized(&coin.parent_coin_info)?.into(), + puzzle_hash: decode_hex_sized(&coin.puzzle_hash)?.into(), + amount: coin.amount, + }) +} + +fn decode_hex(hex: &str) -> Result> { + if let Some(stripped) = hex.strip_prefix("0x") { + Ok(hex::decode(stripped)?) + } else { + Ok(hex::decode(hex)?) + } +} + +fn decode_hex_sized(hex: &str) -> Result<[u8; N]> { + let bytes = decode_hex(hex)?; + Ok(bytes.try_into()?) +} diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index b9c5b1cb..e3ef31b5 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -444,3 +444,12 @@ impl From for Error { } } } + +impl From> for Error { + fn from(value: Vec) -> Self { + Self { + kind: ErrorKind::Serialization, + reason: format!("Unexpected bytes with length {}", value.len()), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3fcbf61b..5e4f4a3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,7 @@ mod error; mod models; #[cfg(all(debug_assertions, not(mobile)))] -use specta_typescript::Typescript; +use specta_typescript::{BigIntExportBehavior, Typescript}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -70,6 +70,7 @@ pub fn run() { commands::create_did, commands::bulk_mint_nfts, commands::transfer_nft, + commands::sign_transaction, commands::submit_transaction, // Peers commands::get_peers, @@ -81,7 +82,10 @@ pub fn run() { // On mobile or release mode we should not export the TypeScript bindings #[cfg(all(debug_assertions, not(mobile)))] builder - .export(Typescript::default(), "../src/bindings.ts") + .export( + Typescript::default().bigint(BigIntExportBehavior::Number), + "../src/bindings.ts", + ) .expect("Failed to export TypeScript bindings"); tauri::Builder::default() diff --git a/src/bindings.ts b/src/bindings.ts index 7b670293..c896bc1f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -365,9 +365,17 @@ async transferNft(nftId: string, address: string, fee: Amount) : Promise> { +async signTransaction(coinSpends: CoinSpendJson[]) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("submit_transaction", { data }) }; + return { status: "ok", data: await TAURI_INVOKE("sign_transaction", { coinSpends }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async submitTransaction(spendBundle: SpendBundleJson) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("submit_transaction", { spendBundle }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -418,7 +426,9 @@ export type Amount = string export type BulkMintNfts = { nft_mints: NftMint[]; did_id: string; fee: Amount } export type BulkMintNftsResponse = { nft_ids: string[]; summary: TransactionSummary } export type CatRecord = { asset_id: string; name: string | null; ticker: string | null; description: string | null; icon_url: string | null; visible: boolean; balance: Amount } +export type CoinJson = { parent_coin_info: string; puzzle_hash: string; amount: number } export type CoinRecord = { coin_id: string; address: string; amount: Amount; created_height: number | null; spent_height: number | null; create_transaction_id: string | null; spend_transaction_id: string | null } +export type CoinSpendJson = { coin: CoinJson; puzzle_reveal: string; solution: string } export type DidRecord = { launcher_id: string; name: string | null; visible: boolean; coin_id: string; address: string; amount: Amount; created_height: number | null; create_transaction_id: string | null } export type Error = { kind: ErrorKind; reason: string } export type ErrorKind = "Io" | "Database" | "Client" | "Keychain" | "Logging" | "Serialization" | "InvalidAddress" | "InvalidMnemonic" | "InvalidKey" | "InvalidAmount" | "InvalidRoyalty" | "InvalidAssetId" | "InvalidLauncherId" | "InsufficientFunds" | "TransactionFailed" | "UnknownNetwork" | "UnknownFingerprint" | "NotLoggedIn" | "Sync" | "Wallet" @@ -432,9 +442,10 @@ export type NftRecord = { launcher_id: string; launcher_id_hex: string; owner_di export type Output = { coin_id: string; amount: Amount; address: string; receiving: boolean } export type PeerRecord = { ip_addr: string; port: number; trusted: boolean; peak_height: number } export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: string | null } +export type SpendBundleJson = { coin_spends: CoinSpendJson[]; aggregated_signature: string } export type SyncEvent = { type: "start"; ip: string } | { type: "stop" } | { type: "subscribed" } | { type: "derivation" } | { type: "coin_state" } | { type: "puzzle_batch_synced" } | { type: "cat_info" } | { type: "did_info" } | { type: "nft_data" } export type SyncStatus = { balance: Amount; unit: Unit; synced_coins: number; total_coins: number; receive_address: string } -export type TransactionSummary = { fee: Amount; inputs: Input[]; data: string } +export type TransactionSummary = { fee: Amount; inputs: Input[]; coin_spends: CoinSpendJson[] } export type Unit = { ticker: string; decimals: number } export type WalletConfig = { name?: string; derive_automatically?: boolean; derivation_batch_size?: number } export type WalletInfo = { name: string; fingerprint: number; public_key: string; kind: WalletKind } diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 9be380ad..d29aa987 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -8,16 +8,23 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useWalletState } from '@/state'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import BigNumber from 'bignumber.js'; import { BadgeMinus, BadgePlus, + BoxIcon, + CircleAlert, + CopyCheckIcon, + CopyIcon, ForwardIcon, LoaderCircleIcon, } from 'lucide-react'; import { PropsWithChildren, useState } from 'react'; import { commands, Error, TransactionSummary } from '../bindings'; +import { Alert, AlertDescription, AlertTitle } from './ui/alert'; import { Badge } from './ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; export interface ConfirmationDialogProps { summary: TransactionSummary | null; @@ -49,6 +56,13 @@ export default function ConfirmationDialog({ const walletState = useWalletState(); const [pending, setPending] = useState(false); + const [signature, setSignature] = useState(null); + + const reset = () => { + setPending(false); + setSignature(null); + close(); + }; const spent: Array = []; const created: Array = []; @@ -177,103 +191,199 @@ export default function ConfirmationDialog({ } } + const json = JSON.stringify( + { + coin_spends: summary?.coin_spends, + aggregated_signature: + signature || + '0xc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + }, + null, + 2, + ); + + const [jsonCopied, setJsonCopied] = useState(false); + + const copyJson = () => { + writeText(json); + + setJsonCopied(true); + setTimeout(() => setJsonCopied(false), 2000); + }; + return ( - { - close(); - setPending(false); - }} - > + Confirm transaction? -
-
- -
- {spent - .sort((a, b) => a.sort - b.sort) - .map((spent, i) => ( -
-
- - {spent.badge} - - {spent.label} -
-
- -
- {spent.coinId} -
-
-
- ))} -
-
- -
- {!BigNumber(summary?.fee || 0).isZero() && ( -
-
- Fee - - {summary?.fee} {walletState.sync.unit.ticker} - -
+
+ + + + Summary + + + Advanced + + + JSON + + + + +
+ {!BigNumber(summary?.fee || 0).isZero() && ( + + )} + {created + .filter((created) => created.address !== 'Change') + .sort((a, b) => a.sort - b.sort) + .map((created, i) => ( + + ))} +
+
+
+ +
+ +
+ {spent + .sort((a, b) => a.sort - b.sort) + .map((spent, i) => ( + + ))}
- )} - {created - .sort((a, b) => a.sort - b.sort) - .map((created, i) => ( -
-
- - {created.badge} - - {created.label} -
-
- -
- {created.address} + + +
+ {!BigNumber(summary?.fee || 0).isZero() && ( +
+
+ Fee + + {summary?.fee} {walletState.sync.unit.ticker} +
-
- ))} + )} + {created + .sort((a, b) => a.sort - b.sort) + .map((created, i) => ( + + ))} +
+
- -
+ + + + + Warning + + This is the raw JSON spend bundle for this transaction. If + you sign it, the transaction can be submitted to the + mempool externally. + + + + + +
+ {json} + + +
+
+
-
); } + +interface ItemProps { + badge: string; + label: string; + icon?: typeof BadgeMinus; + address?: string; +} + +function Item({ badge, label, icon: Icon, address }: ItemProps) { + return ( +
+
+ + {badge} + + {label} +
+ {Icon && ( +
+ +
+ {address} +
+
+ )} +
+ ); +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 00000000..2d3c9d68 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; + +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent };