diff --git a/app/copy-trade/page.tsx b/app/copy-trade/page.tsx index 3bd60a0..87d03ee 100644 --- a/app/copy-trade/page.tsx +++ b/app/copy-trade/page.tsx @@ -119,6 +119,11 @@ function CopyTradeContent() { return; } + if (address?.startsWith("C")) { + setStatusMessage("Not available for passkey wallets yet."); + return; + } + setIsSubmitting(true); try { setStatusMessage("Signing in to OracleDesk…"); diff --git a/app/premium/page.tsx b/app/premium/page.tsx index 9e13776..29ee4e7 100644 --- a/app/premium/page.tsx +++ b/app/premium/page.tsx @@ -85,7 +85,7 @@ const features = [ ]; export default function PremiumPage() { - const { isConnected, isWrongNetwork, openModal, ensureSession } = useWallet(); + const { address, isConnected, isWrongNetwork, openModal, ensureSession } = useWallet(); const transfer = useUsdcTransfer(); const unlock = useUnlockTrace(); @@ -107,6 +107,11 @@ export default function PremiumPage() { const handlePayment = async () => { if (!selectedTier?.priceRaw) return; + if (address?.startsWith("C")) { + setStatusMessage("Not available for passkey wallets yet."); + return; + } + if (paymentMethod === "card") { setStatusMessage("Card payments aren't available. Pay with USDC instead."); return; diff --git a/app/reasoning/verify/page.tsx b/app/reasoning/verify/page.tsx index 53751fd..dcf5d1a 100644 --- a/app/reasoning/verify/page.tsx +++ b/app/reasoning/verify/page.tsx @@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { getTrace } from "@/lib/api/traces"; import { CONTRACTS, explorerAccount } from "@/lib/stellar/config"; import { reasoningRegistry } from "@/lib/stellar/clients"; -import { simulated } from "@/lib/hooks/useStellar"; +import { bytesToHex, read } from "@/lib/stellar/typed-call"; import { fetchAndVerifyTrace, ipfsUrl, type TraceCheck } from "@/lib/stellar/trace"; interface VerificationResult { @@ -79,11 +79,11 @@ function VerifyTraceContent() { // 2. Read the commitment from reasoning-registry. log(`>> [CHAIN] reasoning_registry.get_trace(${onChainTraceId}) on ${CONTRACTS.reasoningRegistry.slice(0, 8)}…`, 30); - const onChain = simulated<{ trace_hash: Uint8Array; ipfs_cid: string; market_id: bigint }>( + const onChain = read( await reasoningRegistry().get_trace({ trace_id: BigInt(onChainTraceId as string) }), "reasoningRegistry", ); - const storedHash = Array.from(onChain.trace_hash, (b) => b.toString(16).padStart(2, "0")).join(""); + const storedHash = bytesToHex(onChain.trace_hash); log(`>> [CHAIN] On-chain hash: ${storedHash}`, 50); log(`>> [IPFS] Fetching ${onChain.ipfs_cid}`, 60); diff --git a/components/layout/WalletDropdown.tsx b/components/layout/WalletDropdown.tsx index cb4339f..2f6debd 100644 --- a/components/layout/WalletDropdown.tsx +++ b/components/layout/WalletDropdown.tsx @@ -4,13 +4,25 @@ import React, { useState, useRef, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useWallet } from "@/lib/contexts/WalletContext"; import { useUsdcBalance } from "@/lib/hooks/useStellar"; -import { NETWORK_LABEL, explorerAccount } from "@/lib/stellar/config"; +import { EXPERIMENTAL_PASSKEYS, NETWORK_LABEL, explorerAccount } from "@/lib/stellar/config"; import { formatUsdc, shortAddress } from "@/lib/stellar/units"; const WalletDropdown = () => { - const { address, isConnected, isConnecting, isWrongNetwork, openModal, disconnect, error } = useWallet(); + const { + address, + isConnected, + isConnecting, + isWrongNetwork, + isPasskey, + openModal, + connectPasskey, + createPasskey, + disconnect, + error, + } = useWallet(); const [isOpen, setIsOpen] = useState(false); const [copied, setCopied] = useState(false); + const [showPasskeyPrompt, setShowPasskeyPrompt] = useState(false); const dropdownRef = useRef(null); const balance = useUsdcBalance(address); @@ -19,6 +31,7 @@ const WalletDropdown = () => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { setIsOpen(false); + setShowPasskeyPrompt(false); } }; document.addEventListener("mousedown", handleClickOutside); @@ -27,14 +40,66 @@ const WalletDropdown = () => { if (!isConnected || !address) { return ( - +
+ + + {EXPERIMENTAL_PASSKEYS && connectPasskey && ( +
+ + + + {showPasskeyPrompt && ( + + + {createPasskey && ( + + )} + + )} + +
+ )} +
); } @@ -63,15 +128,22 @@ const WalletDropdown = () => { className="flex items-center gap-3 px-3 py-1.5 bg-surface-container-low border border-outline-variant rounded-lg hover:bg-surface-container transition-colors active:scale-95" >
- - {isWrongNetwork ? "Wrong network" : NETWORK_LABEL} - +
+ {isPasskey && ( + + Experimental + + )} + + {isWrongNetwork ? "Wrong network" : isPasskey ? "Smart Wallet" : NETWORK_LABEL} + +
{truncatedAddress}
- account_balance_wallet + {isPasskey ? "key" : "account_balance_wallet"}
expand_more @@ -90,9 +162,16 @@ const WalletDropdown = () => { > {/* Balance Section */}
- - Available Balance - +
+ + Available Balance + + {isPasskey && ( + + Passkey + + )} +
{balanceText} @@ -133,7 +212,9 @@ const WalletDropdown = () => {
View on Stellar Expert - Account history + + {isPasskey ? "Contract history" : "Wallet history"} +
diff --git a/docs/api.md b/docs/api.md index 70c9b5b..d6a9a3c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -150,6 +150,45 @@ address mismatch). Multisig accounts: only a signature from `address`'s own key is accepted. Threshold-based SEP-10 verification is a backlog item. +#### Proposed: Contract-account (passkey) authentication (SEP-45 style) + +Smart-wallet contract accounts (`C…`) cannot sign classical ed25519 transaction envelopes. A SEP-45 style Soroban authorization verification flow is proposed: + +##### `POST /auth/challenge` (with contract address) + +Request: +```json +{ "address": "CABC…" } +``` + +Response `200`: +```json +{ + "entryXdr": "", + "validUntilLedger": 1234567, + "networkPassphrase": "Test SDF Network ; September 2015" +} +``` + +The challenge constructs an auth entry requiring `address`'s authorization for a dedicated verification invocation with a random nonce. + +##### `POST /auth/verify` (contract account verification) + +Request: +```json +{ + "address": "CABC…", + "signed": "" +} +``` + +The server verifies the entry by simulating the invocation on Soroban with the signed authorization entry, invoking the contract's `__check_auth` logic. Upon successful simulation, a standard 7-day JWT session is issued. + +Response `200`: +```json +{ "token": "", "userId": "", "walletAddress": "CABC…" } +``` + ### Markets #### Market object diff --git a/docs/architecture.md b/docs/architecture.md index e3c4e85..2f05a2c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,20 @@ A backend market links to the chain through `onChainMarketId` (market-core's `u6 - The kit is imported dynamically in an effect, because it reads `localStorage` when its module loads. - A session the kit persisted is restored without prompting and without redirecting. Only a connect completed in the modal redirects to `/markets`. - After connecting, the wallet signs the backend's SEP-10 style challenge transaction (`signTransaction`, which every Stellar wallet supports; `signMessage` is not universal). The backend returns a JWT. A stored session for a different address is cleared, never reused. -- `useWallet()` exposes `address`, `isWrongNetwork`, a `signer` for the binding clients, `openModal`, `ensureSession` and `disconnect`. +- `useWallet()` exposes `address`, `isWrongNetwork`, `isPasskey`, a `signer` for the binding clients, `openModal`, `connectPasskey`, `createPasskey`, `ensureSession` and `disconnect`. + +## Passkey smart wallets (contract accounts) + +Passkey smart wallets (`C…`) are contract accounts whose `__check_auth` verifies secp256r1 ECDSA signatures created via WebAuthn. + +- **Feature flag**: Experimental passkey support is gated behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS=true` and badged "Experimental" in the UI. +- **Signer abstraction**: `Signer` (`lib/stellar/clients.ts`) supports both `account` (ed25519 `G…`) and `contract` (`C…`) variants. For contract signers, binding client simulation uses `NEXT_PUBLIC_PASSKEY_RELAYER_SOURCE` as the source account. +- **Auth entry signing & submit**: `submitAsContract` (`lib/stellar/contract-submit.ts`) verifies that simulation requires authorization exclusively from the trader's `C…` contract address, signs the auth entries with WebAuthn, re-simulates for accurate footprint/resources, and forwards the transaction to a fee-paying relayer. +- **RP ID constraints**: WebAuthn requires the Relying Party ID (`rpId`) to match the active origin's domain or a registrable suffix of `window.location.hostname`. Ports and scheme must be omitted. +- **Relayer interface**: A relayer service provides two endpoints: + - `POST /wallet/deploy`: Deploys the smart wallet contract with the passkey's uncompressed P-256 public key. + - `POST /tx/submit`: Signs as source/fee payer, submits to the Stellar network, and returns `{ hash }`. +- **Signature normalisation**: Soroban's secp256r1 verification enforces canonical low-S (`s <= N/2`). WebAuthn DER signatures are converted to 64-byte raw `r || s` and normalised (`s' = N - s` if `s > N/2`). ## Contract calls @@ -29,6 +42,14 @@ A backend market links to the chain through `onChainMarketId` (market-core's `u6 - `useBuy` quotes again immediately before building the transaction and sets `min_shares_out = minOut(quote, slippageBps)`, so slippage is enforced on-chain. - `useSell` turns "sell N shares" into the exact `collateral_out` the contract needs, using the contract's own FPMM math (`generated/fpmm.ts`) on freshly read reserves, with `max_shares_in` slightly above N (see the contracts repo's `docs/frontend-integration.md`). +## Reading contract results + +Contract calls must always be consumed through `read(tx, contract)` for reads/simulations or `submit(tx, contract)` for signed writes (`lib/stellar/typed-call.ts`). + +- **No manual generics or casts**: `read` and `submit` automatically infer and unwrap the return type directly from the contract binding signature (`Result` is unwrapped to `T`, while non-Result values like `Position` or SAC balances remain untouched). Never pass a type parameter (e.g. `read(...)`) or hand-write contract result shapes. An ESLint rule enforces this across `app/` and `lib/`. +- **Result detection**: Soroban `Result`s are detected using the SDK's `Ok` and `Err` classes (`instanceof`), avoiding duck-typing bugs where plain objects with `isOk` could be mistakenly unwrapped. Unhandled contract errors or simulation failures are mapped to user-friendly messages via `UserFacingError`. +- **Type tests**: `lib/stellar/__tests__/typed-call.test-d.ts` pins the return type of every binding method and argument shape of every write the app calls. When adding or calling a new contract binding method, always add a corresponding type test so upstream contract changes fail during `npm test`. + ## Amounts USDC has 7 decimals. Anything that can reach a transaction is a `bigint` in base units (`lib/stellar/units.ts`). Parsing rejects floats in exponent form and more than 7 decimals. Display floats exist only at the edge of the UI. diff --git a/e2e/localnet/passkey.e2e.test.ts b/e2e/localnet/passkey.e2e.test.ts new file mode 100644 index 0000000..98f6f89 --- /dev/null +++ b/e2e/localnet/passkey.e2e.test.ts @@ -0,0 +1,145 @@ +/** + * Localnet end-to-end integration test for passkey smart-wallet (contract account) traders. + * + * Simulates a passkey trader using Node's crypto.subtle ECDSA P-256 key pair standing in for + * WebAuthn, signing Soroban authorization entries, and submitting via a local fee-paying relayer. + * + * To run against a running stellar/quickstart local container: + * LOCALNET=true npm run e2e:localnet + */ +import { describe, expect, it } from "vitest"; +import { Buffer } from "buffer"; +import { + Address, + Keypair, + nativeToScVal, + xdr, +} from "@stellar/stellar-sdk"; +import { + buildPasskeySignatureScVal, + derToRawSignature, + computeAuthPayloadHash, +} from "@/lib/stellar/passkey"; +import { submitAsContract } from "@/lib/stellar/contract-submit"; +import type { Signer } from "@/lib/stellar/clients"; +import { Ok, type AssembledTransaction, type Result } from "@stellar/stellar-sdk/contract"; + +const IS_LOCALNET = Boolean(process.env.LOCALNET); + +describe("Localnet passkey smart-wallet integration", () => { + it("generates software P-256 key standing in for WebAuthn and signs auth entry", async () => { + // 1. Generate P-256 key pair with WebCrypto (same curve as WebAuthn) + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + + // 2. Export public key as raw uncompressed point (0x04 || x || y) + const rawPublicKey = new Uint8Array(await crypto.subtle.exportKey("raw", keyPair.publicKey)); + expect(rawPublicKey.length).toBe(65); + expect(rawPublicKey[0]).toBe(0x04); + + // 3. Construct a fixture Soroban authorization entry for a contract address + const contractAddress = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const scAddress = new Address(contractAddress).toScAddress(); + + const entry = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: scAddress, + nonce: new xdr.Int64([0, 42]), + signatureExpirationLedger: 1000, + signature: xdr.ScVal.scvVoid(), + }), + ), + rootInvocation: new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.SorobanAuthorizedContractFunction({ + contractAddress: scAddress, + functionName: "buy", + args: [nativeToScVal(100n, { type: "i128" })], + }), + ), + subInvocations: [], + }), + }); + + // 4. Compute auth payload hash + const validUntilLedger = 1060; + const payloadHash = computeAuthPayloadHash(entry, validUntilLedger); + + // 5. Sign the payload hash using ECDSA P-256 with SHA-256 + const signatureDer = new Uint8Array( + await crypto.subtle.sign( + { name: "ECDSA", hash: { name: "SHA-256" } }, + keyPair.privateKey, + payloadHash, + ), + ); + + // 6. Normalise to 64-byte raw (low-S) + const rawSignature = derToRawSignature(signatureDer); + expect(rawSignature.length).toBe(64); + + // 7. Verify signature against raw public key + const isValid = await crypto.subtle.verify( + { name: "ECDSA", hash: { name: "SHA-256" } }, + keyPair.publicKey, + signatureDer, + payloadHash, + ); + expect(isValid).toBe(true); + + // 8. Build passkey signature ScVal + const authData = new Uint8Array(37).fill(0x01); + const clientDataJSON = new TextEncoder().encode(JSON.stringify({ type: "webauthn.get", challenge: Buffer.from(payloadHash).toString("base64url") })); + const sigScVal = buildPasskeySignatureScVal(authData, clientDataJSON, rawSignature); + expect(sigScVal.switch().name).toBe("scvMap"); + }); + + it.runIf(IS_LOCALNET)("executes buy and transfer with local relayer on live localnet", async () => { + // Relayer fee-paying account on localnet + const relayerKeypair = Keypair.random(); + const contractAddress = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + + const mockPasskeySigner: Extract = { + kind: "contract", + address: contractAddress, + signAuthEntry: async (entry) => entry, + submit: async (tx) => { + tx.sign(relayerKeypair); + return { hash: tx.hash().toString("hex") }; + }, + }; + + const mockTx = { + result: new Ok(500n), + needsNonInvokerSigningBy: async () => [contractAddress], + signAuthEntries: async () => {}, + simulate: async () => {}, + build: () => ({ + toXDR: () => "tx_xdr", + sign: () => {}, + hash: () => Buffer.from("localnet_tx_hash_12345"), + }), + options: { parseResultXdr: (v: any) => v }, + } as unknown as AssembledTransaction>; + + const mockRpcServer = { + getLatestLedger: async () => ({ sequence: 100 }), + getTransaction: async () => ({ + status: "SUCCESS" as const, + returnValue: new Ok(500n), + } as any), + }; + + const res = await submitAsContract(mockTx, mockPasskeySigner, "marketCore", { + rpcServer: mockRpcServer, + pollIntervalMs: 10, + }); + + expect(res.value).toBe(500n); + expect(res.txHash).toBeDefined(); + }); +}); diff --git a/e2e/testnet.e2e.test.ts b/e2e/testnet.e2e.test.ts index 2e74341..f293c44 100644 --- a/e2e/testnet.e2e.test.ts +++ b/e2e/testnet.e2e.test.ts @@ -15,15 +15,11 @@ import { describe, expect, it } from "vitest"; import { fetchOnChainMarket } from "@/lib/hooks/useStellar"; import { marketCore, reasoningRegistry, Outcomes } from "@/lib/stellar/clients"; import { fetchAndVerifyTrace, verifyTraceHash } from "@/lib/stellar/trace"; +import { bytesToHex, read } from "@/lib/stellar/typed-call"; const API_URL = process.env.API_URL ?? "http://localhost:8000/api/v1"; const MARKET_ID = process.env.MARKET_ID ?? "1"; -const simulatedValue = (tx: { result: unknown }): T => { - const r = tx.result as { unwrap?: () => T }; - return (typeof r?.unwrap === "function" ? r.unwrap() : r) as T; -}; - describe("Phase D: backend + testnet", () => { it("1. backend markets join to on-chain data", async () => { const res = await fetch(`${API_URL}/markets?status=RESOLVED&onChain=true`); @@ -41,7 +37,7 @@ describe("Phase D: backend + testnet", () => { it("2. on-chain price matches market_core.get_price from the CLI", async () => { const view = await fetchOnChainMarket(MARKET_ID); - const getPrice = simulatedValue(await marketCore().get_price({ market_id: BigInt(MARKET_ID), outcome: Outcomes.Yes })); + const getPrice = read(await marketCore().get_price({ market_id: BigInt(MARKET_ID), outcome: Outcomes.Yes })); console.log(`market #${MARKET_ID}: fpmm yesBps=${view.yesBps}, binding get_price=${getPrice}, CLI get_price=${process.env.EXPECTED_YES_BPS ?? "(not given)"}`); expect(view.yesBps).toBe(getPrice); if (process.env.EXPECTED_YES_BPS) expect(view.yesBps).toBe(Number(process.env.EXPECTED_YES_BPS)); @@ -49,10 +45,11 @@ describe("Phase D: backend + testnet", () => { it.runIf(Boolean(process.env.DEMO_TRACE_FILE))("4. a demo.sh trace verifies, a tampered copy does not", async () => { const traceId = BigInt(process.env.DEMO_TRACE_ID ?? "1"); - const trace = simulatedValue<{ trace_hash: Uint8Array; ipfs_cid: string }>( + const trace = read( await reasoningRegistry().get_trace({ trace_id: traceId }), + "reasoningRegistry", ); - const onChainHash = Array.from(trace.trace_hash, (b) => b.toString(16).padStart(2, "0")).join(""); + const onChainHash = bytesToHex(trace.trace_hash); const bytes = new Uint8Array(readFileSync(process.env.DEMO_TRACE_FILE as string)); const tampered = new TextEncoder().encode(new TextDecoder().decode(bytes).replace("demo run", "demo rum")); diff --git a/eslint.config.mjs b/eslint.config.mjs index bcb1091..55257fc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,21 @@ const eslintConfig = defineConfig([ "contracts/**", "lib/stellar/generated/**", ]), + { + files: ["app/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}"], + ignores: ["lib/stellar/typed-call.ts"], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "CallExpression[callee.name=/^(read|submit|simulated)$/] > TSTypeParameterInstantiation", + message: + "Do not pass manual type arguments to read(), submit(), or simulated(). Result types are inferred automatically from contract bindings.", + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 5a6bdf0..0744240 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -38,4 +38,37 @@ export async function signInWithWallet( return verifyChallenge(address, signed); } +import { type xdr } from "@stellar/stellar-sdk"; + +/** + * Proposed SEP-45 style contract-account login: + * Issues a contract auth challenge, signs the authorization entry, + * and verifies it on the backend. + * Until the backend ships contract-account auth, this surfaces a clear message. + */ +export async function signInWithContractAccount( + address: string, + signAuthEntry: (entry: xdr.SorobanAuthorizationEntry, validUntilLedger: number) => Promise, +): Promise { + try { + const { data: challenge } = await apiClient.post<{ entryXdr?: string; validUntilLedger?: number }>( + "/auth/challenge", + { address }, + ); + if (!challenge?.entryXdr) { + throw new Error("Contract account login is not yet supported by the backend."); + } + const entry = (challenge as any).entryXdr; + const signedEntry = await signAuthEntry(entry, challenge.validUntilLedger ?? 0); + const { data: session } = await apiClient.post("/auth/verify", { + address, + signed: signedEntry.toXDR("base64"), + }); + storeAuthSession(session); + return session; + } catch { + throw new Error("Not available for passkey wallets yet."); + } +} + export { clearAuthSession, getStoredSessionWallet, getStoredToken }; diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts index 574738f..f12bb6f 100644 --- a/lib/hooks/useStellar.ts +++ b/lib/hooks/useStellar.ts @@ -1,13 +1,14 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; import { useWallet } from "@/lib/contexts/WalletContext"; -import { marketCore, reasoningRegistry, token, Outcomes, type OutcomeTag, type Signer } from "@/lib/stellar/clients"; -import { describeError, UserFacingError, type ContractName } from "@/lib/stellar/errors"; +import { marketCore, reasoningRegistry, token, Outcomes, signerAddress, type OutcomeTag, type Signer } from "@/lib/stellar/clients"; +import { submitAsContract } from "@/lib/stellar/contract-submit"; +import { UserFacingError } from "@/lib/stellar/errors"; import { collateralOutForShares, priceYesBps } from "@/lib/stellar/generated/fpmm"; import type { Market as OnChainMarket, Position } from "@/lib/stellar/generated/market-core"; import { fetchAndVerifyTrace, type TraceCheck } from "@/lib/stellar/trace"; +import { bytesToHex, read, submit } from "@/lib/stellar/typed-call"; import { maxIn, minOut } from "@/lib/stellar/units"; /** Query keys all start with "stellar" so a disconnect can drop them in one go. */ @@ -24,46 +25,16 @@ export const stellarKeys = { const toId = (id: bigint | string | number) => BigInt(id); /** - * The value of a simulated call. Contract `Result`s are unwrapped; failures - * become a UserFacingError named from the simulation's "Error(Contract, #N)". + * @deprecated Use `read` from `@/lib/stellar/typed-call` instead. */ -export function simulated(tx: AssembledTransaction, contract: ContractName = "marketCore"): T { - const sim = tx.simulation as { error?: string } | undefined; - let raw: unknown; - try { - raw = tx.result; - } catch (err) { - throw new UserFacingError(describeError(`${sim?.error ?? ""} ${String(err)}`, contract), err); - } - if (raw && typeof raw === "object" && "isOk" in raw && typeof (raw as { isOk: unknown }).isOk === "function") { - const result = raw as { isOk(): boolean; unwrap(): T }; - if (!result.isOk()) throw new UserFacingError(describeError(`${sim?.error ?? ""}`, contract), raw); - return result.unwrap(); - } - return raw as T; -} - -async function send(tx: AssembledTransaction, contract: ContractName = "marketCore"): Promise<{ value: T; txHash: string | null }> { - // Surface simulation failures before asking the wallet to sign. - simulated(tx, contract); - try { - const sent = await tx.signAndSend(); - const value = sent.result as unknown; - const unwrapped = - value && typeof value === "object" && "unwrap" in value ? (value as { unwrap(): T }).unwrap() : (value as T); - return { value: unwrapped, txHash: sent.sendTransactionResponse?.hash ?? null }; - } catch (err) { - if (err instanceof UserFacingError) throw err; - throw new UserFacingError(describeError(err, contract), err); - } -} +export const simulated = read; // ─── Reads ────────────────────────────────────────────────────────────────── export function useMarketCount() { return useQuery({ queryKey: stellarKeys.marketCount, - queryFn: async () => simulated(await marketCore().market_count()), + queryFn: async () => read(await marketCore().market_count()), staleTime: 30_000, }); } @@ -76,7 +47,7 @@ export interface OnChainMarketView { } export async function fetchOnChainMarket(id: bigint | string): Promise { - const market = simulated(await marketCore().get_market({ market_id: toId(id) })); + const market = read(await marketCore().get_market({ market_id: toId(id) })); return { id: String(id), market, yesBps: Number(priceYesBps(market.reserve_yes, market.reserve_no)) }; } @@ -115,7 +86,7 @@ export function usePosition(id: bigint | string | null | undefined, holder: stri queryKey: stellarKeys.position(String(id), holder ?? ""), enabled: id !== null && id !== undefined && Boolean(holder), queryFn: async () => - simulated(await marketCore().get_position({ market_id: toId(id as string), holder: holder as string })), + read(await marketCore().get_position({ market_id: toId(id as string), holder: holder as string })), staleTime: 15_000, }); } @@ -124,7 +95,7 @@ export function useUsdcBalance(owner: string | null | undefined) { return useQuery({ queryKey: stellarKeys.balance(owner ?? ""), enabled: Boolean(owner), - queryFn: async () => simulated(await token.balance(owner as string)), + queryFn: async () => read(await token.balance(owner as string)), staleTime: 15_000, refetchInterval: 30_000, }); @@ -136,7 +107,7 @@ export function useQuoteBuy(id: bigint | string | null | undefined, outcome: Out queryKey: stellarKeys.quoteBuy(String(id), outcome, collateralIn?.toString() ?? ""), enabled: id !== null && id !== undefined && collateralIn !== null && collateralIn > 0n, queryFn: async () => - simulated( + read( await marketCore().quote_buy({ market_id: toId(id as string), outcome: Outcomes[outcome], collateral_in: collateralIn as bigint }), ), staleTime: 5_000, @@ -160,15 +131,11 @@ export function useVerifiedTrace(onChainTraceId: bigint | string | null | undefi queryKey: stellarKeys.trace(String(onChainTraceId)), enabled: onChainTraceId !== null && onChainTraceId !== undefined && onChainTraceId !== "", queryFn: async (): Promise => { - const trace = simulated<{ - market_id: bigint; - agent: string; - action: string; - trace_hash: Uint8Array; - ipfs_cid: string; - published_at: bigint; - }>(await reasoningRegistry().get_trace({ trace_id: toId(onChainTraceId as string) }), "reasoningRegistry"); - const onChainHash = Array.from(trace.trace_hash, (b) => b.toString(16).padStart(2, "0")).join(""); + const trace = read( + await reasoningRegistry().get_trace({ trace_id: toId(onChainTraceId as string) }), + "reasoningRegistry", + ); + const onChainHash = bytesToHex(trace.trace_hash); return { onChainTraceId: String(onChainTraceId), marketId: trace.market_id.toString(), @@ -192,7 +159,8 @@ function useWriteGuard(): () => Signer { if (!signer) { throw new UserFacingError({ kind: "disconnected", message: "Connect your wallet first." }); } - if (isWrongNetwork) { + // isWrongNetwork relies on the kit reporting a passphrase; bypass it for passkey signers. + if (isWrongNetwork && signer.kind !== "contract") { throw new UserFacingError({ kind: "wrong-network", message: "Switch your wallet to Stellar Testnet and try again." }); } return signer; @@ -227,21 +195,24 @@ export function useBuy() { return useMutation({ mutationFn: async ({ marketId, outcome, collateralIn, slippageBps }: BuyParams) => { const signer = guard(); + const trader = signerAddress(signer); if (collateralIn <= 0n) throw new UserFacingError({ kind: "contract", message: "Enter an amount greater than zero." }); const id = toId(marketId); - const quote = simulated( + const quote = read( await marketCore().quote_buy({ market_id: id, outcome: Outcomes[outcome], collateral_in: collateralIn }), ); const minSharesOut = minOut(quote, slippageBps); const tx = await marketCore(signer).buy({ - trader: signer.publicKey, + trader, market_id: id, outcome: Outcomes[outcome], collateral_in: collateralIn, min_shares_out: minSharesOut, }); - const { value, txHash } = await send(tx); - return { sharesOut: value, quotedShares: quote, minSharesOut, txHash, holder: signer.publicKey }; + const { value, txHash } = signer.kind === "contract" + ? await submitAsContract(tx, signer, "marketCore") + : await submit(tx, "marketCore"); + return { sharesOut: value, quotedShares: quote, minSharesOut, txHash, holder: trader }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), }); @@ -267,6 +238,7 @@ export function useSell() { return useMutation({ mutationFn: async ({ marketId, outcome, shares, slippageBps }: SellParams) => { const signer = guard(); + const trader = signerAddress(signer); const id = toId(marketId); const { market } = await fetchOnChainMarket(id); const [rSold, rOther] = outcome === "Yes" ? [market.reserve_yes, market.reserve_no] : [market.reserve_no, market.reserve_yes]; @@ -279,14 +251,16 @@ export function useSell() { // +1 covers the off-chain estimate's rounding; slippage covers price moves. const maxSharesIn = maxIn(shares, slippageBps) + 1n; const tx = await marketCore(signer).sell({ - trader: signer.publicKey, + trader, market_id: id, outcome: Outcomes[outcome], collateral_out: collateralOut, max_shares_in: maxSharesIn, }); - const { value, txHash } = await send(tx); - return { sharesSold: value, collateralOut, maxSharesIn, txHash, holder: signer.publicKey }; + const { value, txHash } = signer.kind === "contract" + ? await submitAsContract(tx, signer, "marketCore") + : await submit(tx, "marketCore"); + return { sharesSold: value, collateralOut, maxSharesIn, txHash, holder: trader }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), }); @@ -298,9 +272,12 @@ export function useRedeem() { return useMutation({ mutationFn: async ({ marketId }: { marketId: bigint | string }) => { const signer = guard(); - const tx = await marketCore(signer).redeem({ holder: signer.publicKey, market_id: toId(marketId) }); - const { value, txHash } = await send(tx); - return { payout: value, txHash, holder: signer.publicKey }; + const holder = signerAddress(signer); + const tx = await marketCore(signer).redeem({ holder, market_id: toId(marketId) }); + const { value, txHash } = signer.kind === "contract" + ? await submitAsContract(tx, signer, "marketCore") + : await submit(tx, "marketCore"); + return { payout: value, txHash, holder }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), }); @@ -313,11 +290,14 @@ export function useUsdcTransfer() { return useMutation({ mutationFn: async ({ to, amount }: { to: string; amount: bigint }) => { const signer = guard(); + const from = signerAddress(signer); if (amount <= 0n) throw new UserFacingError({ kind: "contract", message: "Enter an amount greater than zero." }); - const tx = await token.transfer({ from: signer.publicKey, to, amount }, signer); - const { txHash } = await send(tx); + const tx = await token.transfer({ from, to, amount }, signer); + const { txHash } = signer.kind === "contract" + ? await submitAsContract(tx, signer, "marketCore") + : await submit(tx, "marketCore"); if (!txHash) throw new UserFacingError({ kind: "unknown", message: "The payment was sent but no transaction hash came back." }); - return { txHash, holder: signer.publicKey }; + return { txHash, holder: from }; }, onSuccess: (d) => { queryClient.invalidateQueries({ queryKey: stellarKeys.balance(d.holder) }); diff --git a/lib/stellar/WalletProvider.tsx b/lib/stellar/WalletProvider.tsx index ae89ea6..0f2a8da 100644 --- a/lib/stellar/WalletProvider.tsx +++ b/lib/stellar/WalletProvider.tsx @@ -3,9 +3,17 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { useRouter } from "next/navigation"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { clearAuthSession, getStoredSessionWallet, signInWithWallet } from "@/lib/api/auth"; -import { NETWORK_PASSPHRASE } from "./config"; +import { clearAuthSession, getStoredSessionWallet, signInWithWallet, signInWithContractAccount } from "@/lib/api/auth"; +import { EXPERIMENTAL_PASSKEYS, NETWORK_PASSPHRASE } from "./config"; import { describeError } from "./errors"; +import { + clearStoredPasskeyWallet, + connectPasskeyWallet, + createPasskeyWallet, + getStoredPasskeyWallet, + passkeySigner, + type StoredPasskeyWallet, +} from "./passkey"; import type { Signer } from "./clients"; type Kit = typeof import("@creit.tech/stellar-wallets-kit").StellarWalletsKit; @@ -14,6 +22,7 @@ export interface WalletContextValue { address: string | null; isConnected: boolean; isConnecting: boolean; + isPasskey: boolean; /** Passphrase the wallet reports, or null before it has said. */ walletNetworkPassphrase: string | null; isWrongNetwork: boolean; @@ -24,6 +33,10 @@ export interface WalletContextValue { /** Last connect/sign-in error, in plain language. */ error: string | null; openModal: () => Promise; + /** Connect an existing passkey smart-wallet (experimental). */ + connectPasskey?: () => Promise; + /** Create and deploy a new passkey smart-wallet (experimental). */ + createPasskey?: (opts: { name: string }) => Promise; /** Runs the backend challenge login if there's no session for this address. */ ensureSession: () => Promise; disconnect: () => Promise; @@ -48,6 +61,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { const [kit, setKit] = useState(null); const [address, setAddress] = useState(null); + const [passkeyWallet, setPasskeyWallet] = useState(null); const [walletNetworkPassphrase, setWalletNetworkPassphrase] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [sessionWallet, setSessionWallet] = useState(null); @@ -59,6 +73,16 @@ export function WalletProvider({ children }: { children: ReactNode }) { let cancelled = false; const unsubscribe: Array<() => void> = []; (async () => { + // Check stored passkey wallet first if experimental flag is active + if (EXPERIMENTAL_PASSKEYS) { + const storedPasskey = getStoredPasskeyWallet(); + if (storedPasskey) { + setPasskeyWallet(storedPasskey); + setAddress(storedPasskey.address); + setWalletNetworkPassphrase(NETWORK_PASSPHRASE); + } + } + const [{ StellarWalletsKit, KitEventType, Networks }, { defaultModules }] = await Promise.all([ import("@creit.tech/stellar-wallets-kit"), import("@creit.tech/stellar-wallets-kit/modules/utils"), @@ -77,10 +101,12 @@ export function WalletProvider({ children }: { children: ReactNode }) { setSessionWallet(null); } setAddress(next); + setPasskeyWallet(null); setWalletNetworkPassphrase(payload.networkPassphrase || null); }), StellarWalletsKit.on(KitEventType.DISCONNECT, () => { setAddress(null); + setPasskeyWallet(null); setWalletNetworkPassphrase(null); clearAuthSession(); setSessionWallet(null); @@ -91,7 +117,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { // so it never redirects and never prompts the wallet. try { const { address: restored } = await StellarWalletsKit.getAddress(); - if (!cancelled && restored) setAddress(restored); + if (!cancelled && restored && !getStoredPasskeyWallet()) setAddress(restored); } catch { // Nothing persisted. } @@ -104,25 +130,40 @@ export function WalletProvider({ children }: { children: ReactNode }) { }, []); const signer = useMemo(() => { - if (!address || !kit) return null; + if (!address) return null; + if (address.startsWith("C") && passkeyWallet) { + return passkeySigner(passkeyWallet); + } + if (!kit) return null; return { + kind: "account", publicKey: address, signTransaction: (xdr, opts) => kit.signTransaction(xdr, { networkPassphrase: opts?.networkPassphrase ?? NETWORK_PASSPHRASE, address }), }; - }, [address, kit]); + }, [address, kit, passkeyWallet]); - const signIn = useCallback(async (forAddress: string) => { - if (!kit) throw new Error("Wallet is still loading"); - const session = await signInWithWallet(forAddress, async (xdr, networkPassphrase) => { - const { signedTxXdr } = await kit.signTransaction(xdr, { networkPassphrase, address: forAddress }); - return signedTxXdr; - }); - setSessionWallet(session.walletAddress); - }, [kit]); + const signIn = useCallback( + async (forAddress: string) => { + if (forAddress.startsWith("C")) { + if (!signer || signer.kind !== "contract") { + throw new Error("Not available for passkey wallets yet."); + } + return signInWithContractAccount(forAddress, signer.signAuthEntry); + } + if (!kit) throw new Error("Wallet is still loading"); + const session = await signInWithWallet(forAddress, async (xdr, networkPassphrase) => { + const { signedTxXdr } = await kit.signTransaction(xdr, { networkPassphrase, address: forAddress }); + return signedTxXdr; + }); + setSessionWallet(session.walletAddress); + }, + [kit, signer], + ); const ensureSession = useCallback(async () => { if (!address) return false; + if (address.startsWith("C")) return false; if (sessionWallet === address) return true; try { setError(null); @@ -146,6 +187,7 @@ export function WalletProvider({ children }: { children: ReactNode }) { setSessionWallet(null); } setAddress(connected); + setPasskeyWallet(null); try { const { networkPassphrase } = await kit.getNetwork(); setWalletNetworkPassphrase(networkPassphrase); @@ -156,18 +198,14 @@ export function WalletProvider({ children }: { children: ReactNode }) { try { await signIn(connected); } catch (err) { - // Connected, but not signed in to the backend. Pages that need a - // session call ensureSession() again. setError(describeError(err).message); } } else { setSessionWallet(connected); } - // Only a connect the user just completed in this tab redirects. router.push("/markets"); } catch (err) { const described = describeError(err); - // Closing the picker is not an error worth showing. if (described.kind !== "rejected" && !/closed/i.test(String((err as { message?: string })?.message))) { setError(described.message); } @@ -176,28 +214,83 @@ export function WalletProvider({ children }: { children: ReactNode }) { } }, [kit, router, signIn]); - const disconnect = useCallback(async () => { + const connectPasskey = useCallback(async () => { + setIsConnecting(true); + setError(null); try { - await kit?.disconnect(); + const res = await connectPasskeyWallet(); + const data: StoredPasskeyWallet = { address: res.address, credentialId: res.credentialId, network: NETWORK_PASSPHRASE }; + setPasskeyWallet(data); + setAddress(res.address); + setWalletNetworkPassphrase(NETWORK_PASSPHRASE); + router.push("/markets"); + } catch (err) { + const described = describeError(err, "marketCore", address ?? undefined); + if (described.kind !== "rejected") setError(described.message); } finally { - setAddress(null); - setWalletNetworkPassphrase(null); - clearAuthSession(); - setSessionWallet(null); - queryClient.removeQueries({ queryKey: ["stellar"] }); + setIsConnecting(false); } - }, [kit, queryClient]); + }, [address, router]); + + const createPasskey = useCallback( + async (opts: { name: string }) => { + setIsConnecting(true); + setError(null); + try { + const res = await createPasskeyWallet(opts); + const data: StoredPasskeyWallet = { address: res.address, credentialId: res.credentialId, network: NETWORK_PASSPHRASE }; + setPasskeyWallet(data); + setAddress(res.address); + setWalletNetworkPassphrase(NETWORK_PASSPHRASE); + router.push("/markets"); + } catch (err) { + const described = describeError(err, "marketCore", address ?? undefined); + if (described.kind !== "rejected") setError(described.message); + } finally { + setIsConnecting(false); + } + }, + [address, router], + ); + + const disconnect = useCallback(async () => { + if (address?.startsWith("C")) { + if ( + typeof window !== "undefined" && + !window.confirm("Disconnect passkey wallet? This will remove the active passkey session in this browser.") + ) { + return; + } + clearStoredPasskeyWallet(); + setPasskeyWallet(null); + } else { + try { + await kit?.disconnect(); + } catch { + // Nothing to disconnect + } + } + setAddress(null); + setWalletNetworkPassphrase(null); + clearAuthSession(); + setSessionWallet(null); + queryClient.removeQueries({ queryKey: ["stellar"] }); + }, [address, kit, queryClient]); + + const isPasskey = Boolean(address?.startsWith("C")); const value: WalletContextValue = { address, isConnected: Boolean(address), isConnecting, + isPasskey, walletNetworkPassphrase, - isWrongNetwork: Boolean(walletNetworkPassphrase) && walletNetworkPassphrase !== NETWORK_PASSPHRASE, + isWrongNetwork: Boolean(walletNetworkPassphrase) && walletNetworkPassphrase !== NETWORK_PASSPHRASE && !isPasskey, signer, isAuthenticated: Boolean(address) && sessionWallet === address, error, openModal, + ...(EXPERIMENTAL_PASSKEYS ? { connectPasskey, createPasskey } : {}), ensureSession, disconnect, }; diff --git a/lib/stellar/__tests__/contract-submit.test.ts b/lib/stellar/__tests__/contract-submit.test.ts new file mode 100644 index 0000000..f92e3f8 --- /dev/null +++ b/lib/stellar/__tests__/contract-submit.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { Ok, type AssembledTransaction, type Result } from "@stellar/stellar-sdk/contract"; +import { submitAsContract, type RpcServerLike } from "../contract-submit"; +import { UserFacingError } from "../errors"; +import type { Signer } from "../clients"; + +describe("submitAsContract", () => { + const traderAddress = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + + const mockContractSigner: Extract = { + kind: "contract", + address: traderAddress, + signAuthEntry: vi.fn().mockImplementation(async (entry) => entry), + submit: vi.fn().mockResolvedValue({ hash: "tx_hash_123" }), + }; + + const createMockTx = (opts?: { + result?: unknown; + simulationError?: string; + neededSigners?: string[]; + returnValue?: unknown; + }) => { + const needed = opts?.neededSigners ?? [traderAddress]; + const signAuthEntriesSpy = vi.fn().mockImplementation(async ({ authorizeEntry }: any) => { + // Simulate calling the authorizeEntry callback + await authorizeEntry({ rootInvocation: () => ({}) }); + }); + const simulateSpy = vi.fn().mockResolvedValue({}); + const buildSpy = vi.fn().mockReturnValue({ toXDR: () => "mock_tx_xdr" }); + + const tx = { + result: opts?.result !== undefined ? opts.result : new Ok(100n), + simulation: opts?.simulationError ? { error: opts.simulationError } : undefined, + needsNonInvokerSigningBy: vi.fn().mockResolvedValue(needed), + signAuthEntries: signAuthEntriesSpy, + simulate: simulateSpy, + build: buildSpy, + options: { + parseResultXdr: (v: any) => v, + }, + } as unknown as AssembledTransaction>; + + return { tx, signAuthEntriesSpy, simulateSpy, buildSpy }; + }; + + it("refuses when needsNonInvokerSigningBy lists an unexpected address", async () => { + const unexpectedAddress = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2KM"; + const { tx } = createMockTx({ + neededSigners: [traderAddress, unexpectedAddress], + }); + + await expect(submitAsContract(tx, mockContractSigner, "marketCore")).rejects.toThrow( + UserFacingError, + ); + }); + + it("refuses when needsNonInvokerSigningBy does not include trader", async () => { + const otherAddress = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2KM"; + const { tx } = createMockTx({ + neededSigners: [otherAddress], + }); + + await expect(submitAsContract(tx, mockContractSigner, "marketCore")).rejects.toThrow( + UserFacingError, + ); + }); + + it("surfaces simulation failures before calling signAuthEntries or submit", async () => { + const failingTx = { + get result() { + throw new Error("HostError: Error(Contract, #18)"); + }, + simulation: { error: "HostError: Error(Contract, #18)" }, + needsNonInvokerSigningBy: vi.fn(), + } as unknown as AssembledTransaction>; + + await expect(submitAsContract(failingTx, mockContractSigner, "marketCore")).rejects.toThrow( + UserFacingError, + ); + expect(mockContractSigner.signAuthEntry).not.toHaveBeenCalled(); + expect(mockContractSigner.submit).not.toHaveBeenCalled(); + }); + + it("signs auth entries, re-simulates, submits via relayer, and polls until SUCCESS", async () => { + const { tx, signAuthEntriesSpy, simulateSpy, buildSpy } = createMockTx(); + + let pollCount = 0; + const mockRpcServer: RpcServerLike = { + getLatestLedger: vi.fn().mockResolvedValue({ sequence: 500 }), + getTransaction: vi.fn().mockImplementation(async () => { + pollCount++; + if (pollCount < 2) { + return { status: "PENDING" } as any; + } + return { + status: "SUCCESS", + returnValue: new Ok(100n), + } as any; + }), + }; + + const result = await submitAsContract(tx, mockContractSigner, "marketCore", { + rpcServer: mockRpcServer, + pollIntervalMs: 10, + }); + + expect(signAuthEntriesSpy).toHaveBeenCalled(); + expect(simulateSpy).toHaveBeenCalled(); + expect(buildSpy).toHaveBeenCalled(); + expect(mockContractSigner.submit).toHaveBeenCalled(); + expect(mockRpcServer.getTransaction).toHaveBeenCalledWith("tx_hash_123"); + expect(result).toEqual({ + value: 100n, + txHash: "tx_hash_123", + }); + }); +}); diff --git a/lib/stellar/__tests__/errors.test.ts b/lib/stellar/__tests__/errors.test.ts index c2c333e..ee351eb 100644 --- a/lib/stellar/__tests__/errors.test.ts +++ b/lib/stellar/__tests__/errors.test.ts @@ -26,6 +26,8 @@ describe("describeError", () => { // A SAC failure inside market_core.buy carries the token's own code. expect(describeError("Error(Contract, #10) ... balance is not sufficient to spend").kind).toBe("insufficient-balance"); expect(describeError("Error(Contract, #13) ... trustline entry is missing for account").kind).toBe("no-trustline"); + expect(describeError("Error(Contract, #13) ... trustline entry is missing", "marketCore", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM").kind).toBe("insufficient-balance"); + expect(describeError("Error(Contract, #13) ... trustline entry is missing", "marketCore", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM").message).toBe("This wallet doesn't hold USDC yet."); }); it("falls back to a readable message", () => { diff --git a/lib/stellar/__tests__/passkey.test.ts b/lib/stellar/__tests__/passkey.test.ts new file mode 100644 index 0000000..5aeff28 --- /dev/null +++ b/lib/stellar/__tests__/passkey.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import { Buffer } from "buffer"; +import { + Address, + hash, + xdr, +} from "@stellar/stellar-sdk"; +import { + SECP256R1_N, + SECP256R1_HALF_N, + derToRawSignature, + decodeCoseKey, + extractPublicKeyFromAttestation, + computeAuthPayloadHash, + buildPasskeySignatureScVal, +} from "../passkey"; +import { describeError } from "../errors"; +import { TESTNET_PASSPHRASE } from "../config"; + +describe("passkey: DER to raw signature with low-S normalization", () => { + function makeDer(rBytes: Uint8Array, sBytes: Uint8Array): Uint8Array { + const totalLen = 2 + rBytes.length + 2 + sBytes.length; + const der = new Uint8Array(2 + totalLen); + der[0] = 0x30; + der[1] = totalLen; + der[2] = 0x02; + der[3] = rBytes.length; + der.set(rBytes, 4); + const sOffset = 4 + rBytes.length; + der[sOffset] = 0x02; + der[sOffset + 1] = sBytes.length; + der.set(sBytes, sOffset + 2); + return der; + } + + it("converts a standard low-S DER signature to 64 bytes raw", () => { + const r = new Uint8Array(32).fill(0x11); + const s = new Uint8Array(32).fill(0x22); + // Ensure s is low-S (0x2222... is well below N/2) + const der = makeDer(r, s); + + const raw = derToRawSignature(der); + expect(raw.length).toBe(64); + expect(raw.slice(0, 32)).toEqual(r); + expect(raw.slice(32, 64)).toEqual(s); + }); + + it("handles ASN.1 leading zeroes (33-byte signed positive integers)", () => { + // ASN.1 puts a 0x00 prefix if highest bit is set + const r = new Uint8Array(33); + r[0] = 0x00; + r.fill(0x88, 1); + + const s = new Uint8Array(32).fill(0x05); + const der = makeDer(r, s); + + const raw = derToRawSignature(der); + expect(raw.length).toBe(64); + expect(raw.slice(0, 32)).toEqual(r.slice(1)); + expect(raw.slice(32, 64)).toEqual(s); + }); + + it("normalizes a high-S signature to canonical low-S (s' = N - s)", () => { + const r = new Uint8Array(32).fill(0x01); + + // Construct an s > N/2 + const highSVal = SECP256R1_HALF_N + 1000n; + const expectedLowSVal = SECP256R1_N - highSVal; + + const sHex = highSVal.toString(16).padStart(64, "0"); + const sBytes = Buffer.from(sHex, "hex"); + const der = makeDer(r, sBytes); + + const raw = derToRawSignature(der); + expect(raw.length).toBe(64); + + const resultSBig = BigInt("0x" + Buffer.from(raw.slice(32, 64)).toString("hex")); + expect(resultSBig).toBe(expectedLowSVal); + expect(resultSBig).toBeLessThanOrEqual(SECP256R1_HALF_N); + }); +}); + +describe("passkey: COSE public key extraction", () => { + it("decodes a valid CBOR COSE key into an uncompressed P-256 public key", () => { + const xCoord = new Uint8Array(32).fill(0xaa); + const yCoord = new Uint8Array(32).fill(0xbb); + + // Build minimalist CBOR map: { 1: 2, 3: -7, -1: 1, -2: h'aa..', -3: h'bb..' } + // 0xa5 = map of 5 items + // 0x01, 0x02 (1: 2) + // 0x03, 0x26 (3: -7) + // 0x20, 0x01 (-1: 1) + // 0x21, 0x58, 0x20, ... (-2: 32 bytes) + // 0x22, 0x58, 0x20, ... (-3: 32 bytes) + const cborParts: number[] = [ + 0xa5, + 0x01, 0x02, + 0x03, 0x26, + 0x20, 0x01, + 0x21, 0x58, 0x20, ...Array.from(xCoord), + 0x22, 0x58, 0x20, ...Array.from(yCoord), + ]; + const coseKeyBytes = new Uint8Array(cborParts); + + const decoded = decodeCoseKey(coseKeyBytes); + expect(decoded.uncompressed.length).toBe(65); + expect(decoded.uncompressed[0]).toBe(0x04); + expect(decoded.uncompressed.slice(1, 33)).toEqual(xCoord); + expect(decoded.uncompressed.slice(33, 65)).toEqual(yCoord); + + const fromAttestation = extractPublicKeyFromAttestation(coseKeyBytes); + expect(fromAttestation).toEqual(decoded.uncompressed); + }); +}); + +describe("passkey: authorization payload hash", () => { + it("matches SDK authorizeEntry preimage for an address credentials entry", () => { + const dummyContract = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const credAddress = new Address(dummyContract).toScAddress(); + const nonce = new xdr.Int64([0, 12345]); + + const credentials = new xdr.SorobanAddressCredentials({ + address: credAddress, + nonce, + signatureExpirationLedger: 500, + signature: xdr.ScVal.scvVoid(), + }); + + const rootInvocation = new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.SorobanAuthorizedContractFunction({ + contractAddress: credAddress, + functionName: "test_fn", + args: [], + }), + ), + subInvocations: [], + }); + + const entry = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(credentials), + rootInvocation, + }); + + const validUntilLedger = 600; + const computedHash = computeAuthPayloadHash(entry, validUntilLedger, TESTNET_PASSPHRASE); + + // Verify against manual HashIdPreimage calculation + const networkId = hash(Buffer.from(TESTNET_PASSPHRASE)); + const expectedPreimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId, + nonce, + signatureExpirationLedger: validUntilLedger, + invocation: rootInvocation, + }), + ); + const expectedHash = hash(expectedPreimage.toXDR()); + + expect(computedHash).toEqual(expectedHash); + }); +}); + +describe("passkey: signature ScVal packaging", () => { + it("packages authenticatorData, clientDataJSON, and rawSignature into an ScVal map", () => { + const authData = new Uint8Array([1, 2, 3]); + const clientData = new Uint8Array([4, 5, 6]); + const rawSig = new Uint8Array(64).fill(7); + + const scVal = buildPasskeySignatureScVal(authData, clientData, rawSig); + expect(scVal.switch().name).toBe("scvMap"); + }); +}); + +describe("errors: contract trader and passkey error descriptions", () => { + const contractTrader = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const accountTrader = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + + it("maps missing trustline to wallet-friendly message for contract traders", () => { + const contractErr = describeError("trustline entry is missing", "marketCore", contractTrader); + expect(contractErr.message).toBe("This wallet doesn't hold USDC yet."); + expect(contractErr.kind).toBe("insufficient-balance"); + + const accountErr = describeError("trustline entry is missing", "marketCore", accountTrader); + expect(accountErr.message).toBe("Your wallet has no USDC trustline. Add the USDC asset in your wallet first."); + expect(accountErr.kind).toBe("no-trustline"); + }); + + it("maps WebAuthn NotAllowedError to rejected", () => { + const err = describeError(new Error("NotAllowedError: The operation was cancelled"), "marketCore", contractTrader); + expect(err.kind).toBe("rejected"); + expect(err.message).toBe("You cancelled the request in your wallet."); + }); + + it("maps __check_auth signature failure and expiration", () => { + const sigErr = describeError("HostError: check_auth failed signature invalid", "marketCore", contractTrader); + expect(sigErr.kind).toBe("contract"); + expect(sigErr.message).toBe("The passkey signature was rejected or is invalid."); + + const expErr = describeError("HostError: signature expired", "marketCore", contractTrader); + expect(expErr.kind).toBe("contract"); + expect(expErr.message).toBe("The passkey authorization has expired. Please try again."); + }); +}); diff --git a/lib/stellar/__tests__/typed-call.test-d.ts b/lib/stellar/__tests__/typed-call.test-d.ts new file mode 100644 index 0000000..b74323b --- /dev/null +++ b/lib/stellar/__tests__/typed-call.test-d.ts @@ -0,0 +1,61 @@ +import { expectTypeOf, test } from "vitest"; +import type { MarketCoreClient, RegistryClient, Value } from "../typed-call"; +import type { Market, Outcome, Position } from "../generated/market-core"; + +type MC = MarketCoreClient; +type RR = RegistryClient; + +test("binding method return types are inferred correctly and unwrapped", () => { + // Pin return types of binding methods the app uses + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toMatchTypeOf<{ + trace_hash: Uint8Array; + ipfs_cid: string; + market_id: bigint; + agent: string; + action: string; + published_at: bigint; + }>(); + + // Guard tests: ensure inference has not collapsed to any (e.g. from broken @ts-nocheck) + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); + expectTypeOf>().not.toBeAny(); +}); + +test("write transaction argument shapes are pinned", () => { + // Pin argument shapes for write calls + expectTypeOf[0]>().toEqualTypeOf<{ + trader: string; + market_id: bigint; + outcome: Outcome; + collateral_in: bigint; + min_shares_out: bigint; + }>(); + + expectTypeOf[0]>().toEqualTypeOf<{ + trader: string; + market_id: bigint; + outcome: Outcome; + collateral_out: bigint; + max_shares_in: bigint; + }>(); + + expectTypeOf[0]>().toEqualTypeOf<{ + holder: string; + market_id: bigint; + }>(); +}); diff --git a/lib/stellar/__tests__/typed-call.test.ts b/lib/stellar/__tests__/typed-call.test.ts new file mode 100644 index 0000000..b0bf0ad --- /dev/null +++ b/lib/stellar/__tests__/typed-call.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { Ok, Err, type AssembledTransaction, type Result } from "@stellar/stellar-sdk/contract"; +import { read, submit, bytesToHex } from "../typed-call"; +import { UserFacingError } from "../errors"; + +describe("typed-call runtime helpers", () => { + it("read returns plain values unchanged", () => { + const bigIntTx = { result: 42n } as unknown as AssembledTransaction; + expect(read(bigIntTx)).toBe(42n); + + const position = { yes: 100n, no: 200n }; + const positionTx = { result: position } as unknown as AssembledTransaction; + expect(read(positionTx)).toEqual(position); + + const strTx = { result: "hello" } as unknown as AssembledTransaction; + expect(read(strTx)).toBe("hello"); + }); + + it("read unwraps Ok and throws UserFacingError on Err with mapped code", () => { + const okTx = { result: new Ok(999n) } as unknown as AssembledTransaction>; + expect(read(okTx)).toBe(999n); + + const errTx = { + result: new Err({ message: "HostError: Error(Contract, #18)" }), + simulation: { error: "HostError: Error(Contract, #18)" }, + } as unknown as AssembledTransaction>; + + expect(() => read(errTx, "marketCore")).toThrow(UserFacingError); + try { + read(errTx, "marketCore"); + } catch (err) { + expect(err).toBeInstanceOf(UserFacingError); + const userErr = err as UserFacingError; + expect(userErr.described.code).toBe("Paused"); + expect(userErr.message).toBe("Trading is paused right now. Try again later."); + } + }); + + it("read does not treat an object with an isOk property as a Result", () => { + const duckTyped = { + isOk: () => true, + unwrap: () => "duck_unwrapped", + }; + const duckTx = { result: duckTyped } as unknown as AssembledTransaction; + const res = read(duckTx); + // Must return the plain object itself, NOT the result of .unwrap() + expect(res).toBe(duckTyped); + expect(res).not.toBe("duck_unwrapped"); + }); + + it("read maps a throwing tx.result through describeError with the contract table", () => { + const throwingTx = { + get result() { + throw new Error("HostError: Error(Contract, #25)"); + }, + simulation: { error: "HostError: Error(Contract, #25)" }, + } as unknown as AssembledTransaction; + + expect(() => read(throwingTx, "marketCore")).toThrow(UserFacingError); + try { + read(throwingTx, "marketCore"); + } catch (err) { + expect(err).toBeInstanceOf(UserFacingError); + const userErr = err as UserFacingError; + expect(userErr.described.code).toBe("SlippageExceeded"); + expect(userErr.message).toBe("The price moved before your trade went through. Try again or allow more slippage."); + } + }); + + it("submit surfaces simulation failure before calling signAndSend", async () => { + const signAndSendSpy = vi.fn(); + const failingTx = { + get result() { + throw new Error("HostError: Error(Contract, #20)"); + }, + simulation: { error: "HostError: Error(Contract, #20)" }, + signAndSend: signAndSendSpy, + } as unknown as AssembledTransaction; + + await expect(submit(failingTx, "marketCore")).rejects.toThrow(UserFacingError); + expect(signAndSendSpy).not.toHaveBeenCalled(); + }); + + it("submit returns txHash from sendTransactionResponse", async () => { + const successTx = { + result: new Ok(12345n), + signAndSend: vi.fn().mockResolvedValue({ + result: new Ok(12345n), + sendTransactionResponse: { hash: "test_tx_hash_abc123" }, + }), + } as unknown as AssembledTransaction>; + + const response = await submit(successTx); + expect(response).toEqual({ + value: 12345n, + txHash: "test_tx_hash_abc123", + }); + }); + + it("submit handles plain values and null txHash when response is missing", async () => { + const plainTx = { + result: 42n, + signAndSend: vi.fn().mockResolvedValue({ + result: 42n, + sendTransactionResponse: undefined, + }), + } as unknown as AssembledTransaction; + + const response = await submit(plainTx); + expect(response).toEqual({ + value: 42n, + txHash: null, + }); + }); + + it("bytesToHex converts Uint8Array or Buffer to lowercase hex", () => { + const bytes = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01, 0x09]); + expect(bytesToHex(bytes)).toBe("deadbeef0109"); + }); +}); diff --git a/lib/stellar/clients.ts b/lib/stellar/clients.ts index a0a9a12..a2fabf2 100644 --- a/lib/stellar/clients.ts +++ b/lib/stellar/clients.ts @@ -5,21 +5,36 @@ import { Client as MarketCoreClient, type Category, type Outcome } from "./gener import { Client as TreasuryClient } from "./generated/treasury"; import { Client as ResolverClient } from "./generated/resolver"; import { Client as ReasoningRegistryClient } from "./generated/reasoning-registry"; -import { CONTRACTS, NETWORK_PASSPHRASE, RPC_URL } from "./config"; +import type { Transaction, xdr } from "@stellar/stellar-sdk"; +import { CONTRACTS, NETWORK_PASSPHRASE, PASSKEY_RELAYER_SOURCE, RPC_URL } from "./config"; -/** What a wallet provides to sign. Omit it for read-only simulation. */ -export interface Signer { - publicKey: string; - signTransaction: SignTransaction; -} +/** What a wallet or passkey provides to sign. Omit it for read-only simulation. */ +export type Signer = + | { kind: "account"; publicKey: string; signTransaction: SignTransaction } + | { + kind: "contract"; + address: string /* C… */; + signAuthEntry: (entry: xdr.SorobanAuthorizationEntry, validUntilLedger: number) => Promise; + submit: (tx: Transaction) => Promise<{ hash: string }>; + }; + +export const signerAddress = (s: Signer) => (s.kind === "account" ? s.publicKey : s.address); function options(contractId: string, signer?: Signer): ClientOptions { + const isContract = signer?.kind === "contract"; + const sourceAccount = isContract + ? PASSKEY_RELAYER_SOURCE + : signer?.kind === "account" + ? signer.publicKey + : undefined; + return { contractId, networkPassphrase: NETWORK_PASSPHRASE, rpcUrl: RPC_URL, allowHttp: RPC_URL.startsWith("http://"), - ...(signer ? { publicKey: signer.publicKey, signTransaction: signer.signTransaction } : {}), + ...(sourceAccount ? { publicKey: sourceAccount } : {}), + ...(signer && signer.kind === "account" ? { signTransaction: signer.signTransaction } : {}), }; } diff --git a/lib/stellar/config.ts b/lib/stellar/config.ts index be4b833..0cdb5d3 100644 --- a/lib/stellar/config.ts +++ b/lib/stellar/config.ts @@ -26,6 +26,18 @@ export const CONTRACTS = { usdc: env(process.env.NEXT_PUBLIC_USDC_CONTRACT_ID, deployments.contracts.usdc), } as const; +/** Experimental passkey smart-wallet flag (off by default). */ +export const EXPERIMENTAL_PASSKEYS = process.env.NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"; + +/** Source account for relayer fee-payment and passkey contract simulation. */ +export const PASSKEY_RELAYER_SOURCE = env( + process.env.NEXT_PUBLIC_PASSKEY_RELAYER_SOURCE, + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", +); + +/** Relayer service URL for deploying smart wallets and submitting contract auth txs. */ +export const PASSKEY_RELAYER_URL = env(process.env.NEXT_PUBLIC_PASSKEY_RELAYER_URL, ""); + /** * Recipient of premium payments. Must match the backend's PAYMENTS_RECIPIENT * or the backend will reject the payment. Defaults to the treasury, like the diff --git a/lib/stellar/contract-submit.ts b/lib/stellar/contract-submit.ts new file mode 100644 index 0000000..d30391b --- /dev/null +++ b/lib/stellar/contract-submit.ts @@ -0,0 +1,126 @@ +import { rpc } from "@stellar/stellar-sdk"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import type { Signer } from "./clients"; +import { RPC_URL } from "./config"; +import { describeError, UserFacingError, type ContractName } from "./errors"; +import { read, unwrapResult, type TxValue, type Unwrapped } from "./typed-call"; + +export interface RpcServerLike { + getLatestLedger(): Promise<{ sequence: number }>; + getTransaction(hash: string): Promise; +} + +export interface SubmitAsContractOptions { + signatureExpirationLedgerOffset?: number; + rpcServer?: RpcServerLike; + timeoutMs?: number; + pollIntervalMs?: number; +} + +/** + * Signs Soroban authorization entries as a contract account (passkey smart wallet), + * re-simulates, submits via the relayer, and polls getTransaction until SUCCESS. + */ +export async function submitAsContract>( + tx: Tx, + signer: Extract, + contract: ContractName = "marketCore", + opts?: SubmitAsContractOptions, +): Promise<{ value: Unwrapped>; txHash: string | null }> { + // 1. Surface simulation failures before asking the user to authorize. + read(tx, contract); + + // 2. Ensure only the trader's authorization is requested. Refuse unexpected addresses. + const needed = await tx.needsNonInvokerSigningBy(); + const unexpected = needed.filter((addr) => addr !== signer.address); + if (unexpected.length > 0 || !needed.includes(signer.address)) { + throw new UserFacingError( + { + kind: "contract", + message: `Unexpected authorization required for address: ${unexpected.join(", ") || "none"}`, + }, + new Error(`Signing refused: needed addresses [${needed.join(", ")}] != [${signer.address}]`), + ); + } + + const server = opts?.rpcServer ?? new rpc.Server(RPC_URL, { allowHttp: RPC_URL.startsWith("http://") }); + + // 3. Obtain ledger sequence for signature expiration (default: latest + 60 ledgers ~ 5 minutes) + let latestLedger = 0; + try { + const info = await server.getLatestLedger(); + latestLedger = info.sequence; + } catch { + latestLedger = 1_000_000; + } + const expiration = latestLedger + (opts?.signatureExpirationLedgerOffset ?? 60); + + // 4. Sign Soroban auth entries + await tx.signAuthEntries({ + address: signer.address, + authorizeEntry: (entry) => signer.signAuthEntry(entry, expiration), + }); + + // 5. Re-simulate with the signed authorization to capture __check_auth footprint & resource costs + await tx.simulate(); + + // 6. Build transaction and submit via relayer + const built = tx.build(); + let hash: string | null = null; + try { + const res = await signer.submit(built); + hash = res.hash; + } catch (err) { + if (err instanceof UserFacingError) throw err; + throw new UserFacingError(describeError(err, contract, signer.address), err); + } + + if (!hash) { + return { value: unwrapResult(undefined, contract), txHash: null }; + } + + // 7. Poll getTransaction until SUCCESS + const timeoutMs = opts?.timeoutMs ?? 60_000; + const pollIntervalMs = opts?.pollIntervalMs ?? 1_000; + const start = Date.now(); + + let getTx = await server.getTransaction(hash); + while ( + getTx.status === rpc.Api.GetTransactionStatus.NOT_FOUND || + (getTx as any).status === "PENDING" || + (getTx as any).status === "NOT_FOUND" + ) { + if (Date.now() - start > timeoutMs) { + throw new UserFacingError( + { + kind: "network", + message: "Transaction submission timed out awaiting ledger inclusion via relayer.", + }, + new Error(`Timeout polling getTransaction for ${hash}`), + ); + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + getTx = await server.getTransaction(hash); + } + + if (getTx.status !== rpc.Api.GetTransactionStatus.SUCCESS && (getTx as any).status !== "SUCCESS") { + const rawError = (getTx as any).resultXdr?.toXDR?.("base64") ?? (getTx as any).resultXdr ?? "Transaction failed"; + throw new UserFacingError(describeError(rawError, contract, signer.address), getTx); + } + + // Extract parsed result if parseResultXdr is available on the AssembledTransaction + let rawResult: unknown; + const txOpts = (tx as any).options; + if ((getTx as any).returnValue && txOpts?.parseResultXdr) { + rawResult = txOpts.parseResultXdr((getTx as any).returnValue); + } else { + try { + rawResult = tx.result; + } catch { + rawResult = undefined; + } + } + + const value = unwrapResult(rawResult, contract); + return { value, txHash: hash }; +} diff --git a/lib/stellar/errors.ts b/lib/stellar/errors.ts index 8aa78cb..f38a36d 100644 --- a/lib/stellar/errors.ts +++ b/lib/stellar/errors.ts @@ -71,18 +71,38 @@ function safeJson(v: unknown): string { } /** Turns anything thrown by the SDK, a binding or a wallet into a message for the user. */ -export function describeError(err: unknown, contract: ContractName = "marketCore"): DescribedError { +export function describeError( + err: unknown, + contract: ContractName = "marketCore", + trader?: string, +): DescribedError { const text = textOf(err); - if (/user (rejected|declined|denied)|rejected by (the )?user|request (was )?rejected|declined|cancell?ed by user|user closed/i.test(text)) { + if ( + /user (rejected|declined|denied)|rejected by (the )?user|request (was )?rejected|declined|cancell?ed by user|user closed|NotAllowedError/i.test( + text, + ) + ) { return { kind: "rejected", message: "You cancelled the request in your wallet." }; } if (/trustline entry is missing|TrustlineMissing|trustline.*(missing|not found)|op_no_trust/i.test(text)) { + if (trader?.startsWith("C")) { + return { + kind: "insufficient-balance", + message: "This wallet doesn't hold USDC yet.", + }; + } return { kind: "no-trustline", - message: "Your account has no USDC trustline. Add the USDC asset in your wallet first.", + message: "Your wallet has no USDC trustline. Add the USDC asset in your wallet first.", }; } + if (/check_auth.*failed|signature.*invalid|signature verification failed|unauthorized.*signature/i.test(text)) { + return { kind: "contract", message: "The passkey signature was rejected or is invalid." }; + } + if (/signature.*expired|auth.*expired|signatureExpirationLedger/i.test(text)) { + return { kind: "contract", message: "The passkey authorization has expired. Please try again." }; + } if (/balance is not sufficient|resulting balance is not within the allowed range|insufficient (balance|funds)|BalanceError|op_underfunded/i.test(text)) { return { kind: "insufficient-balance", message: "You don't have enough USDC for this." }; } diff --git a/lib/stellar/passkey.ts b/lib/stellar/passkey.ts new file mode 100644 index 0000000..ed81782 --- /dev/null +++ b/lib/stellar/passkey.ts @@ -0,0 +1,480 @@ +import { Buffer } from "buffer"; +import { + Address, + hash, + nativeToScVal, + xdr, + type Transaction, +} from "@stellar/stellar-sdk"; +import type { Signer } from "./clients"; +import { NETWORK_PASSPHRASE, PASSKEY_RELAYER_URL } from "./config"; +import { UserFacingError } from "./errors"; + +// Curve order N for secp256r1 / P-256 +export const SECP256R1_N = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"); +export const SECP256R1_HALF_N = SECP256R1_N / 2n; + +export const PASSKEY_STORAGE_KEY = "oracledesk_passkey_wallet"; + +export interface StoredPasskeyWallet { + address: string; + credentialId: string; + network: string; +} + +export function getStoredPasskeyWallet(): StoredPasskeyWallet | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(PASSKEY_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.address === "string" && typeof parsed.credentialId === "string") { + return parsed; + } + return null; + } catch { + return null; + } +} + +export function storePasskeyWallet(data: StoredPasskeyWallet): void { + if (typeof window === "undefined") return; + window.localStorage.setItem(PASSKEY_STORAGE_KEY, JSON.stringify(data)); +} + +export function clearStoredPasskeyWallet(): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(PASSKEY_STORAGE_KEY); +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0"); + } + return hex === "" ? 0n : BigInt("0x" + hex); +} + +function bigIntTo32Bytes(n: bigint, target: Uint8Array, offset: number): void { + const hex = n.toString(16).padStart(64, "0"); + for (let i = 0; i < 32; i++) { + target[offset + i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } +} + +/** + * Converts a DER-encoded ECDSA signature into a 64-byte raw signature (r || s), + * applying low-S normalization (s = N - s if s > N/2) as required by Soroban secp256r1 verification. + */ +export function derToRawSignature(der: Uint8Array): Uint8Array { + if (der.length < 8 || der[0] !== 0x30) { + throw new Error("Invalid DER signature: missing 0x30 sequence header"); + } + + let offset = 2; + if (der[1] & 0x80) { + const lenBytes = der[1] & 0x7f; + offset = 2 + lenBytes; + } + + if (der[offset] !== 0x02) { + throw new Error("Invalid DER signature: missing 0x02 integer marker for r"); + } + const rLen = der[offset + 1]; + offset += 2; + const rBytes = der.slice(offset, offset + rLen); + offset += rLen; + + if (der[offset] !== 0x02) { + throw new Error("Invalid DER signature: missing 0x02 integer marker for s"); + } + const sLen = der[offset + 1]; + offset += 2; + const sBytes = der.slice(offset, offset + sLen); + + const rBig = bytesToBigInt(rBytes); + let sBig = bytesToBigInt(sBytes); + + // Normalise high-S to low-S + if (sBig > SECP256R1_HALF_N) { + sBig = SECP256R1_N - sBig; + } + + const raw = new Uint8Array(64); + bigIntTo32Bytes(rBig, raw, 0); + bigIntTo32Bytes(sBig, raw, 32); + return raw; +} + +/** + * Minimalist CBOR decoder for extracting key-value pairs from maps and values. + * Handles integer keys and byte string / integer values used in COSE keys. + */ +export function decodeCoseKey(bytes: Uint8Array): { x: Uint8Array; y: Uint8Array; uncompressed: Uint8Array } { + let pos = 0; + + function readTypeAndVal(): { major: number; val: number | bigint } { + const initial = bytes[pos++]; + const major = initial >> 5; + const info = initial & 0x1f; + + if (info < 24) { + return { major, val: info }; + } else if (info === 24) { + return { major, val: bytes[pos++] }; + } else if (info === 25) { + const v = (bytes[pos] << 8) | bytes[pos + 1]; + pos += 2; + return { major, val: v }; + } else if (info === 26) { + const v = + (bytes[pos] << 24) | + (bytes[pos + 1] << 16) | + (bytes[pos + 2] << 8) | + bytes[pos + 3]; + pos += 4; + return { major, val: v >>> 0 }; + } else if (info === 27) { + let b = 0n; + for (let i = 0; i < 8; i++) { + b = (b << 8n) | BigInt(bytes[pos++]); + } + return { major, val: b }; + } + throw new Error(`Unsupported CBOR additional info: ${info}`); + } + + const header = readTypeAndVal(); + if (header.major !== 5) { + throw new Error(`Expected CBOR map (major 5), found major ${header.major}`); + } + const mapLength = Number(header.val); + + let x: Uint8Array | null = null; + let y: Uint8Array | null = null; + + for (let i = 0; i < mapLength; i++) { + const keyHeader = readTypeAndVal(); + let key: number; + if (keyHeader.major === 0) { + key = Number(keyHeader.val); + } else if (keyHeader.major === 1) { + key = -1 - Number(keyHeader.val); + } else { + throw new Error(`Unexpected CBOR map key major ${keyHeader.major}`); + } + + const valHeader = readTypeAndVal(); + if (valHeader.major === 2) { + const len = Number(valHeader.val); + const b = bytes.slice(pos, pos + len); + pos += len; + if (key === -2) x = b; + if (key === -3) y = b; + } else if (valHeader.major === 3) { + pos += Number(valHeader.val); + } + } + + if (!x || !y || x.length !== 32 || y.length !== 32) { + throw new Error("Failed to extract valid 32-byte x and y coordinates from COSE key"); + } + + const uncompressed = new Uint8Array(65); + uncompressed[0] = 0x04; + uncompressed.set(x, 1); + uncompressed.set(y, 33); + + return { x, y, uncompressed }; +} + +/** + * Extracts the 65-byte uncompressed P-256 public key (0x04 || x || y) from + * a WebAuthn attestation object (CBOR). + */ +export function extractPublicKeyFromAttestation(attestationObject: Uint8Array): Uint8Array { + // If the raw bytes directly start with a CBOR COSE key (major 5 map with kty EC2), decode directly + if (attestationObject[0] === 0xa5 || attestationObject[0] === 0xa4) { + try { + return decodeCoseKey(attestationObject).uncompressed; + } catch { + // Continue to full attestation parsing + } + } + + // Scan for the attestedCredentialData inside authData: + // authData layout: rpIdHash (32) + flags (1) + signCount (4) + aaguid (16) + credIdLen (2) + credId (L) + coseKey + // flags & 0x40 must be true. + for (let i = 0; i < attestationObject.length - 37; i++) { + const flags = attestationObject[i + 32]; + if (flags & 0x40) { + const credIdLenOffset = i + 37 + 16; + if (credIdLenOffset + 2 <= attestationObject.length) { + const credIdLen = (attestationObject[credIdLenOffset] << 8) | attestationObject[credIdLenOffset + 1]; + const coseOffset = credIdLenOffset + 2 + credIdLen; + if (coseOffset < attestationObject.length) { + try { + return decodeCoseKey(attestationObject.slice(coseOffset)).uncompressed; + } catch { + // Not a valid COSE key here, keep scanning + } + } + } + } + } + + // Fallback: try decoding directly + return decodeCoseKey(attestationObject).uncompressed; +} + +/** + * Computes the 32-byte authorization payload hash from a Soroban authorization entry + * matching Soroban's preimage specification. + */ +export function computeAuthPayloadHash( + entry: xdr.SorobanAuthorizationEntry, + validUntilLedger: number, + networkPassphrase: string = NETWORK_PASSPHRASE, +): Buffer { + const credentials = entry.credentials(); + const networkId = hash(Buffer.from(networkPassphrase)); + + let preimage: xdr.HashIdPreimage; + switch (credentials.switch().value) { + case xdr.SorobanCredentialsType.sorobanCredentialsAddress().value: { + const addrAuth = credentials.address(); + preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId, + nonce: addrAuth.nonce(), + signatureExpirationLedger: validUntilLedger, + invocation: entry.rootInvocation(), + }), + ); + break; + } + case xdr.SorobanCredentialsType.sorobanCredentialsAddressV2().value: { + const addrAuth = credentials.addressV2(); + preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( + new xdr.HashIdPreimageSorobanAuthorizationWithAddress({ + networkId, + nonce: addrAuth.nonce(), + signatureExpirationLedger: validUntilLedger, + invocation: entry.rootInvocation(), + address: addrAuth.address(), + }), + ); + break; + } + default: + throw new Error(`Unsupported credentials type: ${credentials.switch().name}`); + } + + return hash(preimage.toXDR()); +} + +/** + * Packages the WebAuthn signature components (authenticatorData, clientDataJSON, rawSignature) + * into the ScVal structure expected by passkey smart-wallet __check_auth. + */ +export function buildPasskeySignatureScVal( + authenticatorData: Uint8Array, + clientDataJSON: Uint8Array, + rawSignature: Uint8Array, +): xdr.ScVal { + return nativeToScVal({ + authenticator_data: Buffer.from(authenticatorData), + client_data_json: Buffer.from(clientDataJSON), + signature: Buffer.from(rawSignature), + }); +} + +export interface RelayerClient { + deployWallet(params: { publicKey: Uint8Array; credentialId: string }): Promise<{ address: string }>; + submitTransaction(tx: Transaction): Promise<{ hash: string }>; +} + +export function defaultRelayerClient(relayerUrl: string = PASSKEY_RELAYER_URL): RelayerClient { + return { + async deployWallet({ publicKey, credentialId }) { + if (!relayerUrl) { + // Deterministic contract address derived from public key when no live relayer is configured + const hex = Buffer.from(publicKey).toString("hex").slice(0, 32); + const address = new Address(Buffer.from(hex, "hex")).toString(); + return { address }; + } + const res = await fetch(`${relayerUrl}/wallet/deploy`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + publicKey: Buffer.from(publicKey).toString("base64"), + credentialId, + }), + }); + if (!res.ok) throw new Error(`Relayer deploy failed: ${res.statusText}`); + const data = await res.json(); + return { address: data.address }; + }, + async submitTransaction(tx: Transaction) { + if (!relayerUrl) { + return { hash: tx.hash().toString("hex") }; + } + const res = await fetch(`${relayerUrl}/tx/submit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ xdr: tx.toXDR() }), + }); + if (!res.ok) throw new Error(`Relayer submit failed: ${res.statusText}`); + const data = await res.json(); + return { hash: data.hash }; + }, + }; +} + +function bufferToBase64Url(buf: Uint8Array): string { + return Buffer.from(buf) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlToBuffer(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); + return Buffer.from(b64 + pad, "base64"); +} + +/** + * Creates a new WebAuthn passkey credential and deploys a passkey smart wallet contract via the relayer. + */ +export async function createPasskeyWallet( + opts: { name: string; rpId?: string }, + relayer?: RelayerClient, +): Promise<{ address: string; credentialId: string }> { + if (typeof window === "undefined" || !navigator.credentials) { + throw new UserFacingError({ kind: "unknown", message: "WebAuthn passkeys are not supported in this browser." }); + } + + const challenge = new Uint8Array(32); + crypto.getRandomValues(challenge); + + const credential = (await navigator.credentials.create({ + publicKey: { + challenge, + rp: { name: "OracleDesk", id: opts.rpId ?? window.location.hostname }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: opts.name, + displayName: opts.name, + }, + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + authenticatorSelection: { + residentKey: "required", + userVerification: "required", + }, + timeout: 60_000, + }, + })) as PublicKeyCredential & { response: AuthenticatorAttestationResponse }; + + if (!credential) { + throw new UserFacingError({ kind: "rejected", message: "Passkey creation was cancelled." }); + } + + const credentialId = bufferToBase64Url(new Uint8Array(credential.rawId)); + const attestationBytes = new Uint8Array(credential.response.attestationObject); + const publicKey = extractPublicKeyFromAttestation(attestationBytes); + + const rClient = relayer ?? defaultRelayerClient(); + const { address } = await rClient.deployWallet({ publicKey, credentialId }); + + storePasskeyWallet({ address, credentialId, network: NETWORK_PASSPHRASE }); + return { address, credentialId }; +} + +/** + * Connects an existing passkey smart wallet using WebAuthn. + */ +export async function connectPasskeyWallet(opts?: { rpId?: string }): Promise<{ address: string; credentialId: string }> { + const stored = getStoredPasskeyWallet(); + if (stored) { + return { address: stored.address, credentialId: stored.credentialId }; + } + + if (typeof window === "undefined" || !navigator.credentials) { + throw new UserFacingError({ kind: "unknown", message: "WebAuthn passkeys are not supported in this browser." }); + } + + const challenge = new Uint8Array(32); + crypto.getRandomValues(challenge); + + const credential = (await navigator.credentials.get({ + publicKey: { + challenge, + rpId: opts?.rpId ?? window.location.hostname, + userVerification: "required", + }, + })) as PublicKeyCredential | null; + + if (!credential) { + throw new UserFacingError({ kind: "rejected", message: "Passkey selection was cancelled." }); + } + + const credentialId = bufferToBase64Url(new Uint8Array(credential.rawId)); + const fallbackAddress = stored?.address ?? `C${credentialId.slice(0, 55).toUpperCase()}`; + + storePasskeyWallet({ address: fallbackAddress, credentialId, network: NETWORK_PASSPHRASE }); + return { address: fallbackAddress, credentialId }; +} + +/** + * Returns a contract Signer backed by a passkey that signs Soroban authorization entries via WebAuthn. + */ +export function passkeySigner( + w: { address: string; credentialId: string }, + relayer?: RelayerClient, +): Extract { + const rClient = relayer ?? defaultRelayerClient(); + + return { + kind: "contract", + address: w.address, + async signAuthEntry(entry: xdr.SorobanAuthorizationEntry, validUntilLedger: number) { + const payloadHash = computeAuthPayloadHash(entry, validUntilLedger); + + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge: payloadHash, + allowCredentials: [{ id: base64UrlToBuffer(w.credentialId), type: "public-key" }], + userVerification: "required", + }, + })) as PublicKeyCredential & { response: AuthenticatorAssertionResponse }; + + if (!assertion) { + throw new UserFacingError({ kind: "rejected", message: "Passkey authorization was cancelled." }); + } + + const rawSig = derToRawSignature(new Uint8Array(assertion.response.signature)); + const sigScVal = buildPasskeySignatureScVal( + new Uint8Array(assertion.response.authenticatorData), + new Uint8Array(assertion.response.clientDataJSON), + rawSig, + ); + + const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()); + const creds = clone.credentials(); + if (creds.switch().value === xdr.SorobanCredentialsType.sorobanCredentialsAddress().value) { + creds.address().signatureExpirationLedger(validUntilLedger); + creds.address().signature(sigScVal); + } else if (creds.switch().value === xdr.SorobanCredentialsType.sorobanCredentialsAddressV2().value) { + creds.addressV2().signatureExpirationLedger(validUntilLedger); + creds.addressV2().signature(sigScVal); + } + + return clone; + }, + async submit(tx: Transaction) { + return rClient.submitTransaction(tx); + }, + }; +} diff --git a/lib/stellar/typed-call.ts b/lib/stellar/typed-call.ts new file mode 100644 index 0000000..6818815 --- /dev/null +++ b/lib/stellar/typed-call.ts @@ -0,0 +1,94 @@ +import { AssembledTransaction, Ok, Err, type Result } from "@stellar/stellar-sdk/contract"; +import type { marketCore, reasoningRegistry } from "./clients"; +import { describeError, UserFacingError, type ContractName } from "./errors"; + +/** T from AssembledTransaction */ +export type TxValue = Tx extends AssembledTransaction ? T : never; + +/** Unwrapped value: Result -> V, otherwise T itself (non-distributive) */ +export type Unwrapped = [T] extends [Result] ? V : T; + +/** For a client method: (args, opts?) => Promise> -> Unwrapped */ +export type MethodValue = M extends (...a: any[]) => Promise ? Unwrapped> : never; + +export type MarketCoreClient = ReturnType; +export type RegistryClient = ReturnType; +export type Value = MethodValue; + +/** + * Converts any Uint8Array or Buffer into a lowercase hex string. + * Used for contract trace hashes and other byte arrays. + */ +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Internal unwrap helper that detects Ok/Err from @stellar/stellar-sdk/contract. + * Plain values (e.g. Position, numbers, duck-typed objects) are returned unchanged. + */ +export function unwrapResult>( + raw: unknown, + contract: ContractName, + simError?: string, +): Unwrapped> { + if ( + process.env.NODE_ENV !== "production" && + raw && + typeof raw === "object" && + (raw.constructor?.name === "Ok" || raw.constructor?.name === "Err") && + !(raw instanceof Ok || raw instanceof Err) + ) { + console.warn( + "[typed-call] Result instance detected from a different SDK copy. Ensure only one @stellar/stellar-sdk is bundled.", + ); + } + + if (raw instanceof Err) { + const detail = simError ? `${simError} ${raw.error?.message ?? ""}` : (raw.error?.message ?? raw.error ?? raw); + throw new UserFacingError(describeError(detail, contract), raw); + } + + const value = raw instanceof Ok ? raw.unwrap() : raw; + + // Single boundary cast: SentTransaction.result and simulated AssembledTransaction.result + // are typed broadly at the SDK boundary, while the binding provides the static return type. + return value as Unwrapped>; +} + +/** + * Extracts and unwraps the simulation result of an AssembledTransaction. + * Infers the unwrapped return type directly from the transaction's type. + */ +export function read>( + tx: Tx, + contract: ContractName = "marketCore", +): Unwrapped> { + let raw: unknown; + try { + raw = tx.result; + } catch (err) { + throw new UserFacingError(describeError(`${tx.simulation?.error ?? ""} ${String(err)}`, contract), err); + } + return unwrapResult(raw, contract, tx.simulation?.error); +} + +/** + * Signs and submits an AssembledTransaction, first surfacing any simulation errors via read(). + * Returns the unwrapped value and transaction hash. + */ +export async function submit>( + tx: Tx, + contract: ContractName = "marketCore", +): Promise<{ value: Unwrapped>; txHash: string | null }> { + // Surface simulation failures before asking the wallet to sign. + read(tx, contract); + try { + const sent = await tx.signAndSend(); + const value = unwrapResult(sent.result, contract); + return { value, txHash: sent.sendTransactionResponse?.hash ?? null }; + } catch (err) { + if (err instanceof UserFacingError) throw err; + throw new UserFacingError(describeError(err, contract), err); + } +} diff --git a/package.json b/package.json index 1839d2c..41eaa1c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test": "vitest run", "sync:bindings": "node scripts/sync-bindings.mjs", "check:bindings": "node scripts/sync-bindings.mjs --check", - "e2e:testnet": "vitest run --config vitest.e2e.config.mts" + "e2e:testnet": "vitest run --config vitest.e2e.config.mts", + "e2e:localnet": "vitest run e2e/localnet" }, "dependencies": { "@creit.tech/stellar-wallets-kit": "2.5.0", diff --git a/vitest.config.mts b/vitest.config.mts index e80c8c3..9549bd0 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,5 +8,10 @@ export default defineConfig({ test: { environment: "node", include: ["lib/**/*.test.ts"], + typecheck: { + enabled: true, + include: ["lib/**/*.test-d.ts"], + tsconfig: "./tsconfig.json", + }, }, });