diff --git a/.gitignore b/.gitignore index f9601738..804c18cf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist-extension/ # env .env .env.local +.env.fivenorth # typescript *.tsbuildinfo diff --git a/canton-barebones/wallet-service/AGENTS.md b/canton-barebones/wallet-service/AGENTS.md index a847c895..89d4319e 100644 --- a/canton-barebones/wallet-service/AGENTS.md +++ b/canton-barebones/wallet-service/AGENTS.md @@ -4,7 +4,7 @@ This file applies only to `canton-barebones/wallet-service/`. For monorepo-wide ## Scope -The wallet-service is a consumer-dApp-agnostic Express JSON-RPC bridge between Carpincho and the local Canton participant. It holds the Canton bearer token boundary, prepares and executes transactions, proxies participant reads, exposes CIP-56 token-standard reads/transfers and Amulet (Canton Coin) preapproval management, and handles wallet-internal party onboarding. +The wallet-service is a consumer-dApp-agnostic Express JSON-RPC bridge between Carpincho and the local Canton participant. It holds the Canton bearer token boundary, prepares and executes transactions, proxies participant reads, exposes CIP-56 token-standard reads/transfers and Amulet (Canton Coin) preapproval management and DevNet faucet tap, and handles wallet-internal party onboarding. ## Working Rules diff --git a/canton-barebones/wallet-service/README.md b/canton-barebones/wallet-service/README.md index 0956f22b..b90921f3 100644 --- a/canton-barebones/wallet-service/README.md +++ b/canton-barebones/wallet-service/README.md @@ -76,8 +76,9 @@ These add Canton token-standard reads and transfers plus Amulet (Canton Coin) pr | `amulet.preapproval.create` | Prepares enabling Amulet auto-accept. | | `amulet.preapproval.cancel` | Prepares disabling Amulet auto-accept. | | `amulet.preapproval.acceptProposal` | Accepts a `TransferPreapprovalProposal` for the receiver. | +| `amulet.tap` | Prepares the fixed 100 AMT Splice DevNet faucet tap for a receiver (DevNet only). | -The write methods (`create*`, `acceptTransfer`, `amulet.preapproval.create/cancel/acceptProposal`) return prepared transactions; Carpincho signs locally and submits via `executePrepared`. +The write methods (`create*`, `acceptTransfer`, `amulet.preapproval.create/cancel/acceptProposal`, `amulet.tap`) return prepared transactions; Carpincho signs locally and submits via `executePrepared`. `prepareExecute`, `prepareExecuteAndWait`, and `signMessage` stay in Carpincho because they require the user's key and approval UI. diff --git a/canton-barebones/wallet-service/src/mock.ts b/canton-barebones/wallet-service/src/mock.ts index 4b10e0c8..b746b100 100644 --- a/canton-barebones/wallet-service/src/mock.ts +++ b/canton-barebones/wallet-service/src/mock.ts @@ -159,6 +159,11 @@ export const createMockRpc = ( return rpcResult(id, { commands: [], disclosedContracts: [] }) case 'amulet.preapproval.status': return rpcResult(id, { active: false, expired: false }) + case 'amulet.tap': + return rpcResult(id, { + commands: { ExerciseCommand: { choice: 'AmuletRules_DevNet_Tap' } }, + disclosedContracts: [], + }) case 'amulet.preapproval.create': case 'amulet.preapproval.cancel': return rpcResult(id, { commands: [], disclosedContracts: [] }) @@ -218,6 +223,7 @@ export const createMockRpc = ( 'cip56.listHoldings', 'cip56.createTransfer', 'amulet.preapproval.status', + 'amulet.tap', 'amulet.preapproval.create', 'amulet.preapproval.acceptProposal', 'amulet.preapproval.cancel', diff --git a/canton-barebones/wallet-service/src/rpc.ts b/canton-barebones/wallet-service/src/rpc.ts index 31c88f63..1fe48a24 100644 --- a/canton-barebones/wallet-service/src/rpc.ts +++ b/canton-barebones/wallet-service/src/rpc.ts @@ -105,6 +105,7 @@ type ActiveJsContractReader = { type Cip56TokenSdk = { amulet?: { + tap?: (receiver: string, amount: string) => Promise<[unknown, unknown[]]> preapproval: { ctx?: { validatorParty?: string @@ -256,6 +257,7 @@ const TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE_ID = '#splice-wallet:Splice.Wallet.TransferPreapproval:TransferPreapprovalProposal' const TRANSFER_PREAPPROVAL_PROPOSAL_MAX_ATTEMPTS = 31 const TRANSFER_PREAPPROVAL_PROPOSAL_RETRY_MS = 1_000 +const AMULET_TAP_AMOUNT = '100' export const rpcResult = (id: JsonRpcId, result: unknown): JsonRpcSuccess => ({ jsonrpc: '2.0', @@ -571,7 +573,7 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { amulet: { validatorUrl: config.splice.validatorUrl, scanApiUrl: config.splice.scanApiUrl, - registryUrl: config.splice.registryApiUrl, + registryUrl: new URL(config.splice.registryApiUrl), auth, }, }) @@ -880,6 +882,20 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { } } + // Prepares the fixed DevNet tap command so Carpincho can sign as the receiver. + const amuletTap = async ( + params: unknown, + ): Promise<{ commands: unknown; disclosedContracts: unknown[] }> => { + const p = objectParam>(params, 'amulet.tap') + const receiver = requiredStringParam(p, 'receiver') + const sdk = await getTokenSdk() + if (sdk.amulet?.tap === undefined) { + throw new Error('Amulet tap is unavailable') + } + const [commands, disclosedContracts] = await sdk.amulet.tap(receiver, AMULET_TAP_AMOUNT) + return { commands, disclosedContracts } + } + // Prepares the receiver-signed proposal that asks the validator provider to enable auto-accept. const amuletPreapprovalCreate = async ( params: unknown, @@ -1082,6 +1098,8 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { return rpcResult(id, await cip56CreateTransfer(request.params)) case 'amulet.preapproval.status': return rpcResult(id, await amuletPreapprovalStatus(request.params)) + case 'amulet.tap': + return rpcResult(id, await amuletTap(request.params)) case 'amulet.preapproval.create': return rpcResult(id, await amuletPreapprovalCreate(request.params)) case 'amulet.preapproval.acceptProposal': @@ -1137,6 +1155,7 @@ export const createRpc = (config: WalletServiceConfig, deps: RpcDependencies = { 'cip56.acceptTransfer', 'cip56.createTransfer', 'amulet.preapproval.status', + 'amulet.tap', 'amulet.preapproval.create', 'amulet.preapproval.acceptProposal', 'amulet.preapproval.cancel', diff --git a/canton-barebones/wallet-service/test/rpc.test.ts b/canton-barebones/wallet-service/test/rpc.test.ts index b514a4b2..a01ba37d 100644 --- a/canton-barebones/wallet-service/test/rpc.test.ts +++ b/canton-barebones/wallet-service/test/rpc.test.ts @@ -246,7 +246,7 @@ describe('CIP-56 token helpers', () => { assert.deepEqual(seen.amuletConfig, { validatorUrl: 'http://localhost:2000/api/validator', scanApiUrl: 'http://scan.localhost:4000/api/scan', - registryUrl: 'http://localhost:2000/api/validator/v0/scan-proxy', + registryUrl: new URL('http://localhost:2000/api/validator/v0/scan-proxy'), auth: { method: 'static', token: 'backend.jwt' }, }) }) @@ -465,6 +465,39 @@ describe('CIP-56 token helpers', () => { }) }) + it('prepares a fixed DevNet Amulet tap command for the receiver party', async () => { + // Scenario: Carpincho needs a test-only faucet button that requests a fixed + // 100 AMT for the selected external party while preserving local signing. + const disclosedContracts = [{ contractId: 'tap-context-cid', createdEventBlob: 'blob' }] + const seen: { receiver?: string; amount?: string } = {} + const rpc = createRpc(withToken(), { + sdkFactory: async () => ({ + amulet: { + tap: async (receiver: string, amount: string) => { + seen.receiver = receiver + seen.amount = amount + return [{ ExerciseCommand: { choice: 'AmuletRules_DevNet_Tap' } }, disclosedContracts] + }, + }, + }), + }) + + const res = (await rpc.handle({ + jsonrpc: '2.0', + id: 1, + method: 'amulet.tap', + params: { receiver: 'receiver::party' }, + })) as JsonRpcResponse + + assert.ok('result' in res) + assert.deepEqual(res.result, { + commands: { ExerciseCommand: { choice: 'AmuletRules_DevNet_Tap' } }, + disclosedContracts, + }) + assert.equal(seen.receiver, 'receiver::party') + assert.equal(seen.amount, '100') + }) + it('accepts an Amulet transfer preapproval proposal as the validator provider', async () => { // Scenario: after Carpincho creates the receiver-signed proposal, the local // validator provider must accept it with a normal participant submit. diff --git a/carpincho-wallet/.env.example b/carpincho-wallet/.env.example new file mode 100644 index 00000000..fff96df2 --- /dev/null +++ b/carpincho-wallet/.env.example @@ -0,0 +1,4 @@ +# Optional. Minimum zxcvbn strength score (0-4) required to set a vault password. +# 0 too weak, 1 very guessable, 2 fair, 3 strong, 4 excellent. +# Defaults to 1 when unset. Use 3 for a security-conscious build. +# VITE_MIN_PASSWORD_SCORE=1 diff --git a/carpincho-wallet/AGENTS.md b/carpincho-wallet/AGENTS.md index 7d0fec55..e754772d 100644 --- a/carpincho-wallet/AGENTS.md +++ b/carpincho-wallet/AGENTS.md @@ -10,10 +10,10 @@ This file applies only to `carpincho-wallet/`. For monorepo-wide rules (commit s |----------|-----------|-------| | Language | TypeScript (strict mode) | | | Framework | Vite 6 + React 18 | SPA; also builds as a Chrome extension | -| Styling | Tailwind CSS v4 | `@tailwindcss/vite` plugin; utility classes inline in JSX; `src/index.css` declares semantic CSS variables on `:root` / `[data-theme='dark']`, rebinds the `dark:` variant via `@custom-variant`, exposes tokens to Tailwind through `@theme inline`, and holds `@layer base` resets plus named keyframes (`fade-in`, `slide-down-and-fade`, `slide-up-and-fade`, `sheet-up`, `sheet-slide-right`, `slide-in-right`, `slide-in-left`, `soft-pulse`, `drift`). A brand-tinted radial top-glow (`--bg-radial`) sits behind the page; there is no paper-grain overlay | -| Theming | Light / Dark / System selector in the drawer menu (dappbooster brand palette) | `src/theme/ThemeProvider.tsx` owns a persisted `mode` (`light` \| `dark` \| `system`, default `system`), exposes `{ mode, setMode }` via `useTheme()`, resolves `system` against `prefers-color-scheme` (and re-resolves on media changes while in `system`), and writes the resolved `data-theme` on `` after mount. The selector lives in the drawer at Settings → Theme (`src/components/menu/ThemeMenu.tsx`); there is no header toggle. Use semantic colour utilities (`bg-surface`, `text-foreground`, `border-border`, `bg-primary-soft`, `bg-scrim`, etc.) so styles flip automatically. The dark theme is cool navy (`#14152b`) matching `dappbooster-canton-landing`; the light theme is neutral grey (`#f7f7f7`, primary `#692581`) matching the `dAppBooster` boilerplate. A shared purple→pink brand accent — `--bg-gradient-brand` (`linear-gradient(135deg, #c670e5, #e71d73)`) plus `--shadow-glow` — reveals on primary-button hover and tints the hero wordmark. `--color-success` is green (used for the connected-state indicator); `--color-scrim` is the theme-aware overlay tint used by modal/sheet backdrops | +| Styling | Tailwind CSS v4 | `@tailwindcss/vite` plugin; utility classes inline in JSX; `src/index.css` declares semantic CSS variables on `:root` / `[data-theme='dark']`, rebinds the `dark:` variant via `@custom-variant`, exposes tokens to Tailwind through `@theme inline`, and holds `@layer base` resets plus named keyframes (`fade-in`, `slide-down-and-fade`, `slide-up-and-fade`, `sheet-up`, `sheet-slide-right`, `slide-in-right`, `slide-in-left`, `soft-pulse`, `drift`, `spin-fast`). A brand-tinted radial top-glow (`--bg-radial`) sits behind the page; there is no paper-grain overlay | +| Theming | Light / Dark / System selector in the drawer menu (dappbooster brand palette) | `src/theme/ThemeProvider.tsx` owns a persisted `mode` (`light` \| `dark` \| `system`, default `system`), exposes `{ mode, setMode }` via `useTheme()`, resolves `system` against `prefers-color-scheme` (and re-resolves on media changes while in `system`), and writes the resolved `data-theme` on `` after mount. The selector lives in the drawer menu under Theme (`src/components/menu/ThemeMenu.tsx`); there is no header toggle. Use semantic colour utilities (`bg-surface`, `text-foreground`, `border-border`, `bg-primary-soft`, `bg-scrim`, etc.) so styles flip automatically. The dark theme is cool navy (`#14152b`) matching `dappbooster-canton-landing`; the light theme is neutral grey (`#f7f7f7`, primary `#692581`) matching the `dAppBooster` boilerplate. A shared purple→pink brand accent — `--bg-gradient-brand` (`linear-gradient(135deg, #c670e5, #e71d73)`) plus `--shadow-glow` — reveals on primary-button hover and tints the hero wordmark. `--color-success` is green (used for the connected-state indicator); `--color-scrim` is the theme-aware overlay tint used by modal/sheet backdrops | | Fonts | Self-hosted via `@fontsource-variable/manrope`, `@fontsource-variable/jetbrains-mono` | Imported once in `src/main.tsx`; works offline in the extension popup. Manrope is the entire UI: `font-display` for hero wordmarks, view-level headings, section markers (heavier weight for hierarchy) and `font-sans` for UI chrome / body / labels / buttons. `font-mono` (JetBrains Mono) is for party IDs, hashes, RPC URLs, JSON payloads, status eyebrows | -| UI primitives | Radix UI | `@radix-ui/react-dialog`, `@radix-ui/react-select`, `@radix-ui/react-switch`, `@radix-ui/react-tabs`, `@radix-ui/react-toast`, `@radix-ui/react-tooltip` for modals (incl. bottom sheets), the deadline dropdown, on/off toggles, tabs, toasts, and tooltips (auto-positioning with collision detection; `TooltipProvider` mounted once in `App.tsx`); styled with Tailwind via `data-[state=...]` and `data-[highlighted]` variants and animated through the `animate-*` tokens above. **When adding a new interactive component, check the [Radix UI primitives catalogue](https://www.radix-ui.com/primitives) first — prefer a Radix primitive over a hand-rolled implementation.** Local primitives in `src/components/ui/` (Button family, TextInput, PasswordInput, Alert (info / error / warning / success variants), Card, AccountAvatar, PendingActionCard, Sheet, Tabs, Switch, Select, OptionList, Stepper, DangerConfirm, MenuRow, ToastProvider, Tooltip) compose Tailwind utilities + Radix where applicable; reuse them before writing one-off styling. `TextInput` and `PasswordInput` accept an `error?: boolean` prop that applies a danger border + persistent focus ring (`--shadow-focus-danger`) and sets `aria-invalid`; use this for field-level error state instead of wrapping in an `Alert`. Icon SVG literals live in [`src/components/ui/icons.tsx`](src/components/ui/icons.tsx) (`X_ICON`, `BACK_ICON`, `MENU_ICON`, `CHECK_ICON`, `COPY_ICON`, `COG_ICON`, `EYE_ICON`, `EYE_OFF_ICON`, `INFO_ICON`, `ALERT_TRIANGLE_ICON`, `ALERT_CIRCLE_ICON`, `SPINNER_ICON`, `SEARCH_ICON`, `DISCONNECT_ICON`, `CHEVRON_DOWN_ICON`, `CHEVRON_RIGHT_ICON`, `SEND_ICON`, `RECEIVE_ICON`, `RECEIPT_ICON`, `TRASH_ICON`, `WALLET_CONNECT_ICON`, `CONTACTS_ICON`); add new icons there instead of inlining ``. Custom width/height tokens (`w-popup`, `w-drawer`, `max-h-sheet`) are declared as `@utility` in `src/index.css`. `Sheet` wraps Radix Dialog with the shared overlay, title, and close-button chrome and takes `side: 'bottom' | 'right' | 'center'` (default `'bottom'`); the bottom variant uses `animate-sheet-up`, the right variant uses `animate-sheet-slide-right` (caps at 400px wide clamped by `100vw`, full-height, top-aligned content), and the center variant renders a centered modal dialog (used for account management, token detail, approval prompts, and danger confirmations) -- always use `Sheet` for sheet-style flows. `Button.tsx` also exports `GHOST_BUTTON_CLASS` and `ICON_BUTTON_CLASS` for places that need the interactive base without the button element. Larger shared building blocks live one level up in `src/components/` (`WelcomeHero` for the Setup/Unlock hero; `AccountCard`, `HomeTabs`, `ConnectionFooter` compose the Home view. The Home view is a fixed-height (`h-screen`) flex shell: the account selector and footer (no longer `fixed` — it lives in the column flow, bled full-width with `-mx-3`) stay pinned while only the active tab body scrolls. `HomeTabs` (the `Tabs` underline bar acts as the view title — no heading) holds three tabs, in order: `AssetsPanel` (token balances/holdings — each token row opens a centered `TokenDetailSheet` modal showing the balance, Send/Receive actions, and a per-UTXO holdings list; Send is a multi-step flow inside that same `TokenDetailSheet`: a controlled `SendTokenForm` (recipient field with an icon-only contacts button, an `AmountField` with a MAX button and spendable-balance line, a Radix `Select` deadline, a memo field with an info tooltip, and a Review action) → a `ContactsPicker` screen (the user's other accounts as a fixed 4-row list) → a `SendConfirm` screen (To/Amount/Expires/Memo summary, a collapsible "View data" raw-request payload, and Cancel/Confirm; confirming submits then closes the whole sheet), and Receive shows a party-id QR via `TokenReceive` / `react-qr-code`), `TransfersPanel` (the Amulet preapproval auto-accept toggle plus pending CIP-56 transfers split by direction through `transferDirection`: incoming transfers the active party can Accept, and the party's own outgoing transfers shown read-only as "Awaiting acceptance" — a transfer to oneself stays incoming so it remains acceptable; completed transfer history lives in the Activity tab, not here), and `ActivityList` (executed transactions as MetaMask-style rows grouped by day, each opening a centered detail `Sheet`); Assets is the default landing tab. The Transfers tab is force-mounted so its React Query polling continues while hidden and shows a count badge of incoming transfers awaiting acceptance. WalletConnect URI pairing lives in the drawer via `WalletConnectMenu`, the first root menu screen, web-only; `PasswordStrengthIndicator` renders the live strength meter; `NewPasswordFields` owns the "new password + confirm + strength meter + validity callback" trio used by both the Setup form and the change-password form -- callers control the visible-vs-aria label mode and receive validity via `onValidityChange`). ConnectionSettings renders as a bottom `Sheet` from `HomeView`; account management (add / remove / switch) lives in `AccountsDialog`, a centered `Sheet` opened from `AccountCard` (the active account is omitted from the switch list, since switching to the account you are already on is a no-op). Neither is a full-view replacement. `MenuSheet` (the burger-button drawer, right-anchored, 400px wide capped at `100vw`) drives multi-screen flows by toggling an internal `Screen` state inside one `Sheet`; every new sub-section in the drawer must be added as another `Screen` entry (drill-down with title + back + close + `animate-slide-in-*` transitions) — no accordion-style expansion | +| UI primitives | Radix UI | `@radix-ui/react-avatar`, `@radix-ui/react-collapsible`, `@radix-ui/react-dialog`, `@radix-ui/react-select`, `@radix-ui/react-switch`, `@radix-ui/react-tabs`, `@radix-ui/react-toast`, `@radix-ui/react-tooltip` for avatars (the dApp favicon with monogram fallback in `ConnectionFooter`), modals (incl. bottom sheets), the deadline dropdown, on/off toggles, tabs, toasts, and tooltips (auto-positioning with collision detection; `TooltipProvider` mounted once in `App.tsx`); styled with Tailwind via `data-[state=...]` and `data-[highlighted]` variants and animated through the `animate-*` tokens above. **When adding a new interactive component, check the [Radix UI primitives catalogue](https://www.radix-ui.com/primitives) first — prefer a Radix primitive over a hand-rolled implementation.** Local primitives in `src/components/ui/` (Button family, TextInput, PasswordInput, Alert (info / error / warning / success variants), Card, AccountAvatar, PendingActionCard, Sheet, Tabs, Switch, Select, OptionList, Stepper, DangerConfirm, MenuRow, DetailRow, FileDropInput, Collapsible, Copyable, CopyableLabel, JsonView, ToastProvider, Tooltip) compose Tailwind utilities + Radix where applicable; reuse them before writing one-off styling. `TextInput` and `PasswordInput` accept an `error?: boolean` prop that applies a danger border + persistent focus ring (`--shadow-focus-danger`) and sets `aria-invalid`; use this for field-level error state instead of wrapping in an `Alert`. Icon SVG literals live in [`src/components/ui/icons.tsx`](src/components/ui/icons.tsx) (`X_ICON`, `BACK_ICON`, `MENU_ICON`, `CHECK_ICON`, `COPY_ICON`, `EYE_ICON`, `EYE_OFF_ICON`, `GLOBE_ICON`, `INFO_ICON`, `ALERT_TRIANGLE_ICON`, `ALERT_CIRCLE_ICON`, `SPINNER_ICON`, `REFRESH_ICON`, `SEARCH_ICON`, `DISCONNECT_ICON`, `CHEVRON_DOWN_ICON`, `CHEVRON_RIGHT_ICON`, `SEND_ICON`, `RECEIVE_ICON`, `RECEIPT_ICON`, `LOCK_ICON`, `TRASH_ICON`, `WALLET_CONNECT_ICON`, `CONTACTS_ICON`, `CREATE_ICON`, `EXERCISE_ICON`, `CONTRACTS_ICON`, `UPLOAD_ICON`, `DROPLET_ICON`); add new icons there instead of inlining ``. Custom width/height tokens (`w-popup`, `w-drawer`, `max-h-sheet`) are declared as `@utility` in `src/index.css`. `Sheet` wraps Radix Dialog with the shared overlay, title, and close-button chrome and takes `side: 'bottom' | 'right' | 'center'` (default `'bottom'`); the bottom variant uses `animate-sheet-up`, the right variant uses `animate-sheet-slide-right` (caps at 400px wide clamped by `100vw`, full-height, top-aligned content), and the center variant renders a centered modal dialog (used for account management, token detail, approval prompts, and danger confirmations) -- always use `Sheet` for sheet-style flows. `Button.tsx` also exports `GHOST_BUTTON_CLASS` and `ICON_BUTTON_CLASS` for places that need the interactive base without the button element. Larger shared building blocks live one level up in `src/components/` (`WelcomeHero` for the Setup/Unlock hero; `AccountCard`, `HomeTabs`, `ConnectionFooter` compose the Home view. The Home view is a fixed-height (`h-screen`) flex shell: the account selector and footer (no longer `fixed` — it lives in the column flow, bled full-width with `-mx-3`) stay pinned while only the active tab body scrolls. `HomeTabs` (the `Tabs` underline bar acts as the view title — no heading) holds three tabs, in order: `AssetsPanel` (token balances/holdings, with the `AutoAcceptSetting` auto-accept-incoming-transfers toggle pinned above balance-first token rows — each token row opens a centered `TokenDetailSheet` modal showing the balance, Send/Receive actions, and a per-UTXO holdings list; Send is a multi-step flow inside that same `TokenDetailSheet`: a controlled `SendTokenForm` (recipient field with an icon-only contacts button, an `AmountField` with a MAX button and spendable-balance line, a Radix `Select` deadline, a memo field with an info tooltip, and a Review action) → a `ContactsPicker` screen (the user's other accounts as a fixed 4-row list) → a `SendConfirm` screen (To/Amount/Expires/Memo summary, a collapsible "View data" raw-request payload, and Cancel/Confirm; confirming submits then closes the whole sheet), and Receive shows a party-id QR via `TokenReceive` / `react-qr-code`), `ActivityPanel` (pending CIP-56 transfers split by direction through `transferDirection`, pinned above the executed-transaction history: incoming transfers the active party can Accept under a "Needs action" group, and the party's own outgoing transfers shown read-only under "Awaiting acceptance" — a transfer to oneself stays incoming so it remains acceptable; each transfer's full metadata opens in a centered `TransferDetailsSheet` via an eye button, and accepting optimistically hides the card while a progress toast tracks the result; beneath the pending groups, `ActivityList` renders executed transactions as MetaMask-style rows grouped by day, each opening a centered detail `Sheet`), and `UtilsPanel` (Utils — developer ledger tools rendered as a scannable list where each item opens in a centered modal `Sheet`: Create contract, Exercise choice, Active contracts, and Upload DAR, plus a Tap Amulet faucet action row that requests 100 AMT and reports progress via toast — the faucet moved here from the Assets tab); Assets is the default landing tab. The Activity tab is force-mounted so its React Query polling keeps the incoming-transfer count badge live while hidden; the Assets tab is force-mounted too so the auto-accept toggle's optimistic state survives tab switches. WalletConnect URI pairing lives in the drawer via `WalletConnectMenu`, the first root menu screen, web-only; `PasswordStrengthIndicator` renders the live strength meter; `NewPasswordFields` owns the "new password + confirm + strength meter + validity callback" trio used by both the Setup form and the change-password form -- callers control the visible-vs-aria label mode and receive validity via `onValidityChange`). ConnectionSettings renders as a bottom `Sheet` from `HomeView`; account management (add / remove / switch) lives in `AccountsDialog`, a centered `Sheet` opened from `AccountCard` (the active account is omitted from the switch list, since switching to the account you are already on is a no-op). Neither is a full-view replacement. `MenuSheet` (the burger-button drawer, right-anchored, 400px wide capped at `100vw`) drives multi-screen flows by toggling an internal `Screen` state inside one `Sheet`; every new sub-section in the drawer must be added as another `Screen` entry (drill-down with title + back + close + `animate-slide-in-*` transitions) — no accordion-style expansion | | Feedback | Toast vs inline Alert vs error prop | Transient system feedback (action results, async errors, network failures, copy confirmations) renders via `toast.*` from [`@/components/ui/toast.ts`](src/components/ui/toast.ts) — never as inline `Alert`. Mount the `ToastProvider` once in `App.tsx`. Field-level error state (mismatched passwords, wrong current password on a verify step, invalid input) uses the `error` prop on `TextInput` / `PasswordInput` (danger border + `aria-invalid`); pair it with `aria-errormessage` pointing at a sibling caption `

` when there is a human-readable reason to render. Inline `Alert` is reserved for form-level errors that cannot be attributed to a single field and for persistent contextual hints inside cards. All callers (React components, WalletConnect handlers in `src/wc/`, vault errors, extension bridge) use the imperative `toast` import directly. Variant durations: non-critical variants (`info` / `success` / `warning`) auto-dismiss after 5 s; only `error` (must-read) persists until dismissed. Identical messages collapse — emitting a string message that matches a visible toast of the same variant replaces it with a fresh entry (so rapid repeats like copy clicks collapse to a single toast and restart its timer); distinct messages of the same variant (a copy confirmation vs an RPC test result) stack separately. Toasts render as elevated `bg-surface` cards (border + `shadow-popover`) with a variant-tinted icon badge and a left accent rail, sized to the app content width (`w-popup`). The message body caps at `max-h-40` and scrolls when longer; `error` toasts (only) carry a copy-icon button that copies the rendered message text. Max 3 visible, top-center, newest on top | | Package manager | npm | lockfile: `package-lock.json` | | Data fetching | @tanstack/react-query | Polls CIP-56 token holdings, pending transfers, and Amulet preapproval status (5 s); a single `QueryClient` is mounted in `App.tsx`. Read/list logic lives in `src/cip56/*`, wrapped by hooks in `src/hooks/*` | @@ -29,6 +29,15 @@ This file applies only to `carpincho-wallet/`. For monorepo-wide rules (commit s - **Imports:** every import that resolves inside `src/` must use the `@/*` alias (e.g. `import { Foo } from '@/components/Foo'`); relative `./` and `../` paths are banned by Biome's `style/noRestrictedImports`. Do **not** add the `.ts` / `.tsx` extension on import paths — `moduleResolution: bundler` (Vite + the tsx test loader) resolves extensionless. Because resolution is extensionless, two modules in the same directory must not differ only by case and extension (e.g. `Toast.tsx` vs `toast.ts`), or the import becomes ambiguous on a case-insensitive filesystem. The alias is declared in `tsconfig.app.json`, `test/tsconfig.json`, and `vite.config.ts` - **Functional style:** prefer `const` with expression-returning forms (ternaries, IIFEs, helper functions, lookup objects, destructured ternaries) over `let` + reassignment. Reach for `let` only when no clean expression form exists. +## Test selectors (`data-testid`) + +Every interactive or assertion-worthy element ships a stable `data-testid` so end-to-end tooling never falls back on text, role, or DOM-position selectors. This is a hard requirement: do not merge new UI without it, and add one when you touch an existing element that lacks it. + +- **What gets one:** every control a user acts on (buttons, links, inputs, selects, toggles, file pickers); every list row that can be opened, selected, or actioned; every modal / sheet / dialog container; and any value with a copy affordance. Result and landing containers worth asserting (home tabs, connection footer, the pending-approval modal) get one too. +- **Naming:** kebab-case, area-prefixed, action- or role-descriptive — e.g. `send-confirm`, `account-remove`, `utils-tap-amulet`, `transfer-accept`, `rpc-save`. Mirror the existing names and keep them stable; tests depend on them. For repeated rows use one constant testid plus a `data-*` discriminator (`data-testid="token-row" data-token-label={…}`) rather than interpolating unstable values into the id. +- **Native elements** (` - ))} + + Confirmed + + + + ))} + ))} @@ -183,6 +184,7 @@ export const ActivityList = ({ transactions }: ActivityListProps): JSX.Element = onOpenChange={(open) => { if (!open) setSelected(null) }} + testId="activity-detail-sheet" side="center" title={selected === null ? '' : txTitle(selected)} description="Transaction details." diff --git a/carpincho-wallet/src/components/ActivityPanel.tsx b/carpincho-wallet/src/components/ActivityPanel.tsx new file mode 100644 index 00000000..3b09bed9 --- /dev/null +++ b/carpincho-wallet/src/components/ActivityPanel.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { PendingTokenTransfer } from '@/cip56/transfers' +import { transferDirection } from '@/cip56/transfers' +import { ActivityList } from '@/components/ActivityList' +import { TransferCard } from '@/components/TransferCard' +import { TransferDetailsSheet } from '@/components/TransferDetailsSheet' +import { LoadingState } from '@/components/ui/LoadingState' +import { SECTION_LABEL_CLASS } from '@/components/ui/SectionLabel' +import { toast } from '@/components/ui/toast' +import type { Cip56TransferApi } from '@/hooks/usePendingCip56Transfers' +import { usePendingCip56Transfers } from '@/hooks/usePendingCip56Transfers' +import { cn } from '@/utils/cn' +import type { AccountPublic, TransactionRecord } from '@/vault/types' +import { useVault } from '@/vault/useVault' + +export interface ActivityPanelProps { + account?: AccountPublic + transactions: TransactionRecord[] + api?: Cip56TransferApi + onPendingCountChange?: (count: number) => void +} + +// Activity tab body: actionable + in-flight transfers pinned above the settled history feed. +export const ActivityPanel = ({ + account, + transactions, + api, + onPendingCountChange, +}: ActivityPanelProps): JSX.Element => { + const vault = useVault() + const activeAccount = account ?? vault.primary ?? vault.accounts[0] + const [acceptingCid, setAcceptingCid] = useState(undefined) + const [detailsTransfer, setDetailsTransfer] = useState(null) + const { transfers, loading, error, accept } = usePendingCip56Transfers(activeAccount, { + api, + signMessage: vault.signMessage, + recordTransaction: vault.recordTransaction, + }) + + const { incoming, outgoing } = useMemo(() => { + const partyId = activeAccount?.partyId + const incomingTransfers: PendingTokenTransfer[] = [] + const outgoingTransfers: PendingTokenTransfer[] = [] + for (const transfer of transfers) { + if (transferDirection(transfer, partyId) === 'outgoing') { + outgoingTransfers.push(transfer) + } else { + incomingTransfers.push(transfer) + } + } + return { incoming: incomingTransfers, outgoing: outgoingTransfers } + }, [transfers, activeAccount?.partyId]) + + // Hold the latest callback in a ref so the count effects don't depend on its identity; + // a parent that re-creates the callback must not trigger a spurious reset mid-session. + const onPendingCountChangeRef = useRef(onPendingCountChange) + useEffect(() => { + onPendingCountChangeRef.current = onPendingCountChange + }, [onPendingCountChange]) + + // Only incoming transfers need receiver action, so the badge counts those alone. + useEffect(() => { + onPendingCountChangeRef.current?.(incoming.length) + }, [incoming.length]) + + // Reset the parent's count when this panel unmounts (e.g. account teardown). + useEffect(() => { + return () => onPendingCountChangeRef.current?.(0) + }, []) + + if (activeAccount === undefined) { + return ( +

+

No account selected

+
+ ) + } + + // Optimistically hides the accepted transfer while it settles; a progress toast tracks the + // flow and is replaced by the result. On failure the transfer reappears so it can be retried. + const onAccept = async (transferInstructionCid: string): Promise => { + setAcceptingCid(transferInstructionCid) + const pendingToastId = toast.info('Accepting transfer...') + try { + await accept(transferInstructionCid) + toast.dismiss(pendingToastId) + toast.success('Transfer accepted.') + } catch (err) { + toast.dismiss(pendingToastId) + toast.error(`Accept failed: ${(err as Error).message}`) + } finally { + setAcceptingCid(undefined) + } + } + + // Hide the in-flight transfer from the actionable list as soon as Accept is clicked. + const visibleIncoming = incoming.filter((transfer) => transfer.contractId !== acceptingCid) + const hasPending = incoming.length > 0 || outgoing.length > 0 + const showLoading = loading && !hasPending && transactions.length === 0 + // ActivityList owns the "No activity yet" empty state, so render it only when there is real + // history or nothing is pending; otherwise that text reads as empty beneath the pending cards. + const showHistory = !showLoading && (transactions.length > 0 || !hasPending) + + return ( +
+ {error === undefined ? null : ( +
+ {error} +
+ )} + + {visibleIncoming.length > 0 ? ( +
+

Needs action

+ {visibleIncoming.map((transfer) => ( + + ))} +
+ ) : null} + + {outgoing.length > 0 ? ( +
+

Awaiting acceptance

+ {outgoing.map((transfer) => ( + + ))} +
+ ) : null} + + {showLoading ? : null} + {showHistory ? : null} + + setDetailsTransfer(null)} + /> +
+ ) +} diff --git a/carpincho-wallet/src/components/AmountField.tsx b/carpincho-wallet/src/components/AmountField.tsx index fe042c4e..84e7eefc 100644 --- a/carpincho-wallet/src/components/AmountField.tsx +++ b/carpincho-wallet/src/components/AmountField.tsx @@ -8,6 +8,7 @@ export interface AmountFieldProps { balance: string tokenLabel: string error?: boolean + testId?: string } const FIELD_CLASS = @@ -21,6 +22,7 @@ export const AmountField = ({ balance, tokenLabel, error, + testId, }: AmountFieldProps): JSX.Element => (
Amount @@ -34,6 +36,7 @@ export const AmountField = ({ > + )} +
)} - - + - {dapp.kind === 'connected' && ( - <> -
- -
-
-
- {dapp.label} -
- {dappAccountAddress !== undefined && ( -
- {dappAccountAddress} -
- )} -
- {onDisconnectDapp !== undefined && ( - - {DISCONNECT_ICON} - - )} -
- - )} + ) } diff --git a/carpincho-wallet/src/components/ContactsPicker.tsx b/carpincho-wallet/src/components/ContactsPicker.tsx index ca53589f..695a1271 100644 --- a/carpincho-wallet/src/components/ContactsPicker.tsx +++ b/carpincho-wallet/src/components/ContactsPicker.tsx @@ -24,6 +24,8 @@ export const ContactsPicker = ({ contacts, onSelect }: ContactsPickerProps): JSX + ) diff --git a/carpincho-wallet/src/components/DarUploadPanel.tsx b/carpincho-wallet/src/components/DarUploadPanel.tsx new file mode 100644 index 00000000..c74720cc --- /dev/null +++ b/carpincho-wallet/src/components/DarUploadPanel.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { type DarUploadResponse, uploadDarFile } from '@/api/walletService' +import { PrimaryButton } from '@/components/ui/Button' +import { FileDropInput } from '@/components/ui/FileDropInput' +import { toast } from '@/components/ui/toast' + +export interface DarUploadApi { + uploadDarFile: (file: File) => Promise +} + +interface DarUploadPanelProps { + api?: DarUploadApi +} + +const defaultApi: DarUploadApi = { uploadDarFile } + +// Development-only utility for uploading compiled DAML archives through wallet-service. +export const DarUploadPanel = ({ api = defaultApi }: DarUploadPanelProps): JSX.Element => { + const [file, setFile] = useState() + const [uploading, setUploading] = useState(false) + const [uploadedFileName, setUploadedFileName] = useState() + + // Keeps validation and toast feedback inside the dev-only upload utility. + const onUpload = async (): Promise => { + if (file === undefined) { + toast.warning('Select a DAR file') + return + } + setUploading(true) + setUploadedFileName(undefined) + try { + await api.uploadDarFile(file) + setUploadedFileName(file.name) + toast.success(`${file.name} uploaded`) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setUploading(false) + } + } + + return ( +
+ { + setUploadedFileName(undefined) + setFile(selected ?? undefined) + }} + /> + { + void onUpload() + }} + > + {uploading ? 'Uploading...' : 'Upload'} + + {uploadedFileName === undefined ? null : ( +

+ {uploadedFileName} uploaded +

+ )} +
+ ) +} diff --git a/carpincho-wallet/src/components/Header.tsx b/carpincho-wallet/src/components/Header.tsx index 6d537e76..87af610f 100644 --- a/carpincho-wallet/src/components/Header.tsx +++ b/carpincho-wallet/src/components/Header.tsx @@ -21,6 +21,7 @@ export const Header = ({ onOpenMenu }: HeaderProps): JSX.Element => { {!vault.isLocked && (
) } diff --git a/carpincho-wallet/src/components/SecurityPanel.tsx b/carpincho-wallet/src/components/SecurityPanel.tsx deleted file mode 100644 index 9c543787..00000000 --- a/carpincho-wallet/src/components/SecurityPanel.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { type FormEvent, useState } from 'react' -import { NewPasswordFields } from '@/components/NewPasswordFields' -import { PrimaryButton } from '@/components/ui/Button' -import { OptionList } from '@/components/ui/OptionList' -import { PasswordInput } from '@/components/ui/PasswordInput' -import { toast } from '@/components/ui/toast' -import type { AutoLockOption } from '@/vault/storage' -import { useVault } from '@/vault/useVault' - -const AUTO_LOCK_LABELS: Array<{ value: AutoLockOption; label: string }> = [ - { value: 'never', label: 'Never' }, - { value: '1m', label: '1 minute' }, - { value: '5m', label: '5 minutes' }, - { value: '1h', label: '1 hour' }, -] - -interface VerifyState { - phase: 'verify' - current: string - error: string | null -} - -interface ChangeState { - phase: 'change' - current: string - next: string - confirm: string - valid: boolean -} - -type PasswordState = VerifyState | ChangeState - -const initialState = (): VerifyState => ({ phase: 'verify', current: '', error: null }) - -export const PasswordForm = (): JSX.Element => { - const v = useVault() - const [state, setState] = useState(initialState) - - const onSubmitVerify = (e: FormEvent): void => { - e.preventDefault() - if (state.phase !== 'verify') return - if (!v.verifyPassword(state.current)) { - setState({ ...state, error: 'Incorrect password.' }) - return - } - setState({ phase: 'change', current: state.current, next: '', confirm: '', valid: false }) - } - - const onSubmitChange = async (e: FormEvent): Promise => { - e.preventDefault() - if (state.phase !== 'change') return - if (!state.valid) return - try { - await v.changePassword(state.current, state.next) - setState(initialState()) - toast.success('Password updated.') - } catch (err) { - const msg = err instanceof Error ? err.message : 'Could not change password.' - setState({ phase: 'verify', current: '', error: msg }) - } - } - - if (state.phase === 'verify') { - const hasError = state.error !== null - return ( -
- setState({ ...state, current: e.target.value, error: null })} - /> - {hasError && ( -

- {state.error} -

- )} - Continue - - ) - } - - return ( -
- setState({ ...state, confirm: value })} - onPasswordChange={(value) => setState({ ...state, next: value })} - onValidityChange={(valid) => setState((s) => (s.phase === 'change' ? { ...s, valid } : s))} - password={state.next} - /> - - Change password - - - ) -} - -export const AutoLockList = (): JSX.Element => { - const v = useVault() - return ( - - ) -} diff --git a/carpincho-wallet/src/components/SendConfirm.tsx b/carpincho-wallet/src/components/SendConfirm.tsx index 4544a8e1..29e734ee 100644 --- a/carpincho-wallet/src/components/SendConfirm.tsx +++ b/carpincho-wallet/src/components/SendConfirm.tsx @@ -9,6 +9,7 @@ import { transferDeadlineExpiration, } from '@/components/SendTokenForm' import { PrimaryButton, SecondaryButton } from '@/components/ui/Button' +import { JsonView } from '@/components/ui/JsonView' import { toast } from '@/components/ui/toast' import { shortMiddle } from '@/utils/account' import type { AccountPublic } from '@/vault/types' @@ -105,22 +106,30 @@ export const SendConfirm = ({
- + View data -
-          {JSON.stringify(request, null, 2)}
-        
+
+ +
Cancel { void onConfirm() diff --git a/carpincho-wallet/src/components/SendTokenForm.tsx b/carpincho-wallet/src/components/SendTokenForm.tsx index a62d5434..2b6ff71a 100644 --- a/carpincho-wallet/src/components/SendTokenForm.tsx +++ b/carpincho-wallet/src/components/SendTokenForm.tsx @@ -103,6 +103,7 @@ export const SendTokenForm = ({
)}
diff --git a/carpincho-wallet/src/components/ui/SectionLabel.tsx b/carpincho-wallet/src/components/ui/SectionLabel.tsx new file mode 100644 index 00000000..6082823d --- /dev/null +++ b/carpincho-wallet/src/components/ui/SectionLabel.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' +import { cn } from '@/utils/cn' + +// Shared eyebrow heading for list sections (Holdings, Faucet, date groups, ...) so every +// section title shares one size, colour, and weight. Separation to the content below comes +// from the parent's `gap`, keeping the bottom spacing uniform across sections. +export const SECTION_LABEL_CLASS = + 'px-1 text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground' + +export const SectionLabel = ({ + className, + children, +}: { + className?: string + children: ReactNode +}): JSX.Element =>
{children}
diff --git a/carpincho-wallet/src/components/ui/Select.tsx b/carpincho-wallet/src/components/ui/Select.tsx index 51ca9ab3..ccff92a6 100644 --- a/carpincho-wallet/src/components/ui/Select.tsx +++ b/carpincho-wallet/src/components/ui/Select.tsx @@ -13,6 +13,7 @@ export interface SelectProps { options: SelectOption[] ariaLabel: string id?: string + testId?: string } const TRIGGER_CLASS = @@ -34,6 +35,7 @@ export const Select = ({ options, ariaLabel, id, + testId, }: SelectProps): JSX.Element => ( diff --git a/carpincho-wallet/src/components/ui/Sheet.tsx b/carpincho-wallet/src/components/ui/Sheet.tsx index 77d72245..563e9108 100644 --- a/carpincho-wallet/src/components/ui/Sheet.tsx +++ b/carpincho-wallet/src/components/ui/Sheet.tsx @@ -40,6 +40,7 @@ interface SheetProps { onOpenChange: (open: boolean) => void title: string description: string + testId?: string onBack?: () => void hideClose?: boolean // Visually hide the title (kept for screen readers) while leaving the back chevron in place. @@ -57,6 +58,7 @@ export const Sheet = ({ onOpenChange, title, description, + testId, onBack, hideClose = false, hideTitle = false, @@ -71,7 +73,10 @@ export const Sheet = ({ > - +
{onBack !== undefined && ( diff --git a/carpincho-wallet/src/components/ui/Tabs.tsx b/carpincho-wallet/src/components/ui/Tabs.tsx index 3804f856..adaa9f2f 100644 --- a/carpincho-wallet/src/components/ui/Tabs.tsx +++ b/carpincho-wallet/src/components/ui/Tabs.tsx @@ -21,13 +21,16 @@ export const TabsList = ({ export const TabTrigger = ({ value, + testId, children, }: { value: string + testId?: string children: ReactNode }): JSX.Element => ( ( @@ -25,10 +25,10 @@ export const Tooltip = ({ {content} diff --git a/carpincho-wallet/src/components/ui/icons.tsx b/carpincho-wallet/src/components/ui/icons.tsx index f7aaf804..ee121897 100644 --- a/carpincho-wallet/src/components/ui/icons.tsx +++ b/carpincho-wallet/src/components/ui/icons.tsx @@ -113,23 +113,6 @@ export const COPY_ICON = ( ) -export const COG_ICON = ( - -) - export const EYE_ICON = ( ) +export const REFRESH_ICON = ( + +) + export const SEARCH_ICON = ( ) +export const GLOBE_ICON = ( + +) + +export const LOCK_ICON = ( + +) + +export const CREATE_ICON = ( + +) + +export const EXERCISE_ICON = ( + +) + +export const CONTRACTS_ICON = ( + +) + +export const UPLOAD_ICON = ( + +) + +export const DROPLET_ICON = ( + +) + // Official WalletConnect logo mark, in brand blue. export const WALLET_CONNECT_ICON = ( ( + +
+ + {shortMiddle(contract.contractId, 10, 8)} + + + Toggle contract details + + {CHEVRON_DOWN_ICON} + + +
+ + + + {contract.createdOffset === undefined ? null : ( + + )} +
+ + +
+
+ +) + +// Browses the active contract set, narrowing it live by template or contract id as you type. +export const ActiveContractsUtil = ({ + account, + listActiveContracts = defaultList, +}: ActiveContractsUtilProps): JSX.Element => { + const [filterQuery, setFilterQuery] = useState('') + const [contracts, setContracts] = useState([]) + const [busy, setBusy] = useState(false) + const [spinning, setSpinning] = useState(false) + const [error, setError] = useState() + const [loaded, setLoaded] = useState(false) + + const refresh = useCallback(async (): Promise => { + setBusy(true) + setError(undefined) + try { + setContracts(await listActiveContracts({ partyId: account.partyId })) + setLoaded(true) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + toast.error(message) + } finally { + setBusy(false) + } + }, [account.partyId, listActiveContracts]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const visible = contracts.filter((contract) => contractMatchesQuery(contract, filterQuery)) + const emptyMessage = !loaded + ? 'Loading active contracts...' + : contracts.length === 0 + ? 'No active contracts.' + : 'No contracts match the filter.' + + return ( +
+
+ + setFilterQuery(event.currentTarget.value)} + placeholder="Template or contract id" + aria-label="Filter contracts" + className="pl-9 pr-9 font-mono text-[0.9rem]" + /> + {filterQuery !== '' && ( + + )} +
+ {error === undefined ? null : {error}} +
+ +
+ {visible.length === 0 ? ( +

+ {emptyMessage} +

+ ) : ( +
+ {visible.map((contract) => ( + + ))} +
+ )} +
+ ) +} diff --git a/carpincho-wallet/src/components/utils/CreateContractUtil.tsx b/carpincho-wallet/src/components/utils/CreateContractUtil.tsx new file mode 100644 index 00000000..1d5c6754 --- /dev/null +++ b/carpincho-wallet/src/components/utils/CreateContractUtil.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { PrimaryButton } from '@/components/ui/Button' +import { TextInput } from '@/components/ui/TextInput' +import { toast } from '@/components/ui/toast' +import { JsonField } from '@/components/utils/JsonField' +import { UpdateIdResult } from '@/components/utils/UpdateIdResult' +import { createContract as defaultCreateContract } from '@/ledger/contracts' +import { parseJsonObject } from '@/utils/json' +import type { AccountPublic } from '@/vault/types' +import { useVault } from '@/vault/useVault' + +interface CreateContractUtilProps { + account: AccountPublic + createContract?: typeof defaultCreateContract +} + +// Submits one generic CreateCommand and surfaces the resulting update id for copying. +export const CreateContractUtil = ({ + account, + createContract = defaultCreateContract, +}: CreateContractUtilProps): JSX.Element => { + const vault = useVault() + const [templateId, setTemplateId] = useState('') + const [json, setJson] = useState('{}') + const [jsonValid, setJsonValid] = useState(true) + const [busy, setBusy] = useState(false) + const [updateId, setUpdateId] = useState() + + const canSubmit = templateId.trim() !== '' && jsonValid && !busy + + const onSubmit = async (): Promise => { + setBusy(true) + setUpdateId(undefined) + try { + const createArguments = parseJsonObject(json, 'Create arguments') + const result = await createContract({ + account, + templateId: templateId.trim(), + createArguments, + signMessage: vault.signMessage, + recordTransaction: vault.recordTransaction, + }) + setUpdateId(result.updateId) + toast.success('Contract created') + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( +
{ + event.preventDefault() + void onSubmit() + }} + > + + + {updateId === undefined ? null : } + + {busy ? 'Creating...' : 'Create'} + + + ) +} diff --git a/carpincho-wallet/src/components/utils/ExerciseChoiceUtil.tsx b/carpincho-wallet/src/components/utils/ExerciseChoiceUtil.tsx new file mode 100644 index 00000000..f65afbc7 --- /dev/null +++ b/carpincho-wallet/src/components/utils/ExerciseChoiceUtil.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react' +import { PrimaryButton } from '@/components/ui/Button' +import { TextInput } from '@/components/ui/TextInput' +import { toast } from '@/components/ui/toast' +import { JsonField } from '@/components/utils/JsonField' +import { UpdateIdResult } from '@/components/utils/UpdateIdResult' +import { exerciseContract as defaultExerciseContract } from '@/ledger/contracts' +import { parseJsonObject } from '@/utils/json' +import type { AccountPublic } from '@/vault/types' +import { useVault } from '@/vault/useVault' + +interface ExerciseChoiceUtilProps { + account: AccountPublic + exerciseContract?: typeof defaultExerciseContract +} + +// Submits one generic ExerciseCommand and surfaces the resulting update id for copying. +export const ExerciseChoiceUtil = ({ + account, + exerciseContract = defaultExerciseContract, +}: ExerciseChoiceUtilProps): JSX.Element => { + const vault = useVault() + const [templateId, setTemplateId] = useState('') + const [contractId, setContractId] = useState('') + const [choice, setChoice] = useState('') + const [json, setJson] = useState('{}') + const [jsonValid, setJsonValid] = useState(true) + const [busy, setBusy] = useState(false) + const [updateId, setUpdateId] = useState() + + const canSubmit = + templateId.trim() !== '' && + contractId.trim() !== '' && + choice.trim() !== '' && + jsonValid && + !busy + + const onSubmit = async (): Promise => { + setBusy(true) + setUpdateId(undefined) + try { + const choiceArgument = parseJsonObject(json, 'Choice argument') + const result = await exerciseContract({ + account, + templateId: templateId.trim(), + contractId: contractId.trim(), + choice: choice.trim(), + choiceArgument, + signMessage: vault.signMessage, + recordTransaction: vault.recordTransaction, + }) + setUpdateId(result.updateId) + toast.success('Choice exercised') + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( +
{ + event.preventDefault() + void onSubmit() + }} + > + + + + + {updateId === undefined ? null : } + + {busy ? 'Exercising...' : 'Exercise'} + + + ) +} diff --git a/carpincho-wallet/src/components/utils/JsonField.tsx b/carpincho-wallet/src/components/utils/JsonField.tsx new file mode 100644 index 00000000..b905011d --- /dev/null +++ b/carpincho-wallet/src/components/utils/JsonField.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo } from 'react' +import { INPUT_CLASS } from '@/components/ui/TextInput' +import { cn } from '@/utils/cn' +import { formatJsonInput, parseJsonObject } from '@/utils/json' + +interface JsonFieldProps { + id: string + label: string + value: string + onChange: (value: string) => void + onValidityChange?: (valid: boolean) => void + placeholder?: string +} + +// Returns an error message when the text is not a JSON object, else undefined. +const validate = (value: string, label: string): string | undefined => { + try { + parseJsonObject(value, label) + return undefined + } catch (err) { + if (!(err instanceof Error)) return 'Invalid JSON' + const prefix = `${label}: ` + const message = err.message.startsWith(prefix) ? err.message.slice(prefix.length) : err.message + return message + } +} + +// JSON object textarea with live validation and format-on-blur for ledger command payloads. +export const JsonField = ({ + id, + label, + value, + onChange, + onValidityChange, + placeholder, +}: JsonFieldProps): JSX.Element => { + const errorMessage = useMemo(() => validate(value, label), [value, label]) + + useEffect(() => { + onValidityChange?.(errorMessage === undefined) + }, [errorMessage, onValidityChange]) + + const onBlur = (): void => { + try { + onChange(formatJsonInput(value, label)) + } catch { + // Leave partially typed input untouched when it cannot be parsed yet. + } + } + + return ( +