From ce39c545564c18ce380e3a657a066d24d065300e Mon Sep 17 00:00:00 2001 From: Elizabethxxx Date: Tue, 28 Jul 2026 21:16:41 +0100 Subject: [PATCH] feat(mobile): multisig proposal, approval, and execution flow Adds /multisig, which turns the read-only multisig view into a working coordination tool: an owner raises a transfer, owners approve it, and the approval that reaches the threshold executes it. There is no separate Execute action because the contract has no execute entry point -- contracts/multisig-wallet performs the transfer inside the sign_transaction invocation that reaches the threshold. The screen names that approval for what it is ("Approve and execute") and warns that it sends the funds immediately, rather than offering a button the contract cannot back. Three fixes carried over from the web port: - propose_transaction takes caller as its first argument and calls caller.require_auth(). The web version omits it, so every proposal it builds is rejected. It is passed here. - The owner is the source account rather than a separate fee payer, so prepareTransaction resolves require_auth against the source account and no second signature is needed. This also drops the fallback that minted a throwaway keypair from Friendbot. - Amounts convert through integer string arithmetic instead of parseFloat(x) * 10_000_000, which misrounds ordinary values such as 0.7 at stroop precision. Approval preconditions -- owner membership, duplicate approvals, and whether the wallet can actually cover a transfer that is about to execute -- are checked locally, so a rejection is immediate and specific instead of arriving as a contract panic after a fee. Deployment stays on the desktop wizard; the contract address is read from the veil_multisig_contract key the web wallet already uses. --- frontend/mobile/README.md | 22 + frontend/mobile/app/multisig.tsx | 627 ++++++++++++++++++ .../mobile/lib/__tests__/multisig.test.ts | 215 ++++++ frontend/mobile/lib/multisig.ts | 422 ++++++++++++ 4 files changed, 1286 insertions(+) create mode 100644 frontend/mobile/app/multisig.tsx create mode 100644 frontend/mobile/lib/__tests__/multisig.test.ts create mode 100644 frontend/mobile/lib/multisig.ts diff --git a/frontend/mobile/README.md b/frontend/mobile/README.md index 7421a2b1..9360f0b2 100644 --- a/frontend/mobile/README.md +++ b/frontend/mobile/README.md @@ -62,3 +62,25 @@ rejects the metadata before encryption if it finds a secret-looking field. Native `ios/` and `android/` folders are generated on demand (via prebuild/EAS) and are gitignored, along with `node_modules/` and `.expo/`. + +## Multisig + +`/multisig` connects to a deployed M-of-N wallet +(`contracts/multisig-wallet`) and runs the full lifecycle: an owner raises a +transfer, owners approve it, and the approval that reaches the threshold +executes it. + +There is no Execute button, because the contract has no `execute` entry point — +`sign_transaction` performs the transfer in the same invocation that reaches the +threshold. The screen names that approval for what it is ("Approve and execute") +rather than implying a separate step that does not exist. + +Deployment stays on the desktop wizard; the contract address is stored under the +same `veil_multisig_contract` key the web wallet uses. Point the screen at a +network with `EXPO_PUBLIC_SOROBAN_RPC_URL` and `EXPO_PUBLIC_NETWORK_PASSPHRASE` +(defaults to Soroban testnet). + +`lib/multisig.ts` holds the rules the screen applies before touching the chain — +amount conversion, owner and duplicate-approval checks, and whether the next +approval is the deciding one — so a rejection arrives immediately instead of as +a contract panic after a fee. diff --git a/frontend/mobile/app/multisig.tsx b/frontend/mobile/app/multisig.tsx new file mode 100644 index 00000000..f3c416c4 --- /dev/null +++ b/frontend/mobile/app/multisig.tsx @@ -0,0 +1,627 @@ +/** + * Multisig coordination — propose, approve, execute. + * + * The mobile counterpart to the web wallet's `/multisig` page, minus deployment + * (a wallet is created once, from the desktop wizard) and plus the whole point + * of this screen: an owner can raise a transfer, other owners can approve it, + * and the approval that reaches the threshold executes it. + * + * Execution is not a separate step. `contracts/multisig-wallet` performs the + * transfer inside the invocation that reaches the threshold, so the deciding + * approval moves the money. The screen labels that approval accordingly rather + * than offering an Execute button that the contract has no entry point for. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + RefreshControl, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { useTheme } from '../hooks/useTheme'; +import type { ThemeColors } from '../lib/theme'; +import { + approvalBlocker, + approveProposal, + fetchMultisigDetails, + fetchProposals, + formatStroopsAsXlm, + MULTISIG_CONTRACT_STORAGE_KEY, + MultisigError, + proposalStatus, + proposeTransfer, + publicKeyFromSecret, + remainingApprovals, + willExecuteOnApproval, + type MultisigDetails, + type Proposal, +} from '../lib/multisig'; + +/** AsyncStorage key holding this device's signer secret, mirroring the web wallet's. */ +const SIGNER_SECRET_KEY = 'veil_signer_secret'; + +const STATUS_LABEL = { + executed: 'Executed', + ready: 'Threshold met', + pending: 'Awaiting approvals', +} as const; + +/** `GABC…WXYZ` — middle-truncate an address so a full row still fits on a phone. */ +function truncate(address: string): string { + return address.length <= 16 ? address : `${address.slice(0, 8)}…${address.slice(-6)}`; +} + +function describe(error: unknown): string { + if (error instanceof MultisigError) return error.message; + if (error instanceof Error) return error.message; + return 'Something went wrong.'; +} + +export default function MultisigScreen() { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const [contractId, setContractId] = useState(null); + const [contractDraft, setContractDraft] = useState(''); + const [loaded, setLoaded] = useState(false); + + const [details, setDetails] = useState(null); + const [proposals, setProposals] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [loadError, setLoadError] = useState(null); + + const [signerSecret, setSignerSecret] = useState(''); + + const [to, setTo] = useState(''); + const [amount, setAmount] = useState(''); + const [proposing, setProposing] = useState(false); + const [proposeError, setProposeError] = useState(null); + + const [approvingId, setApprovingId] = useState(null); + const [approveErrors, setApproveErrors] = useState>({}); + const [notice, setNotice] = useState(null); + + const signerAddress = useMemo(() => publicKeyFromSecret(signerSecret), [signerSecret]); + + // ── Stored wallet + device signer ──────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + + (async () => { + const [stored, secret] = await Promise.all([ + AsyncStorage.getItem(MULTISIG_CONTRACT_STORAGE_KEY).catch(() => null), + AsyncStorage.getItem(SIGNER_SECRET_KEY).catch(() => null), + ]); + if (cancelled) return; + + setContractId(stored); + setContractDraft(stored ?? ''); + // Prefilled, not required: this device's key is the common case, but + // approving on behalf of another owner means pasting a different one. + if (secret) setSignerSecret(secret); + setLoaded(true); + })(); + + return () => { + cancelled = true; + }; + }, []); + + const load = useCallback(async (id: string) => { + setRefreshing(true); + setLoadError(null); + try { + // Details first: the threshold is what every proposal's state is read + // against, so a queue without it would be meaningless. + const nextDetails = await fetchMultisigDetails(id); + setDetails(nextDetails); + setProposals(await fetchProposals(id)); + } catch (error) { + setDetails(null); + setProposals([]); + setLoadError(describe(error)); + } finally { + setRefreshing(false); + } + }, []); + + useEffect(() => { + if (contractId) void load(contractId); + }, [contractId, load]); + + const connectWallet = useCallback(async () => { + const id = contractDraft.trim(); + if (!id) return; + await AsyncStorage.setItem(MULTISIG_CONTRACT_STORAGE_KEY, id).catch(() => undefined); + setContractId(id); + }, [contractDraft]); + + const disconnectWallet = useCallback(async () => { + await AsyncStorage.removeItem(MULTISIG_CONTRACT_STORAGE_KEY).catch(() => undefined); + setContractId(null); + setDetails(null); + setProposals([]); + setLoadError(null); + }, []); + + const handlePropose = useCallback(async () => { + if (!contractId) return; + setProposeError(null); + setNotice(null); + setProposing(true); + try { + await proposeTransfer({ contractId, proposerSecret: signerSecret, to, amountXlm: amount }); + setTo(''); + setAmount(''); + setNotice('Proposal raised. It needs approvals before it executes.'); + await load(contractId); + } catch (error) { + setProposeError(describe(error)); + } finally { + setProposing(false); + } + }, [amount, contractId, load, signerSecret, to]); + + const handleApprove = useCallback( + async (proposal: Proposal) => { + if (!contractId || !details) return; + setApproveErrors((prev) => ({ ...prev, [proposal.id]: '' })); + setNotice(null); + setApprovingId(proposal.id); + + const decisive = willExecuteOnApproval(proposal, details.threshold); + try { + await approveProposal({ contractId, proposalId: proposal.id, signerSecret }); + setNotice( + decisive + ? `Proposal #${proposal.id} reached its threshold and the transfer was executed.` + : `Approved proposal #${proposal.id}.` + ); + await load(contractId); + } catch (error) { + setApproveErrors((prev) => ({ ...prev, [proposal.id]: describe(error) })); + } finally { + setApprovingId(null); + } + }, + [contractId, details, load, signerSecret] + ); + + if (!loaded) { + return ( + + + + ); + } + + // ── No wallet connected yet ────────────────────────────────────────────────── + if (!contractId) { + return ( + + Multisig + + Connect a deployed M-of-N wallet to propose transfers and collect approvals from its + owners. + + + Multisig contract address + + + Connect wallet + + + ); + } + + const canPropose = + !proposing && + signerAddress !== null && + to.trim().length > 0 && + amount.trim().length > 0 && + (details === null || details.owners.includes(signerAddress)); + + return ( + void load(contractId)} + tintColor={colors.accent} + /> + } + > + Multisig + {contractId} + + {loadError && {loadError}} + + {details && ( + + + + BALANCE + {formatStroopsAsXlm(details.balance)} XLM + + + THRESHOLD + + {details.threshold} of {details.owners.length} + + + + + OWNERS + {details.owners.map((owner) => ( + + {truncate(owner)} + {owner === signerAddress ? ' (you)' : ''} + + ))} + + )} + + {/* ── Signing key ──────────────────────────────────────────────────────── */} + Signing as + + {signerAddress ? ( + + {truncate(signerAddress)} + {details && !details.owners.includes(signerAddress) + ? ' — not an owner of this wallet' + : ''} + + ) : ( + signerSecret.length > 0 && That is not a valid secret key. + )} + + {/* ── Propose ──────────────────────────────────────────────────────────── */} + Propose a transfer + + + {proposeError && {proposeError}} + + {proposing ? ( + + ) : ( + Raise proposal + )} + + + {notice && {notice}} + + {/* ── Queue ────────────────────────────────────────────────────────────── */} + Proposals + + {refreshing && proposals.length === 0 && } + + {!refreshing && proposals.length === 0 && !loadError && ( + No proposals yet. Raise one above. + )} + + {details && + proposals.map((proposal) => { + const status = proposalStatus(proposal, details.threshold); + const blocker = approvalBlocker(proposal, details, signerAddress); + const decisive = willExecuteOnApproval(proposal, details.threshold); + const remaining = remainingApprovals(proposal, details.threshold); + const busy = approvingId === proposal.id; + + return ( + + + Proposal #{proposal.id} + + {STATUS_LABEL[status]} + + + + TO + {truncate(proposal.to)} + + AMOUNT + {formatStroopsAsXlm(proposal.amount)} XLM + + + APPROVALS ({proposal.approvals.length}/{details.threshold}) + + {proposal.approvals.length === 0 ? ( + None yet. + ) : ( + proposal.approvals.map((approver) => ( + + ✓ {truncate(approver)} + + )) + )} + + {status !== 'executed' && ( + <> + {decisive && !blocker && ( + + Yours is the last approval needed — approving sends{' '} + {formatStroopsAsXlm(proposal.amount)} XLM immediately. + + )} + {!decisive && !blocker && ( + + {remaining} more {remaining === 1 ? 'approval' : 'approvals'} needed after + yours. + + )} + {blocker && {blocker}} + {approveErrors[proposal.id] ? ( + {approveErrors[proposal.id]} + ) : null} + + handleApprove(proposal)} + style={[ + styles.primaryButton, + (blocker !== null || approvingId !== null) && styles.disabled, + ]} + > + {busy ? ( + + ) : ( + + {decisive ? 'Approve and execute' : 'Approve'} + + )} + + + )} + + ); + })} + + + Use a different multisig wallet + + + ); +} + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + screen: { + backgroundColor: colors.background, + flex: 1, + }, + centered: { + alignItems: 'center', + justifyContent: 'center', + }, + content: { + gap: 10, + padding: 24, + paddingBottom: 64, + }, + title: { + color: colors.textStrong, + fontSize: 28, + fontWeight: '700', + }, + subtitle: { + color: colors.textSecondary, + fontSize: 14, + lineHeight: 21, + }, + contractId: { + color: colors.accentText, + fontSize: 12, + marginBottom: 6, + }, + sectionTitle: { + color: colors.textStrong, + fontSize: 17, + fontWeight: '600', + marginTop: 20, + }, + label: { + color: colors.textMuted, + fontSize: 13, + fontWeight: '600', + marginTop: 8, + }, + card: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 12, + borderWidth: 1, + gap: 4, + marginTop: 8, + padding: 16, + }, + cardExecuted: { + opacity: 0.75, + }, + statRow: { + flexDirection: 'row', + gap: 24, + marginBottom: 8, + }, + stat: { + gap: 2, + }, + statLabel: { + color: colors.textFaint, + fontSize: 11, + letterSpacing: 0.8, + marginTop: 8, + }, + statValue: { + color: colors.textPrimary, + fontSize: 17, + fontWeight: '600', + }, + owner: { + color: colors.textMuted, + fontSize: 12, + }, + proposalHeader: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'space-between', + }, + proposalId: { + color: colors.textPrimary, + fontSize: 15, + fontWeight: '600', + }, + status: { + color: colors.textMuted, + fontSize: 12, + fontWeight: '600', + }, + statusReady: { + color: colors.accentText, + }, + statusExecuted: { + color: colors.textFaint, + }, + decisive: { + color: colors.accentText, + fontSize: 13, + lineHeight: 19, + marginTop: 10, + }, + input: { + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: 10, + borderWidth: 1, + color: colors.textPrimary, + fontSize: 15, + paddingHorizontal: 14, + paddingVertical: 12, + }, + mono: { + fontFamily: 'Inconsolata_400Regular', + }, + hint: { + color: colors.textMuted, + fontSize: 12, + lineHeight: 18, + }, + notice: { + color: colors.accentText, + fontSize: 13, + lineHeight: 19, + }, + error: { + color: colors.danger, + fontSize: 13, + lineHeight: 19, + }, + primaryButton: { + alignItems: 'center', + backgroundColor: colors.accent, + borderRadius: 12, + justifyContent: 'center', + marginTop: 10, + minHeight: 48, + paddingHorizontal: 20, + }, + primaryButtonLabel: { + color: colors.onAccent, + fontSize: 15, + fontWeight: '600', + }, + ghostButton: { + alignItems: 'center', + borderColor: colors.border, + borderRadius: 12, + borderWidth: 1, + justifyContent: 'center', + minHeight: 48, + paddingHorizontal: 20, + }, + ghostButtonLabel: { + color: colors.textMuted, + fontSize: 14, + }, + disconnect: { + marginTop: 28, + }, + disabled: { + opacity: 0.5, + }, + }); diff --git a/frontend/mobile/lib/__tests__/multisig.test.ts b/frontend/mobile/lib/__tests__/multisig.test.ts new file mode 100644 index 00000000..253712f1 --- /dev/null +++ b/frontend/mobile/lib/__tests__/multisig.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for the multisig proposal/approval rules. + * + * These cover the decisions the screen makes before it ever touches the chain: + * amount conversion, who may approve, and — the crux of the flow — whether the + * next approval is the one that executes the transfer. The RPC calls themselves + * are thin wrappers over the Stellar SDK and are exercised against a real + * contract, not here. + */ + +import { Keypair } from '@stellar/stellar-sdk'; + +import { + approvalBlocker, + formatStroopsAsXlm, + hasApproved, + isValidRecipient, + MultisigError, + parseXlmToStroops, + proposalStatus, + publicKeyFromSecret, + remainingApprovals, + STROOPS_PER_XLM, + willExecuteOnApproval, + type MultisigDetails, + type Proposal, +} from '../multisig'; + +const OWNER_A = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; +const OWNER_B = 'GDQNY3PBOJOKYZSRMK2S7LHHGWZIUISD4QORETLMXEWXBI7KFZZMKTL3'; +const OWNER_C = 'GAJXBGXV7VQGKPTOSFPVLDDMD7MTLKZDUBWMBBRJRVIMTP4XZ2ZAAGRP'; +const OUTSIDER = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; +const CONTRACT = 'CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE'; + +function multisig(overrides: Partial = {}): MultisigDetails { + return { + contractId: CONTRACT, + owners: [OWNER_A, OWNER_B, OWNER_C], + threshold: 2, + balance: 1_000n * STROOPS_PER_XLM, + ...overrides, + }; +} + +function proposal(overrides: Partial = {}): Proposal { + return { + id: 1, + to: OUTSIDER, + amount: 100n * STROOPS_PER_XLM, + approvals: [], + executed: false, + ...overrides, + }; +} + +describe('parseXlmToStroops', () => { + it('converts whole and fractional amounts exactly', () => { + expect(parseXlmToStroops('1')).toBe(10_000_000n); + expect(parseXlmToStroops('12.5')).toBe(125_000_000n); + expect(parseXlmToStroops('0.0000001')).toBe(1n); + expect(parseXlmToStroops(' 250 ')).toBe(2_500_000_000n); + }); + + it('keeps precision that float arithmetic would lose', () => { + // parseFloat('0.7') * 1e7 is 6999999.999999999 — one stroop short after + // truncation, and wrong in the user's favour or against it at random. + expect(parseXlmToStroops('0.7')).toBe(7_000_000n); + expect(parseXlmToStroops('1234567.8912345')).toBe(12_345_678_912_345n); + }); + + it('rejects amounts that are not positive', () => { + expect(() => parseXlmToStroops('0')).toThrow(MultisigError); + expect(() => parseXlmToStroops('0.0')).toThrow('greater than zero'); + expect(() => parseXlmToStroops('-5')).toThrow(MultisigError); + }); + + it('rejects malformed input', () => { + for (const input of ['', ' ', '.', 'abc', '1.2.3', '5 XLM', '1e7']) { + expect(() => parseXlmToStroops(input)).toThrow(MultisigError); + } + }); + + it('rejects amounts finer than a stroop', () => { + expect(() => parseXlmToStroops('0.00000001')).toThrow('7 decimal places'); + }); +}); + +describe('formatStroopsAsXlm', () => { + it('round-trips with parseXlmToStroops', () => { + for (const amount of ['1', '12.5', '0.0000001', '1234567.8912345']) { + expect(formatStroopsAsXlm(parseXlmToStroops(amount))).toBe(amount); + } + }); + + it('trims trailing zeros rather than padding every balance to seven places', () => { + expect(formatStroopsAsXlm(0n)).toBe('0'); + expect(formatStroopsAsXlm(10_000_000n)).toBe('1'); + expect(formatStroopsAsXlm(10_500_000n)).toBe('1.05'); + }); +}); + +describe('isValidRecipient', () => { + it('accepts classic accounts and contracts', () => { + expect(isValidRecipient(OWNER_A)).toBe(true); + expect(isValidRecipient(CONTRACT)).toBe(true); + expect(isValidRecipient(` ${OWNER_A} `)).toBe(true); + }); + + it('rejects anything else', () => { + expect(isValidRecipient('')).toBe(false); + expect(isValidRecipient('GNOTAREALADDRESS')).toBe(false); + // A secret key is the dangerous near-miss: it is the right length and shape. + expect(isValidRecipient(Keypair.random().secret())).toBe(false); + }); +}); + +describe('publicKeyFromSecret', () => { + it('derives the address a secret controls', () => { + const keypair = Keypair.random(); + expect(publicKeyFromSecret(keypair.secret())).toBe(keypair.publicKey()); + expect(publicKeyFromSecret(` ${keypair.secret()} `)).toBe(keypair.publicKey()); + }); + + it('returns null while a field is still being typed into', () => { + expect(publicKeyFromSecret('')).toBeNull(); + expect(publicKeyFromSecret('SBTLK')).toBeNull(); + expect(publicKeyFromSecret(OWNER_A)).toBeNull(); + }); +}); + +describe('proposal state', () => { + it('counts the approvals still outstanding', () => { + expect(remainingApprovals(proposal(), 2)).toBe(2); + expect(remainingApprovals(proposal({ approvals: [OWNER_A] }), 2)).toBe(1); + expect(remainingApprovals(proposal({ approvals: [OWNER_A, OWNER_B] }), 2)).toBe(0); + // A threshold lowered after the fact must not report a negative shortfall. + expect(remainingApprovals(proposal({ approvals: [OWNER_A, OWNER_B] }), 1)).toBe(0); + }); + + it('reports where a proposal stands', () => { + expect(proposalStatus(proposal(), 2)).toBe('pending'); + expect(proposalStatus(proposal({ approvals: [OWNER_A] }), 2)).toBe('pending'); + expect(proposalStatus(proposal({ approvals: [OWNER_A, OWNER_B] }), 2)).toBe('ready'); + expect(proposalStatus(proposal({ executed: true, approvals: [OWNER_A, OWNER_B] }), 2)).toBe( + 'executed' + ); + }); + + it('knows who has already approved', () => { + const pending = proposal({ approvals: [OWNER_A] }); + expect(hasApproved(pending, OWNER_A)).toBe(true); + expect(hasApproved(pending, OWNER_B)).toBe(false); + expect(hasApproved(pending, null)).toBe(false); + }); +}); + +describe('willExecuteOnApproval', () => { + it('is true only for the approval that reaches the threshold', () => { + expect(willExecuteOnApproval(proposal(), 3)).toBe(false); + expect(willExecuteOnApproval(proposal({ approvals: [OWNER_A] }), 3)).toBe(false); + expect(willExecuteOnApproval(proposal({ approvals: [OWNER_A, OWNER_B] }), 3)).toBe(true); + }); + + it('is true for the first approval of a 1-of-N wallet', () => { + expect(willExecuteOnApproval(proposal(), 1)).toBe(true); + }); + + it('is false once the transfer has been made', () => { + expect( + willExecuteOnApproval(proposal({ executed: true, approvals: [OWNER_A, OWNER_B] }), 2) + ).toBe(false); + }); +}); + +describe('approvalBlocker', () => { + it('lets an owner who has not yet approved go ahead', () => { + expect(approvalBlocker(proposal(), multisig(), OWNER_A)).toBeNull(); + expect(approvalBlocker(proposal({ approvals: [OWNER_A] }), multisig(), OWNER_B)).toBeNull(); + }); + + it('blocks an executed proposal', () => { + const done = proposal({ executed: true, approvals: [OWNER_A, OWNER_B] }); + expect(approvalBlocker(done, multisig(), OWNER_C)).toMatch(/already been executed/); + }); + + it('blocks a signer who is not an owner', () => { + expect(approvalBlocker(proposal(), multisig(), OUTSIDER)).toMatch(/not one of/); + }); + + it('blocks a second approval from the same owner', () => { + const pending = proposal({ approvals: [OWNER_A] }); + expect(approvalBlocker(pending, multisig(), OWNER_A)).toMatch(/already approved/); + }); + + it('asks for a key when none has been entered', () => { + expect(approvalBlocker(proposal(), multisig(), null)).toMatch(/Enter an owner secret key/); + }); + + it('blocks the deciding approval when the wallet cannot cover the transfer', () => { + // The contract would transfer inside this same call and panic, costing a + // fee for nothing. Catch it before submitting. + const decisive = proposal({ approvals: [OWNER_A], amount: 500n * STROOPS_PER_XLM }); + const poor = multisig({ balance: 100n * STROOPS_PER_XLM }); + + expect(approvalBlocker(decisive, poor, OWNER_B)).toMatch(/does not hold enough XLM/); + }); + + it('allows a non-deciding approval even when the balance is short', () => { + // The transfer is not attempted yet, and funds can arrive before it is. + const early = proposal({ amount: 500n * STROOPS_PER_XLM }); + const poor = multisig({ threshold: 3, balance: 100n * STROOPS_PER_XLM }); + + expect(approvalBlocker(early, poor, OWNER_A)).toBeNull(); + }); +}); diff --git a/frontend/mobile/lib/multisig.ts b/frontend/mobile/lib/multisig.ts new file mode 100644 index 00000000..8b029b14 --- /dev/null +++ b/frontend/mobile/lib/multisig.ts @@ -0,0 +1,422 @@ +/** + * Multisig coordination against the `multisig-wallet` Soroban contract. + * + * The contract (`contracts/multisig-wallet/src/lib.rs`) models an M-of-N wallet: + * an owner proposes a transfer, owners approve it one at a time, and the + * approval that reaches the threshold performs the transfer in the same + * invocation. There is no separate `execute` entry point, and deliberately so — + * execution is atomic with the deciding approval, which is what stops a + * fully-approved proposal from sitting around waiting for someone to push a + * button. The UI reflects that by naming the deciding approval for what it is. + * + * Ported from the web wallet's `lib/multisig.ts` with three corrections that a + * phone forces or a bug required: + * + * - `propose_transaction` takes `caller` as its first argument and calls + * `caller.require_auth()`. The web port omits it, so every proposal it + * builds is rejected by the contract. It is passed here. + * - The web version signs with a separate fee payer and falls back to minting + * a throwaway keypair from Friendbot. The owner is the source account here: + * they must authorise the call regardless, and a source-account invoker + * needs no second signature. + * - Amounts are converted through integer string arithmetic rather than + * `parseFloat(x) * 10_000_000`, which silently misrounds ordinary values + * (`0.1 + 0.2` arithmetic) at stroop precision. + */ + +import { + Account, + Address, + Asset, + BASE_FEE, + Contract, + Keypair, + Networks, + StrKey, + TransactionBuilder, + nativeToScVal, + rpc as SorobanRpc, + scValToNative, +} from '@stellar/stellar-sdk'; + +// ── Network ───────────────────────────────────────────────────────────────────── + +const NETWORK_PASSPHRASE = + process.env['EXPO_PUBLIC_NETWORK_PASSPHRASE']?.trim() || Networks.TESTNET; + +const RPC_URL = + process.env['EXPO_PUBLIC_SOROBAN_RPC_URL']?.trim() || + (NETWORK_PASSPHRASE === Networks.PUBLIC ? '' : 'https://soroban-testnet.stellar.org'); + +/** AsyncStorage key holding the active multisig contract. Shared with the web wallet. */ +export const MULTISIG_CONTRACT_STORAGE_KEY = 'veil_multisig_contract'; + +/** Stroops in one XLM. The contract stores amounts in stroops. */ +export const STROOPS_PER_XLM = 10_000_000n; + +/** Failures a caller can show verbatim. */ +export class MultisigError extends Error { + constructor(message: string) { + super(message); + this.name = 'MultisigError'; + } +} + +// ── Types ─────────────────────────────────────────────────────────────────────── + +export type Proposal = { + id: number; + /** Recipient of the transfer. */ + to: string; + /** Amount in stroops, as the contract stores it. */ + amount: bigint; + /** Owner addresses that have approved, in approval order. */ + approvals: string[]; + /** True once the transfer has been made. */ + executed: boolean; +}; + +export type MultisigDetails = { + contractId: string; + owners: string[]; + /** Approvals required before a proposal executes. */ + threshold: number; + /** The contract's native balance, in stroops. */ + balance: bigint; +}; + +/** Where a proposal stands, for display. */ +export type ProposalStatus = 'executed' | 'ready' | 'pending'; + +// ── Amounts ───────────────────────────────────────────────────────────────────── + +/** + * Convert an XLM amount to stroops. + * + * Done as integer arithmetic on the decimal string: at stroop precision, going + * via a float loses value on inputs as ordinary as `0.7`, and this is money. + * + * @throws {MultisigError} If the input is not a positive amount with at most + * seven decimal places. + */ +export function parseXlmToStroops(value: string): bigint { + const trimmed = value.trim(); + if (!/^\d*\.?\d*$/.test(trimmed) || trimmed === '' || trimmed === '.') { + throw new MultisigError('Enter an amount in XLM, for example 12.5'); + } + + const [whole = '', fraction = ''] = trimmed.split('.'); + if (fraction.length > 7) { + throw new MultisigError('XLM amounts cannot be finer than 7 decimal places.'); + } + + const stroops = BigInt(`${whole || '0'}${fraction.padEnd(7, '0')}`); + if (stroops <= 0n) { + throw new MultisigError('Amount must be greater than zero.'); + } + return stroops; +} + +/** Render stroops as an XLM string, trimmed of trailing zeros. */ +export function formatStroopsAsXlm(stroops: bigint): string { + const negative = stroops < 0n; + const absolute = negative ? -stroops : stroops; + const whole = absolute / STROOPS_PER_XLM; + const fraction = (absolute % STROOPS_PER_XLM).toString().padStart(7, '0').replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + +// ── Address helpers ───────────────────────────────────────────────────────────── + +/** Whether an address can receive the transfer — a classic account or a contract. */ +export function isValidRecipient(address: string): boolean { + const trimmed = address.trim(); + return StrKey.isValidEd25519PublicKey(trimmed) || StrKey.isValidContract(trimmed); +} + +/** + * The public key for a secret, or null if the secret is unusable. + * + * Returns null rather than throwing because the common caller is a text field + * being typed into, where "not a key yet" is the normal state. + */ +export function publicKeyFromSecret(secret: string): string | null { + try { + return Keypair.fromSecret(secret.trim()).publicKey(); + } catch { + return null; + } +} + +// ── Proposal state ────────────────────────────────────────────────────────────── + +/** Whether `address` has already approved `proposal`. */ +export function hasApproved(proposal: Proposal, address: string | null): boolean { + return address !== null && proposal.approvals.includes(address); +} + +/** How many more approvals a proposal needs. Zero once the threshold is met. */ +export function remainingApprovals(proposal: Proposal, threshold: number): number { + return Math.max(0, threshold - proposal.approvals.length); +} + +/** + * Whether approving now is the one that executes. + * + * The contract transfers the funds inside the invocation that reaches the + * threshold, so this approval is not "one more signature" — it moves the money, + * and the button says so. + */ +export function willExecuteOnApproval(proposal: Proposal, threshold: number): boolean { + return !proposal.executed && remainingApprovals(proposal, threshold) <= 1; +} + +/** Where a proposal stands. `ready` means the threshold is met but the chain has not caught up. */ +export function proposalStatus(proposal: Proposal, threshold: number): ProposalStatus { + if (proposal.executed) return 'executed'; + return remainingApprovals(proposal, threshold) === 0 ? 'ready' : 'pending'; +} + +/** + * Why the signer cannot approve this proposal, or null if they can. + * + * Checked locally so the reason is immediate and specific, rather than arriving + * as a contract panic after a round trip and a fee. + */ +export function approvalBlocker( + proposal: Proposal, + details: MultisigDetails, + signerAddress: string | null +): string | null { + if (proposal.executed) return 'This proposal has already been executed.'; + if (!signerAddress) return 'Enter an owner secret key to approve.'; + if (!details.owners.includes(signerAddress)) { + return 'That key is not one of this wallet’s owners.'; + } + if (hasApproved(proposal, signerAddress)) return 'You have already approved this proposal.'; + if (willExecuteOnApproval(proposal, details.threshold) && details.balance < proposal.amount) { + return 'The wallet does not hold enough XLM to execute this transfer.'; + } + return null; +} + +// ── Chain access ──────────────────────────────────────────────────────────────── + +function server(): SorobanRpc.Server { + if (!RPC_URL) { + throw new MultisigError('No Soroban RPC URL is configured for this network.'); + } + return new SorobanRpc.Server(RPC_URL); +} + +/** + * Read a contract value without submitting anything. + * + * Simulation needs a source account but never charges it, so a throwaway + * address is used rather than requiring the reader to hold a funded key just to + * look at the queue. + */ +async function simulateRead(contract: Contract, method: string, ...args: unknown[]): Promise { + const rpcServer = server(); + const reader = new Account(Keypair.random().publicKey(), '0'); + + const tx = new TransactionBuilder(reader, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(contract.call(method, ...(args as never[]))) + .setTimeout(30) + .build(); + + const simulation = await rpcServer.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simulation)) { + throw new MultisigError(`Could not read ${method} from the contract: ${simulation.error}`); + } + return simulation.result?.retval === undefined + ? undefined + : scValToNative(simulation.result.retval); +} + +/** Poll until the transaction is confirmed, or give up rather than hang the screen. */ +async function waitForTx( + rpcServer: SorobanRpc.Server, + hash: string +): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + const result = await rpcServer.getTransaction(hash); + if (result.status !== SorobanRpc.Api.GetTransactionStatus.NOT_FOUND) return result; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new MultisigError(`Transaction ${hash} was not confirmed in time.`); +} + +/** + * Invoke a contract method as `signerSecret`, and wait for it to land. + * + * The signer is the source account as well as the authorising address, so + * `prepareTransaction` resolves the contract's `require_auth` against the + * source account and the envelope signature is the only one needed. + */ +async function invokeAsOwner(params: { + contractId: string; + signerSecret: string; + method: string; + args: unknown[]; +}): Promise { + const rpcServer = server(); + + let signer: Keypair; + try { + signer = Keypair.fromSecret(params.signerSecret.trim()); + } catch { + throw new MultisigError('That is not a valid Stellar secret key (it starts with S).'); + } + + let source: Account; + try { + source = await rpcServer.getAccount(signer.publicKey()); + } catch { + throw new MultisigError( + 'That owner account does not exist on this network yet, so it cannot pay the fee.' + ); + } + + const contract = new Contract(params.contractId); + const tx = new TransactionBuilder(source, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(contract.call(params.method, ...(params.args as never[]))) + .setTimeout(60) + .build(); + + const prepared = await rpcServer.prepareTransaction(tx); + prepared.sign(signer); + + const submitted = await rpcServer.sendTransaction(prepared); + if (submitted.status === 'ERROR') { + throw new MultisigError(`The network rejected the transaction: ${submitted.status}`); + } + + const confirmed = await waitForTx(rpcServer, submitted.hash); + if (confirmed.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + throw new MultisigError(`The transaction failed on-chain (${confirmed.status}).`); + } + return submitted.hash; +} + +/** Read the wallet's owners, threshold, and native balance. */ +export async function fetchMultisigDetails(contractId: string): Promise { + if (!StrKey.isValidContract(contractId)) { + throw new MultisigError('That is not a valid multisig contract address (it starts with C).'); + } + + const contract = new Contract(contractId); + const owners = (await simulateRead(contract, 'get_owners')) as string[]; + const threshold = Number(await simulateRead(contract, 'get_threshold')); + + // Balance is read from the native SAC, not the multisig — the contract holds + // its funds there. A failure here should not hide the rest of the wallet. + let balance = 0n; + try { + const sac = new Contract(Asset.native().contractId(NETWORK_PASSPHRASE)); + balance = BigInt( + (await simulateRead(sac, 'balance', nativeToScVal(contractId, { type: 'address' }))) as bigint + ); + } catch { + balance = 0n; + } + + return { contractId, owners, threshold, balance }; +} + +/** Read every proposal, newest first. */ +export async function fetchProposals(contractId: string): Promise { + const contract = new Contract(contractId); + const count = Number(await simulateRead(contract, 'get_proposal_count')); + + const proposals: Proposal[] = []; + for (let id = 1; id <= count; id++) { + const raw = (await simulateRead( + contract, + 'get_proposal', + nativeToScVal(id, { type: 'u64' }) + )) as { id: bigint; to: string; amount: bigint; approvals: string[]; executed: boolean }; + + proposals.push({ + id: Number(raw.id), + to: raw.to, + amount: BigInt(raw.amount), + approvals: raw.approvals, + executed: raw.executed, + }); + } + + return proposals.sort((a, b) => b.id - a.id); +} + +/** + * Propose a transfer out of the multisig. + * + * The proposer must be an owner: the contract authorises `caller` and rejects + * anyone else, so a proposal cannot be raised by a stranger holding the + * contract address. + */ +export async function proposeTransfer(params: { + contractId: string; + proposerSecret: string; + to: string; + amountXlm: string; +}): Promise { + const to = params.to.trim(); + if (!isValidRecipient(to)) { + throw new MultisigError('Enter a valid destination address (G… account or C… contract).'); + } + const amount = parseXlmToStroops(params.amountXlm); + + const proposer = publicKeyFromSecret(params.proposerSecret); + if (!proposer) { + throw new MultisigError('That is not a valid Stellar secret key (it starts with S).'); + } + + return invokeAsOwner({ + contractId: params.contractId, + signerSecret: params.proposerSecret, + method: 'propose_transaction', + args: [ + // `caller` — the contract requires the proposer's own authorisation. + new Address(proposer).toScVal(), + new Address(to).toScVal(), + nativeToScVal(amount, { type: 'i128' }), + ], + }); +} + +/** + * Approve a proposal as an owner. + * + * When this approval reaches the threshold the contract executes the transfer + * within the same invocation — so a success here can mean "signed" or "signed + * and paid". {@link willExecuteOnApproval} is how a caller tells the user which + * one they are about to do. + */ +export async function approveProposal(params: { + contractId: string; + proposalId: number; + signerSecret: string; +}): Promise { + const signer = publicKeyFromSecret(params.signerSecret); + if (!signer) { + throw new MultisigError('That is not a valid Stellar secret key (it starts with S).'); + } + + return invokeAsOwner({ + contractId: params.contractId, + signerSecret: params.signerSecret, + method: 'sign_transaction', + args: [ + nativeToScVal(params.proposalId, { type: 'u64' }), + new Address(signer).toScVal(), + ], + }); +}