Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/copy-trade/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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…");
Expand Down
7 changes: 6 additions & 1 deletion app/premium/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions app/reasoning/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);

Expand Down
117 changes: 99 additions & 18 deletions components/layout/WalletDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const balance = useUsdcBalance(address);

Expand All @@ -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);
Expand All @@ -27,14 +40,66 @@ const WalletDropdown = () => {

if (!isConnected || !address) {
return (
<button
onClick={() => openModal()}
disabled={isConnecting}
title={error ?? undefined}
className="bg-primary text-primary-foreground px-4 py-2 rounded-lg font-label-caps text-label-caps uppercase tracking-wider hover:opacity-90 transition-all active:scale-95 disabled:opacity-60"
>
{isConnecting ? "Connecting…" : "Connect Wallet"}
</button>
<div className="flex items-center gap-2">
<button
onClick={() => openModal()}
disabled={isConnecting}
title={error ?? undefined}
className="bg-primary text-primary-foreground px-4 py-2 rounded-lg font-label-caps text-label-caps uppercase tracking-wider hover:opacity-90 transition-all active:scale-95 disabled:opacity-60"
>
{isConnecting ? "Connecting…" : "Connect Wallet"}
</button>

{EXPERIMENTAL_PASSKEYS && connectPasskey && (
<div className="relative">
<button
onClick={() => setShowPasskeyPrompt(!showPasskeyPrompt)}
disabled={isConnecting}
title="Sign in with Face ID or a passkey smart wallet"
className="flex items-center gap-1.5 bg-surface-container-low border border-outline-variant hover:bg-surface-container text-on-surface px-3 py-2 rounded-lg font-label-caps text-label-caps tracking-wider transition-all active:scale-95"
>
<span className="material-symbols-outlined text-[16px]">key</span>
<span>Passkey</span>
<span className="text-[9px] font-bold uppercase bg-amber-100 text-amber-800 px-1 py-0.2 rounded">Exp</span>
</button>

<AnimatePresence>
{showPasskeyPrompt && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute right-0 mt-2 w-56 bg-white border border-outline-variant rounded-xl shadow-xl z-[100] p-2 space-y-1"
>
<button
onClick={() => {
setShowPasskeyPrompt(false);
void connectPasskey();
}}
className="w-full text-left px-3 py-2 text-xs font-bold hover:bg-surface-container rounded-lg transition-colors flex items-center justify-between"
>
<span>Connect existing</span>
<span className="material-symbols-outlined text-[14px]">login</span>
</button>
{createPasskey && (
<button
onClick={() => {
setShowPasskeyPrompt(false);
const name = window.prompt("Enter a name for your passkey smart-wallet:", "My Passkey Wallet");
if (name) void createPasskey({ name });
}}
className="w-full text-left px-3 py-2 text-xs font-bold text-primary hover:bg-surface-container rounded-lg transition-colors flex items-center justify-between"
>
<span>Create new wallet</span>
<span className="material-symbols-outlined text-[14px]">add_circle</span>
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
);
}

Expand Down Expand Up @@ -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"
>
<div className="flex flex-col items-end hidden sm:flex">
<span className={`text-[10px] font-bold leading-none uppercase ${isWrongNetwork ? "text-error" : "text-on-surface-variant"}`}>
{isWrongNetwork ? "Wrong network" : NETWORK_LABEL}
</span>
<div className="flex items-center gap-1">
{isPasskey && (
<span className="text-[9px] font-bold uppercase bg-amber-100 text-amber-800 px-1 rounded">
Experimental
</span>
)}
<span className={`text-[10px] font-bold leading-none uppercase ${isWrongNetwork ? "text-error" : "text-on-surface-variant"}`}>
{isWrongNetwork ? "Wrong network" : isPasskey ? "Smart Wallet" : NETWORK_LABEL}
</span>
</div>
<span className="text-sm font-data-mono font-bold text-on-surface">
{truncatedAddress}
</span>
</div>
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<span className="material-symbols-outlined text-[20px]">account_balance_wallet</span>
<span className="material-symbols-outlined text-[20px]">{isPasskey ? "key" : "account_balance_wallet"}</span>
</div>
<span className={`material-symbols-outlined text-on-surface-variant transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}>
expand_more
Expand All @@ -90,9 +162,16 @@ const WalletDropdown = () => {
>
{/* Balance Section */}
<div className="p-4 bg-surface-container-low border-b border-outline-variant">
<span className="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest block mb-1">
Available Balance
</span>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold text-on-surface-variant uppercase tracking-widest block">
Available Balance
</span>
{isPasskey && (
<span className="text-[9px] font-bold uppercase bg-amber-100 text-amber-800 px-1 py-0.2 rounded">
Passkey
</span>
)}
</div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold text-on-surface font-data-mono">
{balanceText}
Expand Down Expand Up @@ -133,7 +212,9 @@ const WalletDropdown = () => {
</span>
<div className="text-left">
<span className="block text-sm font-bold text-on-surface">View on Stellar Expert</span>
<span className="block text-[10px] text-on-surface-variant uppercase">Account history</span>
<span className="block text-[10px] text-on-surface-variant uppercase">
{isPasskey ? "Contract history" : "Wallet history"}
</span>
</div>
</a>

Expand Down
39 changes: 39 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<base64 SorobanAuthorizationEntry XDR>",
"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": "<base64 signed SorobanAuthorizationEntry XDR>"
}
```

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": "<jwt>", "userId": "<uuid>", "walletAddress": "CABC…" }
```

### Markets

#### Market object
Expand Down
23 changes: 22 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<T>` is unwrapped to `T`, while non-Result values like `Position` or SAC balances remain untouched). Never pass a type parameter (e.g. `read<Type>(...)`) 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.
Expand Down
Loading
Loading