Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/sage-api/endpoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"get_dids": true,
"get_minter_did_ids": true,
"get_pending_transactions": true,
"get_transaction": true,
"get_transactions": true,
"get_nft_collections": true,
"get_nft_collection": true,
Expand Down
1 change: 1 addition & 0 deletions crates/sage-api/src/records/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ pub struct TransactionRecordCoin {
pub address_kind: AddressKind,
#[serde(flatten)]
pub kind: AssetKind,
pub precision: u8,
}
12 changes: 12 additions & 0 deletions crates/sage-api/src/requests/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,18 @@ pub struct GetMinterDidIdsResponse {
#[cfg_attr(feature = "tauri", derive(specta::Type))]
pub struct GetPendingTransactions {}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "tauri", derive(specta::Type))]
pub struct GetTransaction {
pub height: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "tauri", derive(specta::Type))]
pub struct GetTransactionResponse {
pub transaction: Option<TransactionRecord>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "tauri", derive(specta::Type))]
pub struct GetPendingTransactionsResponse {
Expand Down
2 changes: 1 addition & 1 deletion crates/sage-api/src/types/asset_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AssetKind {
Unknown,
Xch,
Xch, //TODO: remove this and replace Cat with Token
Launcher,
Cat {
asset_id: String,
Expand Down
46 changes: 40 additions & 6 deletions crates/sage-database/src/tables/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub struct TransactionCoin {
pub asset: Asset,
pub p2_puzzle_hash: Option<Bytes32>,
pub ticker: Option<String>,
pub precision: u8,
}

impl Database {
Expand Down Expand Up @@ -67,6 +68,7 @@ fn create_transaction_coin(row: &sqlx::sqlite::SqliteRow) -> Result<TransactionC
asset,
p2_puzzle_hash,
ticker: row.get::<Option<String>, _>("ticker"),
precision: row.get::<Option<u8>, _>("precision").unwrap_or(1),
})
}

Expand All @@ -90,7 +92,8 @@ async fn transaction(conn: impl SqliteExecutor<'_>, height: u32) -> Result<Optio
asset_icon_url,
asset_kind,
p2_puzzle_hash,
ticker
ticker,
precision
FROM transaction_coins
WHERE height = ?",
height
Expand Down Expand Up @@ -133,6 +136,7 @@ async fn transaction(conn: impl SqliteExecutor<'_>, height: u32) -> Result<Optio
asset,
p2_puzzle_hash: row.p2_puzzle_hash.convert()?,
ticker: row.ticker,
precision: row.precision.unwrap_or(1) as u8,
};

// these represent whether the coins was spent and/or created in this block
Expand Down Expand Up @@ -180,17 +184,37 @@ async fn transactions(
asset_kind,
p2_puzzle_hash,
ticker,
precision,
COUNT(*) OVER() as total_count
FROM transaction_coins
WHERE 1=1",
);

if let Some(find_value) = find_value {
query.push(" AND (asset_name LIKE %");
query.push_bind(find_value.clone());
query.push("% OR ticker LIKE %");
query.push_bind(find_value);
query.push("%)");
query.push(" AND (asset_name LIKE ");
query.push_bind(format!("%{find_value}%"));
query.push(" OR ticker LIKE ");
query.push_bind(format!("%{find_value}%"));

if is_valid_asset_id(&find_value) {
query.push(" OR asset_hash = X'");
query.push(find_value.clone());
query.push("'");
}

// match on nft or did launcher id
if let Some(puzzle_hash) = puzzle_hash_from_address(&find_value) {
query.push(" OR asset_hash = X'");
query.push(puzzle_hash);
query.push("'");
}

// match on height if the find value is parsable as a u32
if let Ok(height) = find_value.parse::<u32>() {
query.push(" OR height = ");
query.push_bind(height);
}
query.push(")");
}

if sort_ascending {
Expand All @@ -212,6 +236,16 @@ async fn transactions(
Ok((transactions, total_count as u32))
}

pub fn is_valid_asset_id(asset_id: &str) -> bool {
asset_id.len() == 64 && asset_id.chars().all(|c| c.is_ascii_hexdigit())
}

fn puzzle_hash_from_address(address: &str) -> Option<String> {
chia_wallet_sdk::utils::Address::decode(address)
.map(|decoded| hex::encode(decoded.puzzle_hash.as_ref()))
.ok()
}

// Helper function to group rows by height and create Transaction structs
fn group_rows_into_transactions(rows: Vec<sqlx::sqlite::SqliteRow>) -> Result<Vec<Transaction>> {
use std::collections::HashMap;
Expand Down
22 changes: 18 additions & 4 deletions crates/sage/src/endpoints/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ use sage_api::{
GetNftCollectionsResponse, GetNftData, GetNftDataResponse, GetNftIcon, GetNftIconResponse,
GetNftResponse, GetNftThumbnail, GetNftThumbnailResponse, GetNfts, GetNftsResponse,
GetPendingTransactions, GetPendingTransactionsResponse, GetSpendableCoinCount,
GetSpendableCoinCountResponse, GetSyncStatus, GetSyncStatusResponse, GetTransactions,
GetTransactionsResponse, GetVersion, GetVersionResponse, GetXchCoins, GetXchCoinsResponse,
NftCollectionRecord, NftData, NftRecord, NftSortMode as ApiNftSortMode,
PendingTransactionRecord, TransactionRecord, TransactionRecordCoin,
GetSpendableCoinCountResponse, GetSyncStatus, GetSyncStatusResponse, GetTransaction,
GetTransactionResponse, GetTransactions, GetTransactionsResponse, GetVersion,
GetVersionResponse, GetXchCoins, GetXchCoinsResponse, NftCollectionRecord, NftData, NftRecord,
NftSortMode as ApiNftSortMode, PendingTransactionRecord, TransactionRecord,
TransactionRecordCoin,
};
use sage_database::{
AssetFilter, AssetKind as DatabaseAssetKind, CoinFilterMode, CoinSortMode, NftAsset,
Expand Down Expand Up @@ -365,6 +366,18 @@ impl Sage {
Ok(GetPendingTransactionsResponse { transactions })
}

pub async fn get_transaction(&self, req: GetTransaction) -> Result<GetTransactionResponse> {
let wallet = self.wallet()?;

let transaction = wallet.db.transaction(req.height).await?;

let transaction = transaction
.map(|row| self.transaction_record(row))
.transpose()?;

Ok(GetTransactionResponse { transaction })
}

pub async fn get_transactions(&self, req: GetTransactions) -> Result<GetTransactionsResponse> {
let wallet = self.wallet()?;

Expand Down Expand Up @@ -769,6 +782,7 @@ impl Sage {
address_kind,
amount: Amount::u64(amount),
kind,
precision: transaction_coin.precision,
})
}

Expand Down
28 changes: 14 additions & 14 deletions migrations/0001_tables.sql
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ CREATE TABLE rust_migrations (
version INTEGER NOT NULL PRIMARY KEY
);

CREATE TABLE collections (
id INTEGER NOT NULL PRIMARY KEY,
hash BLOB NOT NULL UNIQUE,
uuid TEXT NOT NULL,
minter_hash BLOB NOT NULL,
name TEXT,
icon_url TEXT,
banner_url TEXT,
description TEXT,
is_visible BOOLEAN NOT NULL,
created_height INTEGER
);

/*
* A single table that represents all kinds of supported assets on the Chia blockchain:
* Token = 0
Expand Down Expand Up @@ -66,7 +79,7 @@ CREATE TABLE nfts (
license_hash BLOB,
edition_number INTEGER,
edition_total INTEGER,
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE SET NULL,
FOREIGN KEY (collection_id) REFERENCES collections(id) ON DELETE SET DEFAULT,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
);

Expand Down Expand Up @@ -260,19 +273,6 @@ CREATE TABLE mempool_spends (
UNIQUE(mempool_item_id, coin_hash)
);

CREATE TABLE collections (
id INTEGER NOT NULL PRIMARY KEY,
hash BLOB NOT NULL UNIQUE,
uuid TEXT NOT NULL,
minter_hash BLOB NOT NULL,
name TEXT,
icon_url TEXT,
banner_url TEXT,
description TEXT,
is_visible BOOLEAN NOT NULL,
created_height INTEGER
);

CREATE TABLE files (
id INTEGER NOT NULL PRIMARY KEY,
hash BLOB NOT NULL UNIQUE,
Expand Down
3 changes: 2 additions & 1 deletion migrations/0004_views.sql
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ SELECT
assets.is_visible AS asset_is_visible,
assets.is_sensitive_content AS asset_is_sensitive_content,
assets.created_height AS asset_created_height,
tokens.ticker
tokens.ticker,
tokens.precision
FROM blocks
LEFT JOIN coins ON coins.created_height = blocks.height OR coins.spent_height = blocks.height
INNER JOIN assets ON assets.id = coins.asset_id
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub fn run() {
commands::get_nft_icon,
commands::get_nft_thumbnail,
commands::get_pending_transactions,
commands::get_transaction,
commands::get_transactions,
commands::validate_address,
commands::make_offer,
Expand Down
7 changes: 6 additions & 1 deletion src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ async getNftThumbnail(req: GetNftThumbnail) : Promise<GetNftThumbnailResponse> {
async getPendingTransactions(req: GetPendingTransactions) : Promise<GetPendingTransactionsResponse> {
return await TAURI_INVOKE("get_pending_transactions", { req });
},
async getTransaction(req: GetTransaction) : Promise<GetTransactionResponse> {
return await TAURI_INVOKE("get_transaction", { req });
},
async getTransactions(req: GetTransactions) : Promise<GetTransactionsResponse> {
return await TAURI_INVOKE("get_transactions", { req });
},
Expand Down Expand Up @@ -430,6 +433,8 @@ export type GetSpendableCoinCount = { asset_id: string }
export type GetSpendableCoinCountResponse = { count: number }
export type GetSyncStatus = Record<string, never>
export type GetSyncStatusResponse = { balance: Amount; unit: Unit; synced_coins: number; total_coins: number; receive_address: string; burn_address: string; unhardened_derivation_index: number; hardened_derivation_index: number; checked_uris: number; total_uris: number; database_size: number }
export type GetTransaction = { height: number }
export type GetTransactionResponse = { transaction: TransactionRecord | null }
export type GetTransactions = { offset: number; limit: number; ascending: boolean; find_value: string | null }
export type GetTransactionsResponse = { transactions: TransactionRecord[]; total: number }
export type GetVersion = Record<string, never>
Expand Down Expand Up @@ -515,7 +520,7 @@ export type TakeOfferResponse = { summary: TransactionSummary; spend_bundle: Spe
export type TransactionInput = ({ type: "unknown" } | { type: "xch" } | { type: "launcher" } | { type: "cat"; asset_id: string; name: string | null; ticker: string | null; icon_url: string | null } | { type: "did"; launcher_id: string; name: string | null } | { type: "nft"; launcher_id: string; icon: string | null; name: string | null } | { type: "option" }) & { coin_id: string; amount: Amount; address: string; outputs: TransactionOutput[] }
export type TransactionOutput = { coin_id: string; amount: Amount; address: string; receiving: boolean; burning: boolean }
export type TransactionRecord = { height: number; timestamp: number | null; spent: TransactionRecordCoin[]; created: TransactionRecordCoin[] }
export type TransactionRecordCoin = ({ type: "unknown" } | { type: "xch" } | { type: "launcher" } | { type: "cat"; asset_id: string; name: string | null; ticker: string | null; icon_url: string | null } | { type: "did"; launcher_id: string; name: string | null } | { type: "nft"; launcher_id: string; icon: string | null; name: string | null } | { type: "option" }) & { coin_id: string; amount: Amount; address: string | null; address_kind: AddressKind }
export type TransactionRecordCoin = ({ type: "unknown" } | { type: "xch" } | { type: "launcher" } | { type: "cat"; asset_id: string; name: string | null; ticker: string | null; icon_url: string | null } | { type: "did"; launcher_id: string; name: string | null } | { type: "nft"; launcher_id: string; icon: string | null; name: string | null } | { type: "option" }) & { coin_id: string; amount: Amount; address: string | null; address_kind: AddressKind; precision: number }
export type TransactionResponse = { summary: TransactionSummary; coin_spends: CoinSpendJson[] }
export type TransactionSummary = { fee: Amount; inputs: TransactionInput[] }
export type TransferDids = { did_ids: string[]; address: string; fee: Amount; auto_submit?: boolean }
Expand Down
6 changes: 4 additions & 2 deletions src/components/AmountCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { NumberFormat } from './NumberFormat';
interface AmountCellProps {
amount: Amount;
type: AssetCoinType;
precision?: number;
}

export function AmountCell({ amount, type }: AmountCellProps) {
export function AmountCell({ amount, type, precision }: AmountCellProps) {
const walletState = useWalletState();
const amountNum = BigNumber(amount);
const decimals = type === 'cat' ? 3 : walletState.sync.unit.decimals;
const decimals =
precision ?? (type === 'cat' ? 3 : walletState.sync.unit.decimals);

return (
<div className='whitespace-nowrap'>
Expand Down
7 changes: 6 additions & 1 deletion src/components/TransactionColumns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface FlattenedTransaction {
itemId: string;
displayName: string;
timestamp: number | null;
precision: number;
}

export const columns: ColumnDef<FlattenedTransaction>[] = [
Expand Down Expand Up @@ -100,7 +101,11 @@ export const columns: ColumnDef<FlattenedTransaction>[] = [
enableSorting: false,
size: 120,
cell: ({ row }) => (
<AmountCell amount={row.getValue('amount')} type={row.getValue('type')} />
<AmountCell
amount={row.getValue('amount')}
type={row.getValue('type')}
precision={row.original.precision}
/>
),
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/components/TransactionListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function TransactionListView({
transactionHeight: transaction.height,
iconUrl: getIconUrl(coin),
timestamp: transaction.timestamp,
precision: coin.precision,
}));

const spent: FlattenedTransaction[] = transaction.spent.map((coin) => ({
Expand All @@ -86,6 +87,7 @@ export function TransactionListView({
transactionHeight: transaction.height,
iconUrl: getIconUrl(coin),
timestamp: transaction.timestamp,
precision: coin.precision,
}));

if (!summarized) {
Expand Down
37 changes: 7 additions & 30 deletions src/pages/Transaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { toast } from 'react-toastify';

// TODO: come through here and reduce or eliminate xch vs cat
export default function Transaction() {
const { height } = useParams();

Expand All @@ -28,15 +29,12 @@ export default function Transaction() {

const updateTransaction = useCallback(() => {
commands
.getTransactions({
offset: 0,
limit: 1,
ascending: true,
find_value: height ?? '',
.getTransaction({
height: Number(height),
})
.then((data) => {
if (data.transactions.length > 0) {
setTransaction(data.transactions[0]);
if (data.transaction) {
setTransaction(data.transaction);
} else {
setTransaction(null);
}
Expand Down Expand Up @@ -146,27 +144,6 @@ function TransactionCoinKind({ coin }: TransactionCoinKindProps) {
const walletState = useWalletState();

switch (coin.type) {
case 'xch': {
return (
<div className='flex items-center gap-2'>
<img
alt={t`XCH`}
src='https://icons.dexie.space/xch.webp'
className='w-8 h-8'
aria-hidden={true}
/>

<div className='text-md text-neutral-700 dark:text-neutral-300 break-all'>
<NumberFormat
value={fromMojos(coin.amount, walletState.sync.unit.decimals)}
minimumFractionDigits={0}
maximumFractionDigits={walletState.sync.unit.decimals}
/>{' '}
<span className='break-normal'>{walletState.sync.unit.ticker}</span>
</div>
</div>
);
}
case 'cat': {
return (
<div className='flex items-center gap-2'>
Expand All @@ -180,9 +157,9 @@ function TransactionCoinKind({ coin }: TransactionCoinKindProps) {
<div className='flex flex-col'>
<div className='text-md text-neutral-700 dark:text-neutral-300 break-all'>
<NumberFormat
value={fromMojos(coin.amount, 3)}
value={fromMojos(coin.amount, coin.precision)}
minimumFractionDigits={0}
maximumFractionDigits={3}
maximumFractionDigits={coin.precision}
/>{' '}
<span className='break-normal'>
{coin.ticker ?? coin.name ?? 'CAT'}
Expand Down
Loading