From d063c2872b9ce410193e48c6a2b069b4c464de5b Mon Sep 17 00:00:00 2001 From: Rigidity Date: Wed, 5 Aug 2026 17:16:44 -0400 Subject: [PATCH] Revocable CAT issuance support --- .cursor/rules/generated-artifacts.mdc | 14 +++++ .cursor/rules/validation.mdc | 19 +++++++ crates/sage-api/src/requests/transactions.rs | 4 ++ crates/sage-wallet/src/wallet/cats.rs | 40 +++++++++++++- crates/sage/src/endpoints/transactions.rs | 11 +++- package.json | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/bin/generate-bindings.rs | 10 ++++ src-tauri/src/lib.rs | 46 ++++++++++------ src/bindings.ts | 4 ++ .../confirmations/TokenConfirmation.tsx | 55 ++++++++++++++++--- src/pages/IssueToken.tsx | 53 ++++++++++++++++++ 12 files changed, 228 insertions(+), 30 deletions(-) create mode 100644 .cursor/rules/generated-artifacts.mdc create mode 100644 .cursor/rules/validation.mdc create mode 100644 src-tauri/src/bin/generate-bindings.rs diff --git a/.cursor/rules/generated-artifacts.mdc b/.cursor/rules/generated-artifacts.mdc new file mode 100644 index 000000000..5ae6b7c3b --- /dev/null +++ b/.cursor/rules/generated-artifacts.mdc @@ -0,0 +1,14 @@ +--- +description: Protect generated bindings and release-time translation catalogs +alwaysApply: true +--- + +# Generated Artifacts + +- Never edit `src/bindings.ts` manually. Change its Rust/Specta source definitions instead. +- Never run Sage automatically to regenerate `src/bindings.ts`; Sage is a GUI application. +- Regenerate bindings with `pnpm generate:bindings` when needed; this command must not launch Sage. +- Release builds must never generate or modify `src/bindings.ts`. +- Do not run `pnpm extract` or modify `src/locales/**/*.po` during normal feature work. +- Update `.po` catalogs only when explicitly requested, typically as part of release preparation. +- If a task requires generated artifacts to be refreshed, report that follow-up rather than hand-editing them. diff --git a/.cursor/rules/validation.mdc b/.cursor/rules/validation.mdc new file mode 100644 index 000000000..25497f4cc --- /dev/null +++ b/.cursor/rules/validation.mdc @@ -0,0 +1,19 @@ +--- +description: Required validation after code changes +alwaysApply: true +--- + +# Validation + +Before completing substantive code changes, run the applicable checks for every language changed: + +1. TypeScript formatting: `pnpm prettier:check` +2. TypeScript linting: `pnpm lint` +3. Rust formatting: `cargo fmt --all -- --files-with-diff --check` +4. Rust linting: `cargo clippy --workspace --all-features --all-targets` +5. Relevant targeted tests, expanded to broader test suites when warranted by the change's risk + +- Run both TypeScript checks for TypeScript/TSX changes and both Rust checks for Rust changes. +- Builds, type checks, and IDE diagnostics do not replace formatting or linting. +- Do not claim validation passed unless the corresponding commands ran successfully. +- Report any skipped, blocked, or failing checks, including failures unrelated to the current changes. diff --git a/crates/sage-api/src/requests/transactions.rs b/crates/sage-api/src/requests/transactions.rs index 50576f91d..53a9668d4 100644 --- a/crates/sage-api/src/requests/transactions.rs +++ b/crates/sage-api/src/requests/transactions.rs @@ -210,6 +210,10 @@ pub struct IssueCat { pub ticker: String, /// Initial supply amount pub amount: Amount, + /// Whether the CAT can be revoked by the issuer + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(default = false))] + pub revocable: bool, /// Transaction fee pub fee: Amount, /// Whether to automatically submit the transaction diff --git a/crates/sage-wallet/src/wallet/cats.rs b/crates/sage-wallet/src/wallet/cats.rs index 675eec7dc..af64472de 100644 --- a/crates/sage-wallet/src/wallet/cats.rs +++ b/crates/sage-wallet/src/wallet/cats.rs @@ -10,15 +10,26 @@ impl Wallet { amount: u64, fee: u64, multi_issuance_key: Option, + ) -> Result<(Vec, Bytes32), WalletError> { + self.issue_cat_with_hidden_puzzle_hash(amount, fee, multi_issuance_key, None) + .await + } + + pub async fn issue_cat_with_hidden_puzzle_hash( + &self, + amount: u64, + fee: u64, + multi_issuance_key: Option, + hidden_puzzle_hash: Option, ) -> Result<(Vec, Bytes32), WalletError> { let mut ctx = SpendContext::new(); let issue_cat = if let Some(public_key) = multi_issuance_key { let tail = ctx.curry(EverythingWithSignatureTailArgs::new(public_key))?; let tail_spend = Spend::new(tail, NodePtr::NIL); - Action::issue_cat(tail_spend, None, amount) + Action::issue_cat(tail_spend, hidden_puzzle_hash, amount) } else { - Action::single_issue_cat(None, amount) + Action::single_issue_cat(hidden_puzzle_hash, amount) }; let actions = vec![Action::fee(fee), issue_cat]; let outputs = self.spend(&mut ctx, vec![], &actions).await?; @@ -87,6 +98,31 @@ mod tests { use crate::TestWallet; + #[test(tokio::test)] + async fn test_issue_revocable_cat() -> anyhow::Result<()> { + let mut test = TestWallet::new(1000).await?; + let hidden_puzzle_hash = test.wallet.change_p2_puzzle_hash().await?; + + let (coin_spends, asset_id) = test + .wallet + .issue_cat_with_hidden_puzzle_hash(1000, 0, None, Some(hidden_puzzle_hash)) + .await?; + + test.transact(coin_spends).await?; + test.wait_for_coins().await; + + assert_eq!( + test.wallet + .db + .asset(asset_id) + .await? + .and_then(|asset| asset.hidden_puzzle_hash), + Some(hidden_puzzle_hash) + ); + + Ok(()) + } + #[test(tokio::test)] async fn test_send_cat() -> anyhow::Result<()> { let mut test = TestWallet::new(1500).await?; diff --git a/crates/sage/src/endpoints/transactions.rs b/crates/sage/src/endpoints/transactions.rs index 69a78315f..148a44849 100644 --- a/crates/sage/src/endpoints/transactions.rs +++ b/crates/sage/src/endpoints/transactions.rs @@ -157,8 +157,15 @@ impl Sage { let wallet = self.wallet()?; let amount = parse_amount(req.amount)?; let fee = parse_amount(req.fee)?; + let hidden_puzzle_hash = if req.revocable { + Some(wallet.change_p2_puzzle_hash().await?) + } else { + None + }; - let (coin_spends, asset_id) = wallet.issue_cat(amount, fee, None).await?; + let (coin_spends, asset_id) = wallet + .issue_cat_with_hidden_puzzle_hash(amount, fee, None, hidden_puzzle_hash) + .await?; let mut tx = wallet.db.tx().await?; tx.insert_asset(Asset { @@ -170,7 +177,7 @@ impl Sage { description: None, is_sensitive_content: false, is_visible: true, - hidden_puzzle_hash: None, + hidden_puzzle_hash, kind: AssetKind::Token, }) .await?; diff --git a/package.json b/package.json index 9c407ed5b..e9d90f60f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "vite", "dev:system-apps": "node scripts/dev-system-apps-watch.mjs", + "generate:bindings": "cargo run -p sage-tauri --bin generate-bindings", "generate:bridge-types": "node scripts/generate-bridge-types.mjs", "generate:app-docs": "cargo run -p sage-apps --bin generate_docs", "generate:sdk-types": "pnpm --silent --filter @sage-app/sdk run generate:types && pnpm --silent --filter @sage-system-app/sdk run generate:types", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d75f950f4..0d141b5f4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "sage-tauri" version = "0.12.11" +default-run = "sage-tauri" description = "A next generation Chia wallet." authors = ["Rigidity "] license = "Apache-2.0" diff --git a/src-tauri/src/bin/generate-bindings.rs b/src-tauri/src/bin/generate-bindings.rs new file mode 100644 index 000000000..624460f57 --- /dev/null +++ b/src-tauri/src/bin/generate-bindings.rs @@ -0,0 +1,10 @@ +#[cfg(debug_assertions)] +fn main() { + sage_lib::export_bindings(); +} + +#[cfg(not(debug_assertions))] +fn main() { + eprintln!("Binding generation is disabled in release builds."); + std::process::exit(1); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9d5a189e..f973a3d70 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(all(debug_assertions, not(mobile)))] +use std::path::PathBuf; + use app_state::{AppState, Initialized, RpcTask}; use rustls::crypto::aws_lc_rs::default_provider; use sage::Sage; @@ -150,14 +153,9 @@ macro_rules! sage_commands { }; } -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - default_provider() - .install_default() - .expect("could not install AWS LC provider"); - - #[cfg(not(mobile))] - let builder = Builder::::new() +#[cfg(not(mobile))] +fn specta_builder() -> Builder { + Builder::::new() .error_handling(ErrorHandlingMode::Throw) .commands(sage_commands![ apps::apps_enter_workspace, @@ -183,21 +181,35 @@ pub fn run() { apps::apps_get_auto_update_enabled, apps::apps_set_auto_update_enabled, ]) - .events(collect_events![SyncEvent]); + .events(collect_events![SyncEvent]) +} - #[cfg(mobile)] - let builder = Builder::::new() - .error_handling(ErrorHandlingMode::Throw) - .commands(sage_commands![]) - .events(collect_events![SyncEvent]); +#[cfg(all(debug_assertions, not(mobile)))] +pub fn export_bindings() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../src/bindings.ts"); - #[cfg(all(debug_assertions, not(mobile)))] - builder + specta_builder() .export( Typescript::default().bigint(BigIntExportBehavior::Number), - "../src/bindings.ts", + path, ) .expect("Failed to export TypeScript bindings"); +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + default_provider() + .install_default() + .expect("could not install AWS LC provider"); + + #[cfg(not(mobile))] + let builder = specta_builder(); + + #[cfg(mobile)] + let builder = Builder::::new() + .error_handling(ErrorHandlingMode::Throw) + .commands(sage_commands![]) + .events(collect_events![SyncEvent]); let mut tauri_builder = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) diff --git a/src/bindings.ts b/src/bindings.ts index e6ffc5735..07813430f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1803,6 +1803,10 @@ ticker: string; * Initial supply amount */ amount: Amount; +/** + * Whether the CAT can be revoked by the issuer + */ +revocable?: boolean; /** * Transaction fee */ diff --git a/src/components/confirmations/TokenConfirmation.tsx b/src/components/confirmations/TokenConfirmation.tsx index dad144191..c5f0837de 100644 --- a/src/components/confirmations/TokenConfirmation.tsx +++ b/src/components/confirmations/TokenConfirmation.tsx @@ -1,15 +1,20 @@ import { CoinRecord } from '@/bindings'; import { CopyButton } from '@/components/CopyButton'; +import { MemoDisplay } from '@/components/MemoDisplay.tsx'; +import { formatNumber } from '@/i18n.ts'; import { fromMojos } from '@/lib/utils'; +import { formatMemo, Memo } from '@/types/CoinMemo.ts'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { CoinsIcon, MergeIcon, SplitIcon } from 'lucide-react'; +import { + CoinsIcon, + MergeIcon, + SplitIcon, + TriangleAlertIcon, +} from 'lucide-react'; import { toast } from 'react-toastify'; -import { formatNumber } from '@/i18n.ts'; import { ConfirmationAlert } from './ConfirmationAlert'; import { ConfirmationCard } from './ConfirmationCard'; -import { formatMemo, Memo } from '@/types/CoinMemo.ts'; -import { MemoDisplay } from '@/components/MemoDisplay.tsx'; type TokenOperationType = | 'split' @@ -27,6 +32,7 @@ interface TokenConfirmationProps { precision?: number; name?: string; amount?: string; + revocable?: boolean; currentMemo?: Memo; } @@ -38,6 +44,7 @@ export function TokenConfirmation({ precision, name, amount, + revocable, currentMemo, }: TokenConfirmationProps) { const config = { @@ -68,10 +75,18 @@ export function TokenConfirmation({ title: Token Issuance, variant: 'info' as const, message: ( - - You are issuing a new token. This will create a CAT (Chia Asset Token) - that can be sent to other users and traded on exchanges. - + <> + + You are issuing a new token. This will create a CAT (Chia Asset + Token) that can be sent to other users and traded on exchanges. + {' '} + {revocable && ( + + This wallet's change address will be used as the revocation + address. + + )} + ), }, clawback: { @@ -124,6 +139,19 @@ export function TokenConfirmation({ )} + {type === 'issue' && revocable && ( + Revocable CAT Warning} + variant='warning' + > + + Only issue a revocable CAT if you understand the risks. Sage cannot + revoke it; revocation currently requires external tools. + + + )} + {type === 'send' && currentMemo && (
@@ -144,7 +172,7 @@ export function TokenConfirmation({ icon={} title={name} > -
+
Ticker @@ -166,6 +194,15 @@ export function TokenConfirmation({ {ticker}
+ +
+
+ Revocable +
+
+ {revocable ? Yes : No} +
+
)} diff --git a/src/pages/IssueToken.tsx b/src/pages/IssueToken.tsx index 82b5ffd26..8b00ffef6 100644 --- a/src/pages/IssueToken.tsx +++ b/src/pages/IssueToken.tsx @@ -1,6 +1,7 @@ import ConfirmationDialog from '@/components/ConfirmationDialog'; import { TokenConfirmation } from '@/components/confirmations/TokenConfirmation'; import Header from '@/components/Header'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Form, @@ -11,13 +12,16 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { FeeAmountInput, TokenAmountInput } from '@/components/ui/masked-input'; +import { Switch } from '@/components/ui/switch'; import { useErrors } from '@/hooks/useErrors'; import { amount, positiveAmount } from '@/lib/formTypes'; import { toMojos } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; +import { TriangleAlertIcon } from 'lucide-react'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; @@ -37,10 +41,14 @@ export default function IssueToken() { ticker: z.string().min(1, t`Ticker is required`), amount: positiveAmount(3), fee: amount(walletState.sync.unit.precision).optional(), + revocable: z.boolean(), }); const form = useForm>({ resolver: zodResolver(formSchema), + defaultValues: { + revocable: false, + }, }); const onSubmit = (values: z.infer) => { @@ -49,6 +57,7 @@ export default function IssueToken() { name: values.name, ticker: values.ticker, amount: toMojos(values.amount.toString(), 3), + revocable: values.revocable, fee: toMojos( values.fee?.toString() || '0', walletState.sync.unit.precision, @@ -147,6 +156,49 @@ export default function IssueToken() { />
+ ( + +
+ +

+ + Use this wallet's change address as the + token's revocation address. + +

+
+ + + +
+ )} + /> + + {form.watch('revocable') && ( + + + )} + @@ -171,6 +223,7 @@ export default function IssueToken() { name={form.getValues().name} ticker={form.getValues().ticker} amount={form.getValues().amount.toString()} + revocable={form.getValues().revocable} /> ), }