From 3b88dcbf98980ef2e80d01425283ab34cd122c13 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:36:57 -0300 Subject: [PATCH 001/121] feat(carpincho-wallet): bring feature-ready wallet from darkpool Private key import/export, shared runtime config for the extension worker, content-script injection on all URLs, configurable password strength, and the Amulet DevNet faucet UI. Imported as a subtree from the darkpool line on top of the canton-barebones-splice wallet-service. --- carpincho-wallet/.env.example | 4 + carpincho-wallet/public/manifest.json | 14 +- carpincho-wallet/src/api/walletService.ts | 43 +- .../src/cip56/amuletPreapproval.ts | 25 ++ .../src/components/AssetsPanel.tsx | 40 +- .../src/components/DarUploadPanel.tsx | 74 ++++ carpincho-wallet/src/components/HomeTabs.tsx | 8 + .../src/components/LedgerToolsPanel.tsx | 398 ++++++++++++++++++ .../components/PasswordStrengthIndicator.tsx | 6 +- .../src/components/PrivateKeyPanel.tsx | 176 ++++++++ .../src/components/menu/MenuSheet.tsx | 5 + .../src/components/menu/screens.ts | 16 +- carpincho-wallet/src/config/runtimeConfig.ts | 73 +++- carpincho-wallet/src/ledger/contracts.ts | 212 ++++++++++ carpincho-wallet/src/utils/json.ts | 218 ++++++++++ carpincho-wallet/src/vault/VaultContext.tsx | 15 + carpincho-wallet/src/vault/keypair.ts | 16 +- .../src/vault/passwordStrength.ts | 14 +- .../src/views/ConnectionSettingsView.tsx | 2 +- .../views/onboarding/CreateFirstAccount.tsx | 40 +- carpincho-wallet/src/vite-env.d.ts | 8 + .../test/api/walletService.test.ts | 71 ++++ .../test/cip56/amuletPreapproval.test.ts | 60 +++ .../test/components/AccountCard.test.tsx | 1 + .../test/components/AccountsDialog.test.tsx | 1 + .../test/components/AssetsPanel.test.tsx | 61 ++- .../components/CreateAccountForm.test.tsx | 1 + .../test/components/DarUploadPanel.test.tsx | 39 ++ .../test/components/Header.test.tsx | 1 + .../test/components/HomeTabs.test.tsx | 4 +- .../test/components/LedgerToolsPanel.test.tsx | 271 ++++++++++++ .../test/components/MenuSheet.test.tsx | 22 + .../test/components/PrivateKeyPanel.test.tsx | 205 +++++++++ .../test/components/SecurityPanel.test.tsx | 1 + .../test/components/SendConfirm.test.tsx | 1 + .../test/components/TokenDetailSheet.test.tsx | 1 + .../test/components/TransfersPanel.test.tsx | 1 + .../test/config/runtimeConfig.test.ts | 77 ++++ carpincho-wallet/test/extensionBridge.test.ts | 5 +- .../test/extensionManifest.test.ts | 6 +- .../hooks/useWalletServiceStatus.test.tsx | 2 +- .../test/ledger/contracts.test.ts | 265 ++++++++++++ carpincho-wallet/test/utils/json.test.ts | 33 ++ .../test/vault/exportPrivateKey.test.tsx | 71 ++++ carpincho-wallet/test/vault/keypair.test.ts | 26 ++ .../test/vault/passwordAcceptance.test.tsx | 19 + .../test/vault/passwordStrength.test.ts | 23 + .../views/ConnectionSettingsView.test.tsx | 2 +- .../test/views/CreateVault.test.tsx | 1 + .../test/views/OnboardingFlow.test.tsx | 45 +- 50 files changed, 2672 insertions(+), 51 deletions(-) create mode 100644 carpincho-wallet/.env.example create mode 100644 carpincho-wallet/src/components/DarUploadPanel.tsx create mode 100644 carpincho-wallet/src/components/LedgerToolsPanel.tsx create mode 100644 carpincho-wallet/src/components/PrivateKeyPanel.tsx create mode 100644 carpincho-wallet/src/ledger/contracts.ts create mode 100644 carpincho-wallet/test/api/walletService.test.ts create mode 100644 carpincho-wallet/test/components/DarUploadPanel.test.tsx create mode 100644 carpincho-wallet/test/components/LedgerToolsPanel.test.tsx create mode 100644 carpincho-wallet/test/components/PrivateKeyPanel.test.tsx create mode 100644 carpincho-wallet/test/config/runtimeConfig.test.ts create mode 100644 carpincho-wallet/test/ledger/contracts.test.ts create mode 100644 carpincho-wallet/test/utils/json.test.ts create mode 100644 carpincho-wallet/test/vault/exportPrivateKey.test.tsx create mode 100644 carpincho-wallet/test/vault/keypair.test.ts create mode 100644 carpincho-wallet/test/vault/passwordAcceptance.test.tsx create mode 100644 carpincho-wallet/test/vault/passwordStrength.test.ts 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/public/manifest.json b/carpincho-wallet/public/manifest.json index 0182b277..73176b50 100644 --- a/carpincho-wallet/public/manifest.json +++ b/carpincho-wallet/public/manifest.json @@ -26,20 +26,10 @@ }, "content_scripts": [ { - "matches": [ - "http://localhost/*", - "http://127.0.0.1/*", - "https://localhost/*", - "https://127.0.0.1/*" - ], + "matches": [""], "js": ["contentScript.js"], "run_at": "document_start" } ], - "host_permissions": [ - "http://localhost/*", - "http://127.0.0.1/*", - "https://localhost/*", - "https://127.0.0.1/*" - ] + "host_permissions": [""] } diff --git a/carpincho-wallet/src/api/walletService.ts b/carpincho-wallet/src/api/walletService.ts index 285aa5c6..f38daa80 100644 --- a/carpincho-wallet/src/api/walletService.ts +++ b/carpincho-wallet/src/api/walletService.ts @@ -1,4 +1,4 @@ -import { loadRuntimeConfig } from '@/config/runtimeConfig' +import { loadRuntimeConfigAsync } from '@/config/runtimeConfig' export interface JsonRpcErrorObject { code: number @@ -52,9 +52,15 @@ export interface WalletServiceStatusResponse { } } -const rpcUrl = (options?: WalletServiceRequestOptions): string => +export interface DarUploadResponse { + ok: true + vetAllPackages: true + response: unknown +} + +const rpcUrl = async (options?: WalletServiceRequestOptions): Promise => options?.rpcUrl?.trim() === undefined || options.rpcUrl.trim() === '' - ? loadRuntimeConfig().walletServiceRpcUrl + ? (await loadRuntimeConfigAsync()).walletServiceRpcUrl : options.rpcUrl.trim() export const walletServiceRequest = async ( @@ -62,7 +68,7 @@ export const walletServiceRequest = async ( params?: unknown, options?: WalletServiceRequestOptions, ): Promise => { - const response = await fetch(rpcUrl(options), { + const response = await fetch(await rpcUrl(options), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ @@ -110,9 +116,13 @@ export const getWalletServiceNetworkId = async ( type AdminRequestOptions = WalletServiceRequestOptions -const adminUrl = (path: string, options?: AdminRequestOptions): string => { - const base = rpcUrl(options).replace(/\/rpc\/?$/, '') - return `${base}${path}` +// Reuses the configured JSON-RPC base so admin utilities follow the same wallet-service target. +const adminUrl = async (path: string, options?: AdminRequestOptions): Promise => { + const base = + options?.rpcUrl?.trim() === undefined || options.rpcUrl.trim() === '' + ? (await loadRuntimeConfigAsync()).walletServiceRpcUrl + : options.rpcUrl.trim() + return `${base.replace(/\/rpc\/?$/, '')}${path}` } export const walletServiceAdminPost = async ( @@ -120,7 +130,7 @@ export const walletServiceAdminPost = async ( body: Record, options?: AdminRequestOptions, ): Promise => { - const response = await fetch(adminUrl(path, options), { + const response = await fetch(await adminUrl(path, options), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -132,6 +142,23 @@ export const walletServiceAdminPost = async ( return (await response.json()) as TResult } +// Sends compiled DAML archives as raw bytes so wallet-service can keep the ledger token boundary. +export const uploadDarFile = async ( + file: File, + options?: AdminRequestOptions, +): Promise => { + const response = await fetch(await adminUrl('/admin/dars', options), { + method: 'POST', + headers: { 'content-type': 'application/octet-stream' }, + body: file, + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`wallet-service HTTP ${response.status}${text === '' ? '' : `: ${text}`}`) + } + return (await response.json()) as DarUploadResponse +} + export const prepareCreateParty = async ( body: { publicKeyBase64: string; partyHint: string }, options?: AdminRequestOptions, diff --git a/carpincho-wallet/src/cip56/amuletPreapproval.ts b/carpincho-wallet/src/cip56/amuletPreapproval.ts index 9f91ddab..733ff067 100644 --- a/carpincho-wallet/src/cip56/amuletPreapproval.ts +++ b/carpincho-wallet/src/cip56/amuletPreapproval.ts @@ -21,6 +21,10 @@ export interface AmuletPreapprovalActionParams { recordTransaction?: VaultContextValue['recordTransaction'] } +export interface AmuletTapApi { + tapAmulet: (params: AmuletPreapprovalActionParams) => Promise +} + // Detects whether wallet-service returned a real command that needs signing. const hasCommands = (commands: unknown): boolean => Array.isArray(commands) ? commands.length > 0 : commands !== undefined && commands !== null @@ -37,6 +41,27 @@ export const getAmuletPreapprovalStatus = async ( ): Promise => await walletServiceRequest('amulet.preapproval.status', { receiver }) +// Requests the fixed 100 AMT DevNet faucet command while Carpincho keeps the receiver key local. +export const tapAmulet = async ({ + account, + signMessage, + recordTransaction, +}: AmuletPreapprovalActionParams): Promise => { + const { commands, disclosedContracts } = await walletServiceRequest( + 'amulet.tap', + { receiver: account.partyId }, + ) + return await executePreparedCommands({ + account, + commands, + disclosedContracts, + method: 'amulet.tap', + summary: 'Tap 100 AMT', + signMessage, + recordTransaction, + }) +} + // Enables Amulet auto-accept while keeping the receiver signature inside Carpincho. export const createAmuletPreapproval = async ({ account, diff --git a/carpincho-wallet/src/components/AssetsPanel.tsx b/carpincho-wallet/src/components/AssetsPanel.tsx index 07a62bb6..e812cf65 100644 --- a/carpincho-wallet/src/components/AssetsPanel.tsx +++ b/carpincho-wallet/src/components/AssetsPanel.tsx @@ -1,9 +1,12 @@ import { useState } from 'react' +import { type AmuletTapApi, tapAmulet } from '@/cip56/amuletPreapproval' import type { TokenHoldingSummary } from '@/cip56/holdings' import type { Cip56SendApi } from '@/components/SendTokenForm' import { TokenDetailSheet } from '@/components/TokenDetailSheet' import { TokenRow } from '@/components/TokenRow' +import { PrimaryButton } from '@/components/ui/Button' import { LoadingState } from '@/components/ui/LoadingState' +import { toast } from '@/components/ui/toast' import type { Cip56HoldingsApi } from '@/hooks/useTokenHoldings' import { useTokenHoldings } from '@/hooks/useTokenHoldings' import type { AccountPublic } from '@/vault/types' @@ -13,14 +16,19 @@ export interface AssetsPanelProps { account?: AccountPublic api?: Cip56HoldingsApi sendApi?: Cip56SendApi + tapApi?: AmuletTapApi } +const defaultTapApi: AmuletTapApi = { tapAmulet } + // Renders active CIP-56 token holdings as activity-style rows that open a detail modal. -export const AssetsPanel = ({ account, api, sendApi }: AssetsPanelProps): JSX.Element => { +export const AssetsPanel = ({ account, api, sendApi, tapApi }: AssetsPanelProps): JSX.Element => { const vault = useVault() const activeAccount = account ?? vault.primary ?? vault.accounts[0] const [selected, setSelected] = useState(null) + const [tapping, setTapping] = useState(false) const { summaries, loading, error, refresh } = useTokenHoldings(activeAccount, { api }) + const activeTapApi = tapApi ?? defaultTapApi if (activeAccount === undefined) { return ( @@ -30,8 +38,38 @@ export const AssetsPanel = ({ account, api, sendApi }: AssetsPanelProps): JSX.El ) } + // Runs the fixed faucet request and reloads balances once the ledger accepts it. + const onTapAmulet = async (): Promise => { + setTapping(true) + try { + await activeTapApi.tapAmulet({ + account: activeAccount, + signMessage: vault.signMessage, + recordTransaction: vault.recordTransaction, + }) + await refresh() + toast.success('Tapped 100 AMT') + } catch (tapError) { + toast.error(tapError instanceof Error ? tapError.message : 'Amulet tap failed') + } finally { + setTapping(false) + } + } + return (
+
+ { + void onTapAmulet() + }} + > + {tapping ? 'Tapping...' : 'Tap Amulet'} + +
+ {error === undefined ? null : (
{error} diff --git a/carpincho-wallet/src/components/DarUploadPanel.tsx b/carpincho-wallet/src/components/DarUploadPanel.tsx new file mode 100644 index 00000000..08f3a1cd --- /dev/null +++ b/carpincho-wallet/src/components/DarUploadPanel.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { type DarUploadResponse, uploadDarFile } from '@/api/walletService' +import { PrimaryButton } from '@/components/ui/Button' +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 ( +
+ + { + void onUpload() + }} + > + {uploading ? 'Uploading...' : 'Upload DAR'} + + {uploadedFileName === undefined ? null : ( +

+ {uploadedFileName} uploaded +

+ )} +
+ ) +} diff --git a/carpincho-wallet/src/components/HomeTabs.tsx b/carpincho-wallet/src/components/HomeTabs.tsx index 0062b1f1..e841e978 100644 --- a/carpincho-wallet/src/components/HomeTabs.tsx +++ b/carpincho-wallet/src/components/HomeTabs.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState } from 'react' import { ActivityList } from '@/components/ActivityList' import { AssetsPanel } from '@/components/AssetsPanel' +import { LedgerToolsPanel } from '@/components/LedgerToolsPanel' import type { Cip56SendApi } from '@/components/SendTokenForm' import { TransfersPanel } from '@/components/TransfersPanel' import { TabContent, Tabs, TabsList, TabTrigger } from '@/components/ui/Tabs' @@ -69,6 +70,7 @@ export const HomeTabs = ({ ) : null} Activity + Utils + + + ) } diff --git a/carpincho-wallet/src/components/LedgerToolsPanel.tsx b/carpincho-wallet/src/components/LedgerToolsPanel.tsx new file mode 100644 index 00000000..782e6817 --- /dev/null +++ b/carpincho-wallet/src/components/LedgerToolsPanel.tsx @@ -0,0 +1,398 @@ +import { useState } from 'react' +import { DarUploadPanel } from '@/components/DarUploadPanel' +import { Alert } from '@/components/ui/Alert' +import { PrimaryButton, SecondaryButton } from '@/components/ui/Button' +import { TabContent, Tabs, TabsList, TabTrigger } from '@/components/ui/Tabs' +import { INPUT_CLASS, TextInput } from '@/components/ui/TextInput' +import { toast } from '@/components/ui/toast' +import { + type ActiveContract, + createContract, + exerciseContract, + listActiveContracts, +} from '@/ledger/contracts' +import { cn } from '@/utils/cn' +import { formatJsonInput, parseJsonObject, prettyJson } from '@/utils/json' +import type { AccountPublic } from '@/vault/types' +import { useVault } from '@/vault/useVault' + +export interface LedgerToolsApi { + createContract: typeof createContract + exerciseContract: typeof exerciseContract + listActiveContracts: typeof listActiveContracts +} + +interface LedgerToolsPanelProps { + account?: AccountPublic + api?: LedgerToolsApi +} + +const defaultApi: LedgerToolsApi = { createContract, exerciseContract, listActiveContracts } + +const EMPTY_JSON = '{}' + +// Formats a ledger JSON textarea when possible without interrupting partially typed input. +const formatJsonTextarea = ( + value: string, + setValue: (value: string) => void, + label: string, +): void => { + try { + setValue(formatJsonInput(value, label)) + } catch { + return + } +} + +// Renders one active contract with the id first for copying/inspection. +const ContractCard = ({ contract }: { contract: ActiveContract }): JSX.Element => ( +
+
+ + Contract ID + + + {contract.contractId} + +
+
+ Template + + {contract.templateId} + +
+ {contract.createdOffset === undefined ? null : ( +

+ Offset {contract.createdOffset} +

+ )} +
+      {prettyJson(contract.createArgument)}
+    
+
+) + +// Development ledger utility for generic creates and active-contract inspection. +export const LedgerToolsPanel = ({ + account, + api = defaultApi, +}: LedgerToolsPanelProps): JSX.Element => { + const vault = useVault() + const [createTemplateId, setCreateTemplateId] = useState('') + const [createJson, setCreateJson] = useState(EMPTY_JSON) + const [exerciseTemplateId, setExerciseTemplateId] = useState('') + const [exerciseContractId, setExerciseContractId] = useState('') + const [exerciseChoice, setExerciseChoice] = useState('') + const [exerciseJson, setExerciseJson] = useState(EMPTY_JSON) + const [filterTemplateId, setFilterTemplateId] = useState('') + const [contracts, setContracts] = useState([]) + const [busy, setBusy] = useState(false) + const [error, setError] = useState() + const [createdUpdateId, setCreatedUpdateId] = useState() + const [exercisedUpdateId, setExercisedUpdateId] = useState() + + // Reloads ACS for the active party and preserves any optional template filter. + const refreshContracts = async (templateId = filterTemplateId): Promise => { + if (account === undefined) { + return + } + setBusy(true) + setError(undefined) + try { + setContracts( + await api.listActiveContracts({ + partyId: account.partyId, + ...(templateId.trim() === '' ? {} : { templateId: templateId.trim() }), + }), + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + toast.error(message) + } finally { + setBusy(false) + } + } + + // Submits one generic CreateCommand and refreshes ACS so created CIDs are visible. + const onCreate = async (): Promise => { + if (account === undefined) { + return + } + const templateId = createTemplateId.trim() + if (templateId === '') { + setError('Template ID is required') + return + } + setBusy(true) + setError(undefined) + setCreatedUpdateId(undefined) + try { + const createArguments = parseJsonObject(createJson, 'Create arguments') + setCreateJson(prettyJson(createArguments)) + const result = await api.createContract({ + account, + templateId, + createArguments, + signMessage: vault.signMessage, + recordTransaction: vault.recordTransaction, + }) + setCreatedUpdateId(result.updateId) + toast.success('Contract created') + await refreshContracts(templateId) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + toast.error(message) + } finally { + setBusy(false) + } + } + + // Submits one generic ExerciseCommand and refreshes ACS because the choice may archive/create. + const onExercise = async (): Promise => { + if (account === undefined) { + return + } + const templateId = exerciseTemplateId.trim() + const contractId = exerciseContractId.trim() + const choice = exerciseChoice.trim() + if (templateId === '') { + setError('Exercise template ID is required') + return + } + if (contractId === '') { + setError('Contract ID is required') + return + } + if (choice === '') { + setError('Choice is required') + return + } + setBusy(true) + setError(undefined) + setExercisedUpdateId(undefined) + try { + const choiceArgument = parseJsonObject(exerciseJson, 'Choice argument') + setExerciseJson(prettyJson(choiceArgument)) + const result = await api.exerciseContract({ + account, + templateId, + contractId, + choice, + choiceArgument, + signMessage: vault.signMessage, + recordTransaction: vault.recordTransaction, + }) + setExercisedUpdateId(result.updateId) + toast.success('Choice exercised') + await refreshContracts() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + toast.error(message) + } finally { + setBusy(false) + } + } + + if (account === undefined) { + return ( +
+ Create an account before using ledger tools. +
+ ) + } + + return ( + + + Create + Exercise + Contracts + DAR + + +
{ + event.preventDefault() + void onCreate() + }} + > + +