diff --git a/frontend/wallet/app/privacy/unshield/amounts.ts b/frontend/wallet/app/privacy/unshield/amounts.ts new file mode 100644 index 00000000..5776d865 --- /dev/null +++ b/frontend/wallet/app/privacy/unshield/amounts.ts @@ -0,0 +1,46 @@ +/** + * The arithmetic and the asset list behind the unshield screen. + * + * Separate from `page.tsx` so it can be tested without rendering, which is how + * `app/swap/direction.ts` and `app/earn/prefill.ts` are arranged. + */ + +import { getSppConfig } from '@/lib/privacy/config' +import type { VeilNetworkName } from '@/lib/network' + +/** + * What can actually be withdrawn. + * + * Derived from the pools the project pins in `lib/privacy/config.ts`, never + * from a list written by hand. Both configured testnet pools are + * `assetKind: 'native'`, so XLM is the only answer today — offering USDC or + * EURC would offer a pool that does not exist, and nobody can unshield from + * one of those. + */ +export function withdrawableAssetCodes(network: VeilNetworkName): string[] { + const pools = getSppConfig(network)?.pools ?? [] + const codes = new Set() + for (const pool of pools) if (pool.assetKind === 'native') codes.add('XLM') + return [...codes] +} + +/** + * XLM as typed, to stroops as the pool counts them. + * + * Returns null rather than NaN or a silent truncation: `parseFloat` accepts + * `"1.2.3"`, `"1e9"` and `" 12abc"`, and this value decides how much money + * leaves a shielded pool. + */ +export function xlmToStroops(value: string): bigint | null { + const trimmed = value.trim() + if (!/^\d+(\.\d{1,7})?$/.test(trimmed)) return null + const [whole, fraction = ''] = trimmed.split('.') + return BigInt(whole) * 10_000_000n + BigInt(fraction.padEnd(7, '0')) +} + +/** Stroops back to a display string, without trailing zeros. */ +export 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}` +} diff --git a/frontend/wallet/app/privacy/unshield/page.tsx b/frontend/wallet/app/privacy/unshield/page.tsx new file mode 100644 index 00000000..f11770ab --- /dev/null +++ b/frontend/wallet/app/privacy/unshield/page.tsx @@ -0,0 +1,546 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { Horizon, StrKey } from '@stellar/stellar-sdk' +import { Nav, Label, Amount as AmountDisplay, TokenIcon } from '@/components/ui/primitives' +import { useInactivityLock } from '@/hooks/useInactivityLock' +import { walletLocal, walletSession } from '@/lib/walletStorage' +import { getNetwork } from '@/lib/network' +import { isPrivacyEnabled } from '@/lib/privacy/config' +import { withdrawableAssetCodes, xlmToStroops, stroopsToXlm } from './amounts' +import { + attachPrivacyProgress, + getPrivacyClient, + toUserFacingPrivacyError, + type PrivacyClient, +} from '@/lib/privacy/client' +import { explorerNetworkSegment } from '@/lib/about' + +type Step = 'amount' | 'review' | 'proving' | 'complete' | 'error' + +export default function UnshieldPage() { + const router = useRouter() + useInactivityLock() + + const [step, setStep] = useState('amount') + const [amount, setAmount] = useState('') + // null means "we could not read it", which is not the same as zero — a zero + // shown for an unreadable balance invites someone to try to withdraw nothing. + const [privateBalanceStroops, setPrivateBalanceStroops] = useState(null) + const [publicBalance, setPublicBalance] = useState('0.0000000') + + // Destination account state + const [ownSpendingAddress, setOwnSpendingAddress] = useState('') + const [destinationAddress, setDestinationAddress] = useState('') + const [isCustomRecipient, setIsCustomRecipient] = useState(false) + + // Transaction & Proving state + const [provingStatus, setProvingStatus] = useState('Generating proof...') + const [txHash, setTxHash] = useState(null) + const [sentAmount, setSentAmount] = useState('') + const [errorMessage, setErrorMessage] = useState(null) + const [isLoadingBalances, setIsLoadingBalances] = useState(true) + + const network = getNetwork() + const assetCodes = withdrawableAssetCodes(network.name) + const assetCode = assetCodes[0] ?? 'XLM' + + // Progress comes from the SPP client as it works, rather than being narrated + // on a timer. If it says nothing, the screen says the generic line. + useEffect( + () => attachPrivacyProgress((event) => setProvingStatus(event.message)), + [], + ) + + // Load account addresses and initial balances + useEffect(() => { + if (!isPrivacyEnabled(network.name)) { + setErrorMessage('Privacy features are currently available on Stellar Testnet only.') + setStep('error') + return + } + + const walletAddr = walletSession.getItem('invisible_wallet_address') || walletLocal.getItem('invisible_wallet_address') + const signerPub = walletSession.getItem('veil_signer_secret') + ? undefined + : walletLocal.getItem('veil_signer_public_key') || walletSession.getItem('veil_signer_public_key') + + const defaultDest = signerPub || walletAddr || '' + setOwnSpendingAddress(defaultDest) + setDestinationAddress(defaultDest) + + async function loadBalances() { + setIsLoadingBalances(true) + try { + const client = await getPrivacyClient() + setPrivateBalanceStroops(await client.privateBalance()) + + // Load public balance if address exists + if (defaultDest && StrKey.isValidEd25519PublicKey(defaultDest)) { + const server = new Horizon.Server(network.horizonUrl) + const account = await server.loadAccount(defaultDest) + const assetBal = account.balances.find((b) => b.asset_type === 'native') + setPublicBalance(assetBal ? assetBal.balance : '0.0000000') + } + } catch (err) { + // The shielded balance stays null, which renders as "Unavailable" and + // blocks the flow, rather than as a zero the user might act on. + setErrorMessage(toUserFacingPrivacyError(err)) + } finally { + setIsLoadingBalances(false) + } + } + + void loadBalances() + }, [network]) + + const amountStroops = xlmToStroops(amount) + const isAmountExceeding = + amountStroops !== null && privateBalanceStroops !== null && amountStroops > privateBalanceStroops + const isAmountValid = + amountStroops !== null && amountStroops > 0n && privateBalanceStroops !== null && !isAmountExceeding + // A withdrawal leaves the pool for a real account. "At least five characters" + // would send one to a typo, and there is nothing to undo it with. + const isDestinationValid = StrKey.isValidEd25519PublicKey(destinationAddress.trim()) + + const canProceedToReview = isAmountValid && isDestinationValid && !isLoadingBalances + + const handleSetPercent = (percent: number) => { + if (privateBalanceStroops === null || privateBalanceStroops <= 0n) return + setAmount(stroopsToXlm((privateBalanceStroops * BigInt(percent)) / 100n)) + } + + const handleStartUnshield = async () => { + // Strict pre-proof validation check + if (!isAmountValid || !isDestinationValid || amountStroops === null) return + + setStep('proving') + setProvingStatus('Initializing zero-knowledge prover...') + setErrorMessage(null) + + try { + const client: PrivacyClient = await getPrivacyClient() + const hash = await client.unshield(amountStroops, destinationAddress.trim()) + + setTxHash(hash) + setSentAmount(stroopsToXlm(amountStroops)) + + // Re-read both balances rather than doing arithmetic on them: the pool + // decides what actually moved, and a locally computed figure beside a + // real transaction hash is a number nobody can reconcile. + setPrivateBalanceStroops(await client.privateBalance()) + if (StrKey.isValidEd25519PublicKey(destinationAddress.trim())) { + try { + const server = new Horizon.Server(network.horizonUrl) + const account = await server.loadAccount(destinationAddress.trim()) + const assetBal = account.balances.find((b) => b.asset_type === 'native') + if (assetBal) setPublicBalance(assetBal.balance) + } catch { + // The withdrawal succeeded; only the follow-up read did not. Leave the + // previous figure rather than inventing one. + } + } + + setStep('complete') + } catch (err) { + setErrorMessage(toUserFacingPrivacyError(err)) + setStep('error') + } + } + + return ( +
+
+ ) +} diff --git a/frontend/wallet/app/settings/privacy/page.tsx b/frontend/wallet/app/settings/privacy/page.tsx index b84c38d7..617bcf55 100644 --- a/frontend/wallet/app/settings/privacy/page.tsx +++ b/frontend/wallet/app/settings/privacy/page.tsx @@ -147,6 +147,34 @@ export default function PrivacySettingsPage() { + + )} diff --git a/frontend/wallet/lib/__tests__/unshield.test.ts b/frontend/wallet/lib/__tests__/unshield.test.ts new file mode 100644 index 00000000..6ad9d720 --- /dev/null +++ b/frontend/wallet/lib/__tests__/unshield.test.ts @@ -0,0 +1,83 @@ +import { TextEncoder, TextDecoder } from 'util' +Object.assign(globalThis, { TextEncoder, TextDecoder }) + +import { isPrivacyEnabled, getSppConfig } from '../privacy/config' +import { + stroopsToXlm, + withdrawableAssetCodes, + xlmToStroops, +} from '../../app/privacy/unshield/amounts' + +/** + * Unshield moves money out of a shielded pool to a public address, so the + * things worth pinning are the ones that decide whether it can run at all and + * how much leaves: the mainnet lockout, which pools really exist, and the + * amount arithmetic. + */ + +describe('mainnet lockout', () => { + it('is off on mainnet whatever the feature flag says', () => { + // Unconditional, because there is no mainnet SPP deployment to talk to. + expect(isPrivacyEnabled('mainnet')).toBe(false) + }) + + it('has no mainnet SPP configuration to point at', () => { + expect(getSppConfig('mainnet')).toBeNull() + }) +}) + +describe('the pools that actually exist', () => { + it('pins two testnet pools, both of them XLM', () => { + const config = getSppConfig('testnet') + expect(config).not.toBeNull() + expect(config!.pools).toHaveLength(2) + for (const pool of config!.pools) expect(pool.assetKind).toBe('native') + }) + + it('offers XLM and nothing else to withdraw', () => { + // The screen used to offer USDC and EURC from a hand-written list. Neither + // has a pool, so both were a flow that could only fail — and one of the two + // issuer addresses in that list was not a valid Stellar address at all. + expect(withdrawableAssetCodes('testnet')).toEqual(['XLM']) + }) + + it('offers nothing on mainnet', () => { + expect(withdrawableAssetCodes('mainnet')).toEqual([]) + }) +}) + +describe('amount arithmetic', () => { + it('converts whole and fractional XLM to stroops', () => { + expect(xlmToStroops('1')).toBe(10_000_000n) + expect(xlmToStroops('0.0000001')).toBe(1n) + expect(xlmToStroops('12.5')).toBe(125_000_000n) + }) + + it('round-trips through the display form', () => { + for (const value of ['1', '0.0000001', '12.5', '1000000']) { + expect(stroopsToXlm(xlmToStroops(value)!)).toBe(value) + } + }) + + it('drops trailing zeros rather than printing them', () => { + expect(stroopsToXlm(125_000_000n)).toBe('12.5') + expect(stroopsToXlm(10_000_000n)).toBe('1') + }) + + it('refuses anything that is not a plain decimal amount', () => { + // parseFloat would accept every one of these and hand back a number that + // decides how much money leaves a shielded pool. + for (const junk of ['', ' ', 'abc', '1.2.3', '1e9', '12abc', '-1', '.5', '1.00000001']) { + expect(xlmToStroops(junk)).toBeNull() + } + }) + + it('accepts surrounding whitespace, since people paste amounts', () => { + expect(xlmToStroops(' 2.5 ')).toBe(25_000_000n) + }) + + it('handles an amount larger than a double can hold exactly', () => { + // The reason this is bigint arithmetic and not floating point. + expect(xlmToStroops('9007199254.7409911')).toBe(90_071_992_547_409_911n) + }) +})