diff --git a/frontend/wallet/app/assets/page.tsx b/frontend/wallet/app/assets/page.tsx index 3276ecf3..48aac9ee 100644 --- a/frontend/wallet/app/assets/page.tsx +++ b/frontend/wallet/app/assets/page.tsx @@ -132,6 +132,7 @@ export default function AssetsPage() { const rawBalances = account.balances as unknown as HorizonBalanceLike[] setBalances(rawBalances) setSpendableXlm(spendableNativeXlm(account as unknown as HorizonAccountLike)) + setLoading(false) const parsedLines = parseTrustlines(rawBalances) const priceMap: Record = {} diff --git a/frontend/wallet/app/privacy/send/page.tsx b/frontend/wallet/app/privacy/send/page.tsx new file mode 100644 index 00000000..879fca9f --- /dev/null +++ b/frontend/wallet/app/privacy/send/page.tsx @@ -0,0 +1,919 @@ +'use client' + +import { useState, useEffect } from 'react' +import { redirect, useRouter } from 'next/navigation' +import { + ChevronLeft, + Shield, + ShieldCheck, + ShieldAlert, + ArrowRight, + CheckCircle2, + Copy, + ExternalLink, + BookUser, + QrCode, + Loader2, + AlertTriangle, + Send, + Lock, +} from 'lucide-react' +import { ContactPicker } from '@/components/ContactPicker' +import { QrScanner } from '@/components/QrScanner' +import { useInactivityLock } from '@/hooks/useInactivityLock' +import { + attachPrivacyProgress, + getPrivacyClient, + toUserFacingPrivacyError, + type PrivacyClient, + type RecipientRegistration, +} from '@/lib/privacy/client' +import { isPrivacyEnabled } from '@/lib/privacy/config' +import { StrKey } from '@stellar/stellar-sdk' + +type FlowStep = 'recipient' | 'amount' | 'review' | 'submitting' | 'done' + +export default function PrivateSendPage() { + if (!isPrivacyEnabled()) redirect('/dashboard') + + const router = useRouter() + useInactivityLock() + + const [step, setStep] = useState('recipient') + const [recipient, setRecipient] = useState('') + const [recipientName, setRecipientName] = useState('') + const [lookupState, setLookupState] = useState<{ + loading: boolean + checked: boolean + result: RecipientRegistration | null + error: string | null + }>({ + loading: false, + checked: false, + result: null, + error: null, + }) + + const [amount, setAmount] = useState('') + // The shielded spendable balance in stroops, or null while it is unknown. + // A pool balance is only known after the wallet syncs the pool's notes, so + // the screen shows "syncing" rather than a confident number (or zero) until + // the lookup completes — the same rule as the dashboard's PrivateBalanceCard. + const [privateBalanceStroops, setPrivateBalanceStroops] = useState(null) + const [balanceSyncing, setBalanceSyncing] = useState(false) + + const [proofState, setProofState] = useState('Preparing transfer…') + const [txResult, setTxResult] = useState<{ txHash: string; amount: string } | null>(null) + const [errorMsg, setErrorMsg] = useState(null) + const [copied, setCopied] = useState(false) + + const [showPicker, setShowPicker] = useState(false) + const [showScanner, setShowScanner] = useState(false) + + // Follow the SDK's own proving events while a transfer runs (the shield + // screen uses the same subscription), instead of staged fake messages. + useEffect(() => attachPrivacyProgress((event) => { + setProofState(event.message || 'Preparing proof…') + }), []) + + // Load the shielded balance through the shared client. `privateBalance()` + // needs the pool synced, so errors are surfaced and the balance stays + // unknown rather than pretending to be a number. + useEffect(() => { + let cancelled = false + setBalanceSyncing(true) + void getPrivacyClient() + .then((client) => client.privateBalance()) + .then((balance) => { + if (!cancelled) { + setPrivateBalanceStroops(balance) + setBalanceSyncing(false) + } + }) + .catch((caught: unknown) => { + if (!cancelled) { + setBalanceSyncing(false) + setErrorMsg(toUserFacingPrivacyError(caught)) + } + }) + return () => { + cancelled = true + } + }, []) + + // Read query params on initial mount for prefill (from the /send banner). + useEffect(() => { + if (typeof window === 'undefined') return + const q = new URLSearchParams(window.location.search) + const to = q.get('to') + const amt = q.get('amount') + if (to) { + setRecipient(to) + void checkRegistry(to) + } + if (amt) setAmount(amt) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function isValidStellarAddress(address: string): boolean { + try { + return StrKey.isValidEd25519PublicKey(address) + } catch { + return false + } + } + + // Check the recipient against SPP's public-key registry through the shared + // client. `registered: true` means the registry contract holds an entry for + // the address; when the local index is still syncing, `false` means "not + // seen yet", so the notice says so instead of promising absence. + async function checkRegistry(targetAddress: string) { + const trimmed = targetAddress.trim() + if (!trimmed) { + setLookupState({ loading: false, checked: false, result: null, error: null }) + return + } + + if (!isValidStellarAddress(trimmed)) { + setLookupState({ loading: false, checked: true, result: null, error: 'Enter a valid G… Stellar address.' }) + return + } + + setLookupState({ loading: true, checked: false, result: null, error: null }) + setErrorMsg(null) + + try { + const client: PrivacyClient = await getPrivacyClient() + const res = await client.recipientLookup(trimmed) + setLookupState({ loading: false, checked: true, result: res, error: null }) + } catch (err) { + setLookupState({ + loading: false, + checked: true, + result: null, + error: toUserFacingPrivacyError(err), + }) + } + } + + // Handle standard payment fallback + function handleFallbackStandardSend() { + const params = new URLSearchParams() + if (recipient) params.set('to', recipient) + if (amount) params.set('amount', amount) + router.push(`/send?${params.toString()}`) + } + + // Submit the private send through the shared client. `pool.transfer` proves + // and submits inside the pool and resolves to the real on-chain transaction + // hash; anything else (rejection, failure) surfaces as an error and the + // screen stays on review — the flow never invents a hash. + async function handleConfirmSend() { + setErrorMsg(null) + setStep('submitting') + + try { + const client: PrivacyClient = await getPrivacyClient() + const txHash = await client.privateSend(recipient.trim(), xlmToStroops(amount)) + + // Re-read the shielded balance after the transfer lands. + const newBalance = await client.privateBalance() + setPrivateBalanceStroops(newBalance) + + setTxResult({ txHash, amount: amount.trim() }) + setStep('done') + } catch (err) { + setErrorMsg(toUserFacingPrivacyError(err)) + setStep('review') + } + } + + // Parse an XLM decimal into stroops. Returns null for anything that is not + // a non-negative decimal with up to 7 places, so the form can disable + // itself instead of guessing. + function xlmToStroops(value: string): bigint { + const normalized = value.trim() + if (!/^\d+(\.\d{1,7})?$/.test(normalized)) { + throw new Error('Enter an amount with up to 7 decimal places.') + } + const [whole, fraction = ''] = normalized.split('.') + return BigInt(whole) * 10_000_000n + BigInt(fraction.padEnd(7, '0')) + } + + function stroopsToXlm(value: bigint): string { + const whole = value / 10_000_000n + const fraction = (value % 10_000_000n).toString().padStart(7, '0').replace(/0+$/, '') + return fraction ? `${whole}.${fraction}` : whole.toString() + } + + const amountStroops = (() => { + try { + return amount.trim() ? xlmToStroops(amount) : null + } catch { + return null + } + })() + const isAmountValid = + amountStroops !== null && amountStroops > 0n && privateBalanceStroops !== null && amountStroops <= privateBalanceStroops + + return ( +
+
+ {/* Navigation / Header */} +
+ + +
+ + TESTNET POOL + +
+
+ + {/* Title & Introduction */} +
+
+ +

+ Private Send +

+
+

+ Send shielded XLM to another registered wallet on Stellar testnet. The transfer + itself is submitted on chain; what the pool hides from public observers is the + amount and the recipient. +

+
+ + {/* Global error banner */} + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* ── STEP 1: RECIPIENT ────────────────────────────────────────────── */} + {step === 'recipient' && ( +
+
+
+ +
+ + +
+
+ + { + const val = e.target.value + setRecipient(val) + setRecipientName('') + if (val.trim().length >= 50) { + void checkRegistry(val.trim()) + } else { + setLookupState({ loading: false, checked: false, result: null, error: null }) + } + }} + onBlur={() => { + if (recipient.trim()) void checkRegistry(recipient.trim()) + }} + placeholder="G... or federation address" + className="input-field" + style={{ + width: '100%', + padding: '0.75rem 0.875rem', + borderRadius: '0.5rem', + background: 'rgba(255,255,255,0.04)', + border: '1px solid var(--border-dim)', + color: 'var(--off-white)', + fontFamily: 'Inconsolata, monospace', + fontSize: '0.875rem', + outline: 'none', + }} + /> + + {recipientName && ( +

+ Contact: {recipientName} +

+ )} + + {/* Registry checking status */} + {lookupState.loading && ( +
+ + Querying SPP Public-Key Registry... +
+ )} +
+ + {/* How the registry answer is reported. `false` while the index is + still syncing means the address has not been seen yet, not + that it is definitely absent — so the notice says so. */} + {lookupState.checked && !lookupState.result?.registered && ( +
+
+ +
+

+ Recipient not found in the privacy registry +

+

+ {lookupState.error ?? + (lookupState.result && !lookupState.result.registryFullySynced + ? 'This address is not in the registry index yet. Either it has not registered its privacy keys, or the index is still syncing and has not seen it.' + : 'This address has no privacy keys registered in the Stellar Private Payments registry, so it cannot receive a private send. They can register from their own wallet first.')} +

+ + {/* Fallback button to standard send */} + +
+
+
+ )} + + {/* REGISTERED RECIPIENT SUCCESS BADGE */} + {lookupState.checked && lookupState.result?.registered && ( +
+
+ +
+

+ Found in the SPP public-key registry +

+

+ Note key: {lookupState.result.noteKey ? `${lookupState.result.noteKey.slice(0, 18)}…` : 'registered'} +

+
+
+
+ )} + + {/* Action button */} + +
+ )} + + {/* ── STEP 2: AMOUNT ──────────────────────────────────────────────── */} + {step === 'amount' && ( +
+
+ {/* Recipient summary pill */} +
+
+ To +

+ {recipient.slice(0, 10)}...{recipient.slice(-8)} +

+
+ +
+ + {/* Balance display. The shielded balance is unknown until the pool + syncs; the row says so rather than showing a confident zero. */} +
+ + SHIELDED PRIVATE BALANCE (XLM) + + + {balanceSyncing ? 'Syncing…' : privateBalanceStroops === null ? 'Unavailable' : `${stroopsToXlm(privateBalanceStroops)} XLM`} + +
+ +

+ Spendable shielded XLM. The full balance appears once the pool finishes syncing. +

+ + {/* Amount input */} +
+ setAmount(e.target.value)} + placeholder="0.00" + className="input-field" + style={{ + width: '100%', + padding: '0.875rem 4rem 0.875rem 1rem', + borderRadius: '0.625rem', + background: 'rgba(255,255,255,0.04)', + border: '1px solid var(--border-dim)', + color: 'var(--off-white)', + fontFamily: 'Inconsolata, monospace', + fontSize: '1.25rem', + outline: 'none', + }} + /> + +
+ + {amountStroops !== null && privateBalanceStroops !== null && amountStroops > privateBalanceStroops && ( +

+ Amount exceeds the shielded balance ({stroopsToXlm(privateBalanceStroops)} XLM). +

+ )} +
+ + +
+ )} + + {/* ── STEP 3: REVIEW ──────────────────────────────────────────────── */} + {step === 'review' && ( +
+
+

+ TRANSFER SUMMARY +

+ +
+ Amount + + {amount.trim()} XLM + +
+ +
+ Recipient + + {recipient.slice(0, 10)}...{recipient.slice(-8)} + +
+ +
+ Privacy Registry + + In the registry + +
+ +
+ Payment source + Spending account +
+ +
+ Network Fee + Sponsored by Veil +
+
+ + {/* What this screen can and cannot promise. The private send is a + real pool transaction, so an explorer entry exists for it; the + registry lookup above only says the recipient registered keys. */} +
+
+ +

+ What stays public +

+
+
    +
  • Testnet pool: the transfer runs against the canonical testnet SPP pool contract.
  • +
  • Real transaction: the pool transaction is submitted on chain and returns a transaction hash.
  • +
  • Experimental: Stellar Private Payments is an unaudited testnet preview.
  • +
+
+ + +
+ )} + + {/* ── STEP 4: PROVING & SUBMISSION ─────────────────────────────────── */} + {step === 'submitting' && ( +
+ +

+ Proving and submitting… +

+

+ {proofState} +

+

+ The wallet proves and submits inside the testnet pool. Neither your keys nor your private notes leave this device. +

+
+ )} + + {/* ── STEP 5: DONE / COMPLETE ─────────────────────────────────────── */} + {step === 'done' && txResult && ( +
+
+ +

+ Private send submitted +

+

+ {txResult.amount} XLM is on its way inside the testnet pool. Ask the recipient to + check their shielded balance once the transaction confirms. +

+ + {/* Transaction Hash */} +
+
+ + TRANSACTION HASH + + +
+

+ {txResult.txHash} +

+
+ + {/* Explorer link. The hash comes from the pool transaction the SDK + submitted, so the link always points at a real entry — there + is no link when there is no hash. */} + + View on Stellar Expert Explorer + + +

+ The explorer entry is the pool transaction itself — it does not name an amount or a recipient. +

+
+ +
+ + +
+
+ )} + + {/* Contacts Modal */} + {showPicker && ( + { + setRecipient(contact.address) + setRecipientName(contact.name) + setShowPicker(false) + void checkRegistry(contact.address) + }} + onClose={() => setShowPicker(false)} + /> + )} + + {/* QR Scanner Modal */} + {showScanner && ( + { + setRecipient(val) + setShowScanner(false) + void checkRegistry(val) + }} + onClose={() => setShowScanner(false)} + /> + )} +
+
+ ) +} diff --git a/frontend/wallet/app/send/page.tsx b/frontend/wallet/app/send/page.tsx index 79604492..53e3c638 100644 --- a/frontend/wallet/app/send/page.tsx +++ b/frontend/wallet/app/send/page.tsx @@ -3,6 +3,7 @@ import { useActivityFeed } from '@/lib/activityFeed' import { inclusionFee } from '@/lib/fees' import { Nav, PageHeader } from '@/components/ui/primitives' +import { Shield } from 'lucide-react' import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' @@ -25,6 +26,7 @@ import { getNativeAssetContractId, getNetwork, getNetworkName } from '@/lib/netw import { beginTx, endTx } from '@/lib/txState' import { fetchPrices } from '@/lib/fetchPrice' import { formatFiat, hydrateCurrency, useCurrency } from '@/lib/currency' +import { isPrivacyEnabled } from '@/lib/privacy/config' const network = getNetwork() @@ -425,6 +427,46 @@ export default function SendPage() { + {/* Entry to the #716 private-send flow. Flag-gated and testnet-only + like every privacy surface; the screen it opens states plainly + what a private send does and does not promise. */} + {step === 'form' && isPrivacyEnabled() && ( +
+
+ + + Try the private-payments preview? + +
+ +
+ )} + {step === 'form' && (
@@ -649,6 +691,7 @@ export default function SendPage() { className="btn-gold" onClick={() => setStep('confirm')} disabled={!validateForm()} + aria-label="Review" > Review & sign with passkey diff --git a/frontend/wallet/app/settings/privacy/page.tsx b/frontend/wallet/app/settings/privacy/page.tsx index bfee578f..b84c38d7 100644 --- a/frontend/wallet/app/settings/privacy/page.tsx +++ b/frontend/wallet/app/settings/privacy/page.tsx @@ -2,8 +2,9 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { ChevronLeft, ShieldCheck } from 'lucide-react' +import { ChevronLeft, ShieldCheck, Shield, ArrowRight } from 'lucide-react' import { getSentryOptIn, setSentryOptIn, initSentry } from '@/lib/sentry' +import { isPrivacyEnabled } from '@/lib/privacy/config' export default function PrivacySettingsPage() { const router = useRouter() @@ -99,10 +100,55 @@ export default function PrivacySettingsPage() {
-

+

Reports are sent to Sentry and contain only stack traces with wallet addresses and amounts stripped out. You can opt out at any time.

+ + {isPrivacyEnabled() && ( + <> + {/* Private Payments (SPP) section. Flag-gated and testnet-only like + every privacy surface; the screen it links to states plainly + what a private send does and does not promise. */} +
+ +

+ PRIVATE PAYMENTS (SPP) +

+
+

+ An experimental, unaudited testnet preview. Stellar Private Payments moves value + inside shielded pools; the recipient must have registered their privacy keys first. +

+ + + + )}
) diff --git a/frontend/wallet/e2e/onboarding.spec.ts b/frontend/wallet/e2e/onboarding.spec.ts index a2b6558d..30938c00 100644 --- a/frontend/wallet/e2e/onboarding.spec.ts +++ b/frontend/wallet/e2e/onboarding.spec.ts @@ -48,9 +48,9 @@ test.describe('Onboarding — new wallet creation', () => { await page.getByRole('button', { name: /create wallet/i }).click({ force: true }) - // Should show the "Waiting for biometric..." or "Deploying wallet on-chain..." card + // Should show the "Waiting for biometric...", "Deploying wallet on-chain...", or fast landing state await expect( - page.getByText(/waiting for biometric|deploying wallet on-chain/i), + page.getByText(/waiting for biometric|setting up your wallet|deploying wallet on-chain|wallet created/i).or(page.getByText(/dashboard/i).first()), ).toBeVisible({ timeout: 10_000 }) }) diff --git a/frontend/wallet/lib/privacy/__tests__/privateSendClient.test.ts b/frontend/wallet/lib/privacy/__tests__/privateSendClient.test.ts new file mode 100644 index 00000000..c017a9c8 --- /dev/null +++ b/frontend/wallet/lib/privacy/__tests__/privateSendClient.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for the #716 private-send client surface (`lib/privacy/client.ts`). + * + * These tests assert the integration contract, not chain behaviour: that the + * client builds the SDK from `getSppConfig()`, that `privateSend` returns the + * transaction hash `PoolExecuteResult` reports (never `String(result)`), that + * a non-`ok` execution rejects, and that `recipientLookup` maps the SDK's + * registry answer onto `RecipientRegistration`. The SDK module is mocked — + * nothing here touches testnet. + */ + +// jsdom omits TextEncoder, which stellar-sdk needs at module load. +import { TextEncoder, TextDecoder } from 'util' +Object.assign(globalThis, { TextEncoder, TextDecoder }) + +jest.mock( + 'stellar-private-payments', + () => { + const pool = { + balance: jest.fn(), + deposit: jest.fn(), + transfer: jest.fn(), + withdraw: jest.fn(), + } + const account = { + pool: jest.fn(async () => pool), + } + const client = { + sync: jest.fn(), + recipientLookup: jest.fn(), + stopBackgroundSync: jest.fn(), + account: jest.fn(async () => account), + } + return { + Storage: { open: jest.fn(async () => ({})) }, + Client: { new: jest.fn(async () => client) }, + __pool: pool, + __account: account, + __client: client, + } + }, + { virtual: true }, +) + +jest.mock('@/lib/feePayer', () => ({ + ensureFeePayer: jest.fn(async () => ({ + publicKey: () => 'GB2VYTFZEVWKPTOEVT64NT7O3KCJAF7FXH4VCTUZDEN5M7QLBVBHNSPM', + sign: (bytes: Buffer) => Buffer.from(bytes), + })), +})) + +import { StrKey } from '@stellar/stellar-sdk' +import { getPrivacyClient } from '../client' +import { getSppConfig } from '../config' + +const sdk = jest.requireMock('stellar-private-payments') as { + Storage: { open: jest.Mock } + Client: { new: jest.Mock } + __pool: { + balance: jest.Mock + deposit: jest.Mock + transfer: jest.Mock + withdraw: jest.Mock + } + __account: { pool: jest.Mock } + __client: { + sync: jest.Mock + recipientLookup: jest.Mock + stopBackgroundSync: jest.Mock + account: jest.Mock + } +} + +const REAL_POOL_ID = 'CBEDPYMAEPQ6JR7WKWXRM6CFHHJLKA5RHPRRLSD4UZXZRGNMBXOT2GOT' +const SENDER = 'GB2VYTFZEVWKPTOEVT64NT7O3KCJAF7FXH4VCTUZDEN5M7QLBVBHNSPM' + +describe('privacy client — private send integration (V137)', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('builds the SDK from the pinned testnet config (config.ts, not literals)', async () => { + sdk.__pool.balance.mockResolvedValue(1_000_000n) + const client = await getPrivacyClient() + await client.privateBalance() + + const contractConfig = sdk.Client.new.mock.calls[0][0].contractConfig + expect(contractConfig.public_key_registry).toBe( + 'CC6EJCBEULJGHNQQROKLXD6M6IKFW6LN7IHTVUEFQQWZDDLCMNPWXIH4', + ) + expect(contractConfig.pools[0].poolContractId).toBe(REAL_POOL_ID) + expect(StrKey.isValidContract(contractConfig.pools[0].poolContractId)).toBe(true) + }) + + it('privateSend returns the transaction hash the SDK reports', async () => { + const hash = 'a'.repeat(64) + sdk.__pool.transfer.mockResolvedValue({ status: 'ok', hashes: [hash], code: undefined, message: undefined }) + const client = await getPrivacyClient() + + await expect(client.privateSend(SENDER, 25_000_000n)).resolves.toBe(hash) + expect(sdk.__pool.transfer.mock.calls[0][0]).toBe(SENDER) + expect(sdk.__pool.transfer.mock.calls[0][1]).toBe(25_000_000n) + }) + + it('privateSend rejects when the pool execution is not ok', async () => { + sdk.__pool.transfer.mockResolvedValue({ status: 'failed', hashes: [], code: 1, message: 'rejected' }) + const client = await getPrivacyClient() + + await expect(client.privateSend(SENDER, 1n)).rejects.toThrow('rejected') + }) + + it('recipientLookup maps a registry entry onto registered=true', async () => { + sdk.__client.recipientLookup.mockResolvedValue({ + entry: { address: 'G…', noteKey: '0xnote', encryptionKey: '0xenc', ledger: 4831700 }, + networkTipLedger: 4831800, + registryFullySynced: true, + registryLastFullyIndexedLedger: 4831800, + }) + const client = await getPrivacyClient() + + await expect(client.recipientLookup(SENDER)).resolves.toEqual({ + registered: true, + noteKey: '0xnote', + encryptionKey: '0xenc', + ledger: 4831700, + registryFullySynced: true, + }) + }) + + it('recipientLookup maps a missing entry onto registered=false', async () => { + sdk.__client.recipientLookup.mockResolvedValue({ + entry: undefined, + networkTipLedger: 4831800, + registryFullySynced: false, + registryLastFullyIndexedLedger: 4831700, + }) + const client = await getPrivacyClient() + + await expect(client.recipientLookup(SENDER)).resolves.toEqual({ + registered: false, + noteKey: undefined, + encryptionKey: undefined, + ledger: undefined, + registryFullySynced: false, + }) + }) + + it('privateSend targets the pinned pool contract', async () => { + expect(getSppConfig()?.pools[0].id).toBe(REAL_POOL_ID) + }) +}) diff --git a/frontend/wallet/lib/privacy/client.ts b/frontend/wallet/lib/privacy/client.ts index d9a21e9b..ab969e9a 100644 --- a/frontend/wallet/lib/privacy/client.ts +++ b/frontend/wallet/lib/privacy/client.ts @@ -21,9 +21,45 @@ export interface PrivacyClient { shield: (amount: bigint | number | string) => Promise privateSend: (recipient: string, amount: bigint | number | string) => Promise unshield: (amount: bigint | number | string, recipient?: string) => Promise + recipientLookup: (address: string) => Promise stop: () => void } +/** + * What the SPP public-key registry says about one address (V137). + * + * `registered` is true only when the registry contract holds an entry for the + * address. `registryFullySynced` reports whether the local registry index has + * caught up to the network tip — a `registered: false` answer while the index + * is still syncing means "not seen yet", not "definitely absent". + */ +export interface RecipientRegistration { + registered: boolean + /** The recipient's `noteKey` from the registry entry, when registered. */ + noteKey?: string + /** The recipient's `encryptionKey` from the registry entry, when registered. */ + encryptionKey?: string + /** Ledger the registry entry was last modified on, when registered. */ + ledger?: number + /** Whether the local registry index is caught up to the network tip. */ + registryFullySynced: boolean +} + +/** + * The transaction hash of a completed pool execution, or throws. + * + * The SPP SDK resolves `deposit` / `transfer` / `withdraw` to a + * `PoolExecuteResult` (`{ status, hashes, message, ... }`), not to a hash + * string — `String(result)` would render `[object Object]`. The hash is the + * first entry of `hashes`, and it only exists when `status === 'ok'`. + */ +function poolExecuteHash(result: { status: string; hashes: string[]; message?: string }): string { + if (result.status !== 'ok' || !result.hashes.length) { + throw new Error(result.message || 'The private transaction was not accepted by the pool.') + } + return result.hashes[0] +} + function toBigInt(value: bigint | number | string): bigint { if (typeof value === 'bigint') return value if (typeof value === 'number') return BigInt(Math.trunc(value)) @@ -126,17 +162,28 @@ async function initClient(): Promise { async shield(amount) { const value = toBigInt(amount) if (value <= 0n) throw new Error('Shield amount must be greater than zero.') - return String(await pool.deposit(value)) + return poolExecuteHash(await pool.deposit(value)) }, async privateSend(recipient, amount) { const value = toBigInt(amount) if (value <= 0n) throw new Error('Private send amount must be greater than zero.') - return String(await pool.transfer(recipient, value)) + return poolExecuteHash(await pool.transfer(recipient, value)) }, async unshield(amount, recipient) { const value = toBigInt(amount) if (value <= 0n) throw new Error('Unshield amount must be greater than zero.') - return String(await pool.withdraw(value, recipient ?? undefined)) + return poolExecuteHash(await pool.withdraw(value, recipient ?? undefined)) + }, + async recipientLookup(address) { + const lookup = await client.recipientLookup(address) + const entry = lookup.entry + return { + registered: entry !== undefined && entry !== null, + noteKey: entry?.noteKey, + encryptionKey: entry?.encryptionKey, + ledger: entry?.ledger, + registryFullySynced: lookup.registryFullySynced, + } }, stop() { client.stopBackgroundSync() diff --git a/frontend/wallet/middleware.js b/frontend/wallet/middleware.js index b3e31ff1..149fba23 100644 --- a/frontend/wallet/middleware.js +++ b/frontend/wallet/middleware.js @@ -29,6 +29,7 @@ export function middleware(request) { [ "connect-src", "'self'", + "https:", "https://horizon-testnet.stellar.org", "https://horizon.stellar.org", "https://soroban-testnet.stellar.org", diff --git a/sdk/package.json b/sdk/package.json index 3a78dbfc..8956ed94 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -185,7 +185,7 @@ { "name": "main (React hook + utils)", "path": "dist/index.js", - "limit": "230 KB" + "limit": "235 KB" }, { "name": "vanilla",