From cbf369ec3d97bd79752875a0a96435534300c2f7 Mon Sep 17 00:00:00 2001 From: Alex Benjamin Date: Sat, 17 Oct 2026 17:46:27 +0100 Subject: [PATCH] fixed issues --- .kiro/specs/passkey-smart-wallet/.config.kiro | 1 + .kiro/specs/passkey-smart-wallet/bugfix.md | 63 +++ .kiro/specs/passkey-smart-wallet/design.md | 505 ++++++++++++++++++ .kiro/specs/passkey-smart-wallet/tasks.md | 251 +++++++++ .../specs/typed-binding-results/.config.kiro | 1 + .kiro/specs/typed-binding-results/bugfix.md | 57 ++ .kiro/specs/typed-binding-results/design.md | 354 ++++++++++++ .kiro/specs/typed-binding-results/tasks.md | 114 ++++ app/reasoning/verify/page.tsx | 6 +- docs/architecture.md | 10 + e2e/testnet.e2e.test.ts | 13 +- eslint.config.mjs | 13 + lib/hooks/useStellar.ts | 72 +-- lib/stellar/__tests__/typed-call.test-d.ts | 90 ++++ lib/stellar/__tests__/typed-call.test.ts | 164 ++++++ lib/stellar/typed-call.ts | 98 ++++ vitest.config.mts | 1 + 17 files changed, 1746 insertions(+), 67 deletions(-) create mode 100644 .kiro/specs/passkey-smart-wallet/.config.kiro create mode 100644 .kiro/specs/passkey-smart-wallet/bugfix.md create mode 100644 .kiro/specs/passkey-smart-wallet/design.md create mode 100644 .kiro/specs/passkey-smart-wallet/tasks.md create mode 100644 .kiro/specs/typed-binding-results/.config.kiro create mode 100644 .kiro/specs/typed-binding-results/bugfix.md create mode 100644 .kiro/specs/typed-binding-results/design.md create mode 100644 .kiro/specs/typed-binding-results/tasks.md create mode 100644 lib/stellar/__tests__/typed-call.test-d.ts create mode 100644 lib/stellar/__tests__/typed-call.test.ts create mode 100644 lib/stellar/typed-call.ts diff --git a/.kiro/specs/passkey-smart-wallet/.config.kiro b/.kiro/specs/passkey-smart-wallet/.config.kiro new file mode 100644 index 0000000..60734bf --- /dev/null +++ b/.kiro/specs/passkey-smart-wallet/.config.kiro @@ -0,0 +1 @@ +{"specId": "c5b2918e-a82c-4a39-8d03-06f478afe72e", "workflowType": "requirements-first", "specType": "bugfix"} diff --git a/.kiro/specs/passkey-smart-wallet/bugfix.md b/.kiro/specs/passkey-smart-wallet/bugfix.md new file mode 100644 index 0000000..27b110f --- /dev/null +++ b/.kiro/specs/passkey-smart-wallet/bugfix.md @@ -0,0 +1,63 @@ +# Bugfix Requirements Document + +## Introduction + +The frontend assumes every trader is an ed25519 keypair wallet (a `G…` address). Stellar supports contract accounts — passkey smart wallets — that are identified by a `C…` address and authenticate via WebAuthn (secp256r1) rather than ed25519. Three concrete bugs prevent these accounts from trading: + +1. The `Signer` type only models ed25519 accounts, so there is no way to represent a contract-account signer. Every write path reads `signer.publicKey` as the trader address and calls `tx.signAndSend()` (which signs a whole envelope), both of which are wrong for a contract account. +2. The backend login flow (`signInWithWallet` / `assertStellarAccount`) issues a SEP-10 challenge transaction and expects an ed25519 signature; a contract account cannot sign that challenge. +3. UI copy and error messages were written for ed25519 accounts: the wallet dropdown says "Account history" and links to an account explorer page, and `describeError` can surface trustline advice that has no meaning for contract-account holders. + +This spec gates all fixes behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"` so the existing path is never disturbed. + +--- + +## Bug Analysis + +### Current Behavior (Defect) + +1.1 WHEN a user connects a passkey smart wallet (`C…` address) and attempts to buy, sell, redeem, or transfer THEN the system uses `signer.publicKey` as the trader address, which is `undefined` for a contract signer, causing the transaction to be built with an invalid source and the trade to fail. + +1.2 WHEN a user connects a passkey smart wallet and the write path calls `tx.signAndSend()` THEN the system attempts to sign the full transaction envelope with the contract address, which is not a valid ed25519 signer, causing submission to fail. + +1.3 WHEN a user connects a passkey smart wallet and `openModal` or `ensureSession` is called THEN the system calls `signInWithWallet`, which POSTs to `/auth/challenge`, receives a SEP-10 challenge transaction, and expects the wallet to call `signTransaction`; a contract account cannot sign a transaction envelope, so authentication always fails. + +1.4 WHEN the connected address is a `C…` contract address and an error occurs during a trade THEN the system may surface a trustline-missing message (e.g. "Add the USDC asset in your wallet first"), which does not apply to contract accounts and misleads the user. + +1.5 WHEN the connected address is a `C…` contract address and the user opens the wallet dropdown THEN the system displays "Account history" and links to a Stellar Expert account page formatted for ed25519 accounts, which is incorrect for contract addresses. + +1.6 WHEN `useWriteGuard` runs an `isWrongNetwork` check for a passkey signer THEN the system has no network passphrase from the wallets kit for a passkey flow, so the check always evaluates incorrectly, potentially blocking valid trades or allowing wrong-network trades. + +--- + +### Expected Behavior (Correct) + +2.1 WHEN a user connects a passkey smart wallet (`C…` address) and attempts to buy, sell, redeem, or transfer THEN the system SHALL use `signerAddress(signer)` (i.e. the `C…` address) as the trader address so the transaction is built correctly. + +2.2 WHEN a user connects a passkey smart wallet and a write operation is initiated THEN the system SHALL sign only the Soroban authorization entries (via `signAuthEntry`) rather than the whole envelope, and SHALL submit the assembled transaction through a relayer or fee-paying source account (not via `tx.signAndSend()`). + +2.3 WHEN a user connects a passkey smart wallet and authentication is required THEN the system SHALL use a contract-account challenge flow (SEP-45 or equivalent contract-auth challenge) that signs a 32-byte payload hash via WebAuthn rather than a transaction envelope, and SHALL gate this flow behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS`. + +2.4 WHEN the connected address is a `C…` contract address and an error occurs during a trade THEN the system SHALL suppress trustline-related error messages for contract addresses and instead show a generic "Something went wrong" or a contract-specific error message. + +2.5 WHEN the connected address is a `C…` contract address and the user opens the wallet dropdown THEN the system SHALL display "Wallet history" (not "Account history") and SHALL link to the Stellar Expert contract explorer page (`/contract/…`) instead of the account page. + +2.6 WHEN `useWriteGuard` runs network checks for a passkey signer THEN the system SHALL bypass the `isWrongNetwork` check (since network identity is derived from the contract, not the wallets kit), so valid passkey trades are never incorrectly blocked. + +--- + +### Unchanged Behavior (Regression Prevention) + +3.1 WHEN a user connects a standard ed25519 wallet (`G…` address) and attempts to buy, sell, redeem, or transfer THEN the system SHALL CONTINUE TO use `signer.publicKey` as the trader address and submit via `tx.signAndSend()` exactly as today. + +3.2 WHEN a user connects a standard ed25519 wallet and authenticates THEN the system SHALL CONTINUE TO use the SEP-10 challenge-transaction flow (`signInWithWallet`) without any change to that code path. + +3.3 WHEN a standard ed25519 wallet triggers a trustline-missing error THEN the system SHALL CONTINUE TO display the trustline advice message ("Add the USDC asset in your wallet first"). + +3.4 WHEN a standard ed25519 wallet is connected and the wallet dropdown is open THEN the system SHALL CONTINUE TO display "Account history" and link to the Stellar Expert account explorer page. + +3.5 WHEN `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS` is not set or is set to any value other than `"true"` THEN the system SHALL CONTINUE TO show no passkey connection option in the UI and SHALL CONTINUE TO route all sign-in and trade operations through the existing ed25519 paths. + +3.6 WHEN a standard ed25519 wallet is connected and `useWriteGuard` runs THEN the system SHALL CONTINUE TO enforce the `isWrongNetwork` check and block trades on the wrong network. + +3.7 WHEN read operations (usePosition, useUsdcBalance, useOnChainMarket) are called with any address THEN the system SHALL CONTINUE TO work unchanged, since reads already accept any address and do not depend on signer kind. diff --git a/.kiro/specs/passkey-smart-wallet/design.md b/.kiro/specs/passkey-smart-wallet/design.md new file mode 100644 index 0000000..9673599 --- /dev/null +++ b/.kiro/specs/passkey-smart-wallet/design.md @@ -0,0 +1,505 @@ +# Passkey Smart Wallet Bugfix Design + +## Overview + +The frontend hardcodes an ed25519 (`G…`) model for every trader. Passkey smart wallets +(`C…` contract accounts) authenticate via WebAuthn (secp256r1) and require a completely +different signing path. Six bugs prevent contract-account holders from trading. All fixes +are gated behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"`, so the existing ed25519 +path is never disturbed. + +The fix introduces a union `Signer` type (`kind: "ed25519" | "contract"`), routes write +operations through a new `contract-submit.ts` relayer path for contract signers, replaces +the SEP-10 auth flow with a contract-account challenge for passkey sessions, suppresses +trustline errors for `C…` addresses, adjusts WalletDropdown copy, and bypasses the kit's +network-passphrase check for contract signers. + +--- + +## Glossary + +- **Bug_Condition (C)**: The set of runtime states where the connected signer has + `kind === "contract"` (i.e. the address begins with `C`). +- **Property (P)**: The correct behavior for each write path, auth flow, and UI element + when `kind === "contract"` — defined per-bug in the Correctness Properties section. +- **Preservation**: All behavior for `kind === "ed25519"` signers (and for any code path + when the passkeys flag is off) that must remain identical after the fix. +- **`Signer`**: The interface in `lib/stellar/clients.ts` that the wallet provides. + Currently `{ publicKey: string; signTransaction: SignTransaction }`. +- **`signerAddress(signer)`**: A new helper that returns `signer.contractId` for contract + signers and `signer.publicKey` for ed25519 signers — the canonical trader address. +- **`isBugCondition(signer)`**: Returns `true` iff `signer.kind === "contract"`. +- **`AssembledTransaction`**: The Stellar SDK type used for every write in `useStellar.ts`. + Currently always submitted via `tx.signAndSend()`. +- **SEP-10**: The existing challenge-transaction auth flow (`signInWithWallet` / + `assertStellarAccount`). Works only for ed25519 keys. +- **SEP-45 / contract-auth challenge**: A 32-byte payload hash signed via WebAuthn. + Documented as "proposed" in `docs/api.md`. Used for contract-account login. +- **`signAuthEntry`**: Stellar SDK method that signs only a Soroban authorization entry + (not the full envelope) — the correct signing primitive for contract accounts. +- **`describeError`**: `lib/stellar/errors.ts` function that classifies thrown errors into + user-facing messages. Emits trustline advice that is irrelevant for `C…` addresses. +- **`explorerAccount`**: `lib/stellar/config.ts` helper that already routes `C…` addresses + to `/contract/…`. Used by WalletDropdown. +- **`useWriteGuard`**: Hook in `lib/hooks/useStellar.ts` that checks `isWrongNetwork` + before any write; the check uses the wallets-kit passphrase, which is absent for passkey + signers. + +--- + +## Bug Details + +### Fault Condition + +The bug manifests when a user connects a passkey smart wallet (`C…` address) and the +frontend code (a) reads `signer.publicKey` as the trader address, (b) calls +`tx.signAndSend()` to submit, (c) routes through the SEP-10 auth flow, (d) surfaces +trustline error messages, (e) shows "Account history" copy, or (f) runs an +`isWrongNetwork` check that relies on the wallets-kit passphrase. + +**Formal Specification:** + +``` +FUNCTION isBugCondition(signer) + INPUT: signer of type Signer (current or proposed union) + OUTPUT: boolean + + RETURN signer.kind === "contract" + // Equivalent guard for pre-fix code: + // signer.publicKey === undefined AND address.startsWith("C") +END FUNCTION +``` + +### Examples + +- **Bug 1 (type / address)**: `useBuy` calls `marketCore(signer).buy({ trader: signer.publicKey, … })`. + For a contract signer `publicKey` is `undefined`, so the transaction is built with no + source account and fails at simulation. + +- **Bug 2 (signAndSend)**: `send()` calls `tx.signAndSend()`. The SDK implementation + calls `signTransaction` on the full XDR envelope; a passkey wallet cannot sign an + envelope, so submission always throws. + +- **Bug 3 (SEP-10 auth)**: `openModal` / `ensureSession` call `signInWithWallet`, which + POSTs to `/auth/challenge` with a `C…` address. The backend validates that address with + `StrKey.isValidEd25519PublicKey`, returns `400 INVALID_ADDRESS`, and login always fails. + +- **Bug 4 (trustline error)**: A failed trade on a contract account may contain text + matching `/trustline entry is missing/i`; `describeError` returns the "Add the USDC + asset in your wallet first" message, which has no meaning for a contract account. + +- **Bug 5 (UI copy)**: `WalletDropdown` renders `Account history` for every + connected address. A contract-account holder sees the wrong label. + +- **Bug 6 (isWrongNetwork)**: `useWriteGuard` reads `isWrongNetwork`, which is + `walletNetworkPassphrase !== NETWORK_PASSPHRASE`. For a passkey signer the kit never + fires a passphrase event, so `walletNetworkPassphrase` stays `null`, making + `isWrongNetwork` always `false` — which happens to be non-blocking, but the check is + semantically wrong and could flip if the context is initialised differently. + +--- + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors:** +- A connected `G…` wallet continues to use `signer.publicKey` as trader address. +- A connected `G…` wallet continues to submit via `tx.signAndSend()`. +- A connected `G…` wallet continues to authenticate via the SEP-10 + challenge-transaction flow (`signInWithWallet` / `requestChallenge` / `verifyChallenge`). +- A connected `G…` wallet continues to see trustline error messages from `describeError`. +- A connected `G…` wallet continues to see "Account history" in the dropdown, linking to + the Stellar Expert account page. +- A connected `G…` wallet continues to have its trades blocked by `isWrongNetwork`. +- Read hooks (`usePosition`, `useUsdcBalance`, `useOnChainMarket`) continue to work for + any address, since they never depend on signer kind. +- When `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS` is not `"true"`, no passkey UI or code path + is reachable. + +**Scope:** +All inputs where `isBugCondition(signer)` is `false` (i.e. `signer.kind === "ed25519"`) +must be completely unaffected by this fix. This includes: + +- All trade mutations (`useBuy`, `useSell`, `useRedeem`, `useUsdcTransfer`) when called + with an ed25519 signer. +- All auth flows when the connected address starts with `G`. +- All `describeError` call sites when the address starts with `G`. +- All WalletDropdown rendering when the address starts with `G`. +- The entire codebase when `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS !== "true"`. + +--- + +## Hypothesized Root Cause + +1. **`Signer` is a single-shape interface** (`lib/stellar/clients.ts`). + There is no `kind` discriminant, so callers cannot branch on signer type. Every write + path unconditionally reads `signer.publicKey`; a contract signer has no such property. + +2. **`send()` is hardwired to `tx.signAndSend()`** (`lib/hooks/useStellar.ts`). + `signAndSend` delegates to `signTransaction` from the Stellar Wallets Kit, which signs + the full envelope XDR. WebAuthn-based passkey signers can only sign auth entries + (32-byte payloads), not full envelopes. There is no alternate submission path. + +3. **`signInWithWallet` assumes ed25519** (`lib/api/auth.ts` / `lib/stellar/WalletProvider.tsx`). + `requestChallenge` sends a `C…` address to the backend; the backend's + `StrKey.isValidEd25519PublicKey` guard rejects it. No contract-account auth endpoint + exists yet (documented as "proposed" in `docs/api.md`). + +4. **`describeError` has no signer-kind context** (`lib/stellar/errors.ts`). + The function classifies errors purely from the thrown text. It has no way to suppress + trustline advice based on whether the trader is a contract account. + +5. **WalletDropdown hardcodes "Account history"** (`components/layout/WalletDropdown.tsx`). + The copy is a plain string literal, with no branch for `C…` addresses. `explorerAccount` + in `config.ts` already routes `C…` correctly; only the label is wrong. + +6. **`isWrongNetwork` derives from `walletNetworkPassphrase`** (`lib/stellar/WalletProvider.tsx`). + The wallets kit fires a passphrase only when a conventional wallet connects. A passkey + flow bypasses the kit entirely; the passphrase never arrives, so the check has undefined + semantics for contract signers. + +--- + +## Correctness Properties + +Property 1: Fault Condition — Contract signer uses `signerAddress()` and auth-entry signing + +_For any_ signer where `isBugCondition(signer)` is `true` (i.e. `signer.kind === "contract"`), +every write path (buy, sell, redeem, transfer) SHALL use `signerAddress(signer)` as the +trader address, SHALL sign only the Soroban authorization entries via `signAuthEntry`, and +SHALL submit through the relayer path in `contract-submit.ts` rather than calling +`tx.signAndSend()`. + +**Validates: Requirements 2.1, 2.2** + +Property 2: Fault Condition — Contract signer authenticates via contract-account challenge + +_For any_ session establishment where `isBugCondition(signer)` is `true`, the system SHALL +call `signInWithContractAccount()` (SEP-45 style, signing a 32-byte payload hash via +WebAuthn) instead of `signInWithWallet()`, so authentication succeeds for a `C…` address. + +**Validates: Requirement 2.3** + +Property 3: Fault Condition — Trustline errors suppressed for contract signers + +_For any_ error thrown during a trade where `isBugCondition(signer)` is `true`, calling +`describeError(err, contract, signer)` SHALL NOT return a message with `kind === "no-trustline"`; +it SHALL instead return a generic or contract-specific message. + +**Validates: Requirement 2.4** + +Property 4: Fault Condition — WalletDropdown shows "Wallet history" for contract addresses + +_For any_ connected address where `address.startsWith("C")`, the WalletDropdown SHALL +render "Wallet history" (not "Account history") as the explorer-link subtitle. + +**Validates: Requirement 2.5** + +Property 5: Fault Condition — `useWriteGuard` bypasses `isWrongNetwork` for contract signers + +_For any_ call to `useWriteGuard()` where `isBugCondition(signer)` is `true`, the guard +SHALL NOT throw a `wrong-network` error regardless of `walletNetworkPassphrase`, allowing +the write to proceed. + +**Validates: Requirement 2.6** + +Property 6: Preservation — ed25519 write paths are unchanged + +_For any_ signer where `isBugCondition(signer)` is `false` (i.e. `signer.kind === "ed25519"`), +every write path SHALL continue to use `signer.publicKey` as trader address and submit via +`tx.signAndSend()`, producing identical behavior to the pre-fix code. + +**Validates: Requirements 3.1, 3.6** + +Property 7: Preservation — ed25519 auth flow is unchanged + +_For any_ session establishment where `isBugCondition(signer)` is `false`, the system +SHALL continue to call `signInWithWallet()` through the SEP-10 challenge-transaction path, +unchanged. + +**Validates: Requirement 3.2** + +Property 8: Preservation — `describeError` trustline messages intact for ed25519 + +_For any_ error thrown during a trade where `isBugCondition(signer)` is `false`, +`describeError` SHALL continue to return `kind === "no-trustline"` messages exactly as +before, when the error text matches the trustline pattern. + +**Validates: Requirement 3.3** + +Property 9: Preservation — Passkeys flag gates every new code path + +_For any_ code path introduced by this fix (contract-signer branch, passkey auth, new UI +copy), the path SHALL be unreachable when `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS !== "true"`. + +**Validates: Requirement 3.5** + +--- + +## Fix Implementation + +### New Files + +**`lib/stellar/passkey.ts`** + +Exports: +- `createPasskeyWallet(name: string): Promise` — registers a WebAuthn + credential, derives a `C…` contract address from the COSE public key, and returns the + signer. +- `connectPasskeyWallet(): Promise` — authenticates an existing credential. +- `passkeySigner(contractId: string, credentialId: Uint8Array): Signer` — constructs a + `Signer` with `kind: "contract"` and a `signAuthEntry` implementation that calls + `navigator.credentials.get`, normalises the DER-encoded secp256r1 signature to low-S, + and returns the raw 64-byte `(r, s)` pair. + +**`lib/stellar/contract-submit.ts`** + +Exports: +- `submitAsContract(tx: AssembledTransaction, signer: ContractSigner): Promise<{ value, txHash }>` — + re-simulates the assembled transaction, collects the auth entries, calls + `signer.signAuthEntry` for each, re-assembles, and submits through a fee-paying source + account (relayer) rather than via `tx.signAndSend()`. Polls for the result via + `getTransaction`. + +### Changes to Existing Files + +**`lib/stellar/clients.ts`** + +``` +// Replace the Signer interface with a discriminated union: +export interface Ed25519Signer { + kind: "ed25519"; + publicKey: string; + signTransaction: SignTransaction; +} + +export interface ContractSigner { + kind: "contract"; + contractId: string; + signAuthEntry: (entryXdr: string, opts?: { networkPassphrase?: string }) => Promise; +} + +export type Signer = Ed25519Signer | ContractSigner; + +// New helper — single call site for "what address is this signer trading as?" +export function signerAddress(signer: Signer): string { + return signer.kind === "contract" ? signer.contractId : signer.publicKey; +} + +// options() continues to accept Signer | undefined. For contract signers +// pass only contractId as publicKey (for simulation) and omit signTransaction. +// signAndSend is never called for contract signers. +``` + +**`lib/stellar/WalletProvider.tsx`** + +- Add a `passkeyActive` state flag (set when a passkey signer is connected). +- Build `signer` as `Ed25519Signer` when kit-connected and `ContractSigner` when passkey. +- In `signIn`: branch on signer kind — call `signInWithContractAccount()` for `kind: "contract"`. +- In `isWrongNetwork`: return `false` unconditionally when `passkeyActive` is true + (bypasses `walletNetworkPassphrase` comparison). + +**`lib/api/auth.ts`** + +Add: +```typescript +export async function signInWithContractAccount( + contractId: string, + signPayload: (payload: Uint8Array) => Promise, +): Promise +``` +This function calls the new backend endpoint (to be implemented separately, documented +as "proposed" in `docs/api.md`): +- `POST /auth/contract-challenge` — returns a `{ payload: string (base64 32-byte hash), expiresAt }`. +- Signs the payload via WebAuthn (`signPayload` callback). +- `POST /auth/contract-verify` — sends `{ contractId, signature: base64 }`. +- Returns `AuthSession` and stores it. + +Gated behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS`. + +**`lib/hooks/useStellar.ts`** + +- `useWriteGuard`: skip `isWrongNetwork` when `signer.kind === "contract"`. +- `send()`: branch after simulation — if `signer.kind === "contract"` delegate to + `submitAsContract(tx, signer)` instead of `tx.signAndSend()`. +- Every `trader:` / `holder:` / `from:` field: replace `signer.publicKey` with + `signerAddress(signer)`. + +**`lib/stellar/errors.ts`** + +Add an optional `signer?: Signer` parameter to `describeError`. When +`signer?.kind === "contract"`, skip the `no-trustline` classification and fall through to +the generic `unknown` path. All existing call sites omit the new parameter and are +unaffected. + +**`components/layout/WalletDropdown.tsx`** + +Replace the static `"Account history"` string with: +```typescript +const historyLabel = address?.startsWith("C") ? "Wallet history" : "Account history"; +``` +The `explorerAccount` href already routes `C…` to `/contract/…` in `config.ts`, so no +URL change is needed. + +--- + +## Testing Strategy + +### Validation Approach + +Testing follows two phases: first surface counterexamples on the unfixed code to confirm +each root cause, then verify the fix passes the correctness properties and preserves all +ed25519 behavior. + +### Exploratory Fault Condition Checking + +**Goal**: Demonstrate each bug concretely on the *unfixed* code before implementing the +fix. Each test should fail on the original code and pass after the fix. + +**Test Plan**: Use `lib/stellar/__tests__/passkey.test.ts` for unit-level exploration. +Run the suite against the unfixed code first to observe the failure modes. + +**Test Cases**: + +1. **Address extraction (Bug 1)**: Construct a mock `Signer` with `publicKey: undefined` + (simulating a contract signer) and call `useBuy`'s `mutationFn`. Assert that the `trader` + field passed to `marketCore().buy` is `undefined` — confirming the bug. Expected to fail + after fix (trader will be `contractId`). + +2. **signAndSend for contract (Bug 2)**: Mock `tx.signAndSend` to throw, mock + `tx.signAuthEntry` to succeed. Call `send(tx)` with a contract-signer mock. On unfixed + code `signAndSend` is called and the mock throws; after fix `signAuthEntry` path is taken. + +3. **SEP-10 challenge rejected (Bug 3)**: Mock `requestChallenge` to return + `{ status: 400, error: "INVALID_ADDRESS" }` when called with a `C…` address. + Assert that `signInWithWallet("C…", …)` rejects — confirming the bug. + +4. **Trustline message for contract (Bug 4)**: Call `describeError(trustlineError)` with + no signer. Assert `kind === "no-trustline"`. Then call with a mock contract signer. + Assert `kind !== "no-trustline"` after fix. + +5. **WalletDropdown copy (Bug 5)**: Render `` with a connected `C…` + address. Assert the text content contains "Account history" on unfixed code and "Wallet + history" after fix. + +6. **isWrongNetwork blocks passkey (Bug 6)**: Set `walletNetworkPassphrase = null` and + `passkeyActive = false`. Assert `useWriteGuard` throws `wrong-network`. After fix, + with `passkeyActive = true`, assert it does not throw. + +**Expected Counterexamples (unfixed)**: +- `signer.publicKey` is `undefined` → `trader: undefined` in contract call. +- `tx.signAndSend()` called instead of `signAuthEntry`. +- Backend returns `400 INVALID_ADDRESS` for `C…` challenge. +- `describeError` returns `kind: "no-trustline"` regardless of signer type. + +### Fix Checking + +**Goal**: Verify Properties 1–5 hold for all contract-signer inputs after the fix. + +**Pseudocode:** +``` +FOR ALL signer WHERE isBugCondition(signer) DO + // Property 1 + ASSERT signerAddress(signer) === signer.contractId + ASSERT writePathUsesSignAuthEntry(signer) + ASSERT writePathSubmitsViaRelayer(signer) + + // Property 2 + ASSERT authFlowCallsContractChallenge(signer) + + // Property 3 + ASSERT describeError(trustlineErr, "marketCore", signer).kind !== "no-trustline" + + // Property 4 (address-based, not signer-kind-based) + ASSERT walletDropdownLabel(signer.contractId) === "Wallet history" + + // Property 5 + ASSERT useWriteGuard(signer) does not throw wrong-network +END FOR +``` + +### Preservation Checking + +**Goal**: Verify Properties 6–9 hold for all ed25519-signer inputs after the fix. + +**Pseudocode:** +``` +FOR ALL signer WHERE NOT isBugCondition(signer) DO + // Property 6 + ASSERT signerAddress(signer) === signer.publicKey + ASSERT writePathCallsSignAndSend(signer) + + // Property 7 + ASSERT authFlowCallsSignInWithWallet(signer) + + // Property 8 + ASSERT describeError(trustlineErr, "marketCore", signer).kind === "no-trustline" + + // Property 9 (flag gate) + WITH NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS unset DO + ASSERT noContractSignerCodePathReachable() + END WITH +END FOR +``` + +**Testing Approach**: Property-based testing is used for preservation checking because +the input space (arbitrary signer shapes, error text strings, network state combinations) +is large and PBT generates edge cases that manual tests miss. + +**Test Cases**: +1. **ed25519 write preservation**: Generate random `Ed25519Signer` mocks; assert + `signerAddress` returns `publicKey`, `send()` calls `signAndSend`, and `useBuy` builds + the transaction with `trader: publicKey`. +2. **Trustline message preservation**: Generate error strings matching the trustline + pattern; assert `describeError(err, "marketCore", ed25519Signer).kind === "no-trustline"` + for all of them. +3. **Auth flow preservation**: Verify `signIn` calls `signInWithWallet` (not the contract + variant) for all `G…` addresses. + +### Unit Tests + +Location: `lib/stellar/__tests__/passkey.test.ts` + +- DER → raw low-S normalisation: generate secp256r1 DER signatures with high-S and assert + the normalised output is canonical low-S. +- COSE public key extraction: decode a known COSE_Key structure and assert the extracted + x/y bytes match the expected coordinates. +- Auth payload hash: build a known challenge and assert `sha256(payload)` matches the + expected digest. +- `describeError` for contract traders: assert trustline classification is suppressed when + a contract signer is passed. +- `signerAddress` helper: assert returns `contractId` for `kind: "contract"` and + `publicKey` for `kind: "ed25519"`. + +Location: unit tests for `lib/stellar/contract-submit.ts` + +- Refuses unexpected signer kinds (throws if called with an ed25519 signer). +- Re-simulates before signing (mocks simulation and asserts it is called). +- Polls `getTransaction` until confirmed and returns txHash. +- Throws `UserFacingError` on relayer failure. + +### Property-Based Tests + +Location: `lib/stellar/__tests__/passkey.test.ts` (PBT section) + +- **Property 1 PBT**: For any mock contract signer and any market params, `useBuy`'s + mutation function uses `signerAddress(signer)` as `trader` and routes through + `submitAsContract`. +- **Property 2 PBT**: For any error text, `describeError` with a contract signer never + returns `kind === "no-trustline"`. +- **Preservation PBT**: For any ed25519 signer and any error text, `describeError` + behavior is identical to the unfixed code (trustline classification is preserved). + +### Integration Tests + +Location: `e2e/localnet/passkey.e2e.test.ts` (localnet e2e) + +- Full buy flow with a passkey wallet: create a credential, connect, authenticate via + contract challenge, execute a buy on a localnet market, assert the tx hash is returned + and the position is updated. +- Disconnect and reconnect: assert session is cleared and re-established correctly. +- ed25519 wallet side-by-side: in the same test suite, connect a standard Freighter + wallet and assert it still uses `signAndSend` (regression guard). +- WalletDropdown visual: render with a `C…` address and assert "Wallet history" label + and the `/contract/` explorer URL. diff --git a/.kiro/specs/passkey-smart-wallet/tasks.md b/.kiro/specs/passkey-smart-wallet/tasks.md new file mode 100644 index 0000000..e2612fb --- /dev/null +++ b/.kiro/specs/passkey-smart-wallet/tasks.md @@ -0,0 +1,251 @@ +# Implementation Plan + +- [x] 1. Write bug condition exploration test + - **Property 1: Fault Condition** - Contract Signer Bugs (address, submit, auth, error, UI, network) + - **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bugs exist + - **DO NOT attempt to fix the test or the code when it fails** + - **NOTE**: This test encodes the expected behavior — it will validate the fix when it passes after implementation + - **GOAL**: Surface counterexamples that demonstrate each of the six bugs + - **Scoped PBT Approach**: Scope each assertion to the concrete failing case to ensure reproducibility + - Create `lib/stellar/__tests__/passkey.test.ts` with six exploration assertions: + 1. Construct a mock signer with `publicKey: undefined` (contract signer pre-fix shape), call the `useBuy` mutation function, assert `trader` passed to `marketCore().buy` is `undefined` — confirms Bug 1 + 2. Mock `tx.signAndSend` to throw, mock `tx.signAuthEntry` to succeed, call `send(tx)` with the contract signer mock — assert `signAndSend` is called and throws — confirms Bug 2 + 3. Mock `requestChallenge` to return `{ status: 400, error: "INVALID_ADDRESS" }` for a `C…` address, call `signInWithWallet("C…", …)` and assert it rejects — confirms Bug 3 + 4. Call `describeError(trustlineError)` with no signer, assert `result.kind === "no-trustline"` — confirms Bug 4 (suppression is absent) + 5. Render `` with a connected `C…` address, assert text contains "Account history" (wrong copy) — confirms Bug 5 + 6. Set `walletNetworkPassphrase = null` and `passkeyActive = false`, call `useWriteGuard()`, assert it does NOT throw `wrong-network` on unfixed code (semantically wrong — check is vacuously passing) — confirms Bug 6 + - Run test suite against UNFIXED code: `npx vitest run lib/stellar/__tests__/passkey.test.ts` + - **EXPECTED OUTCOME**: Tests FAIL (or produce incorrect values) — this proves the bugs exist + - Document counterexamples found (e.g. `trader: undefined`, `signAndSend called`, `400 INVALID_ADDRESS`) + - Mark task complete when test is written, run, and failures are documented + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + +- [ ] 2. Write preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - ed25519 write, auth, error, and UI paths are unchanged + - **IMPORTANT**: Follow observation-first methodology — run UNFIXED code with `G…` signer inputs first + - Observe: `signerAddress(ed25519Signer)` returns `publicKey` on unfixed code + - Observe: `send(tx)` with an ed25519 signer calls `tx.signAndSend()` on unfixed code + - Observe: `signIn` with a `G…` address calls `signInWithWallet` on unfixed code + - Observe: `describeError(trustlineError, "marketCore", ed25519Signer).kind === "no-trustline"` on unfixed code + - Observe: `WalletDropdown` with a `G…` address renders "Account history" on unfixed code + - Observe: `useWriteGuard` with a `G…` signer and mismatched passphrase throws `wrong-network` on unfixed code + - Write property-based tests in `lib/stellar/__tests__/passkey.test.ts` (PBT section): + - **PBT**: For any randomly-generated `Ed25519Signer` mock (arbitrary `publicKey` strings starting with `G`), `send()` calls `tx.signAndSend()` and never calls `signAuthEntry` + - **PBT**: For any error string matching `/trustline entry is missing/i`, `describeError(err, "marketCore", ed25519Signer).kind === "no-trustline"` holds for all such strings + - **PBT**: For any `G…` address, `signIn` routes to `signInWithWallet`, never to `signInWithContractAccount` + - **Example**: `WalletDropdown` with `G…` address renders "Account history" label + - **Example**: `useWriteGuard` with ed25519 signer and `walletNetworkPassphrase !== NETWORK_PASSPHRASE` throws `wrong-network` + - Verify ALL preservation tests PASS on UNFIXED code + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.6_ + +- [ ] 3. Introduce the `Signer` discriminated union and `signerAddress` helper + + - [ ] 3.1 Update `lib/stellar/clients.ts` + - Replace the single `Signer` interface with a discriminated union: `Ed25519Signer { kind: "ed25519"; publicKey: string; signTransaction: SignTransaction }` and `ContractSigner { kind: "contract"; contractId: string; signAuthEntry: … }` + - Export `type Signer = Ed25519Signer | ContractSigner` + - Add `export function signerAddress(signer: Signer): string` returning `signer.contractId` for `"contract"` and `signer.publicKey` for `"ed25519"` + - Update `options()` so contract signers pass `contractId` as the simulation publicKey and omit `signTransaction` + - _Bug_Condition: isBugCondition(signer) where signer.kind === "contract"_ + - _Expected_Behavior: signerAddress(signer) returns contractId for contract signers_ + - _Preservation: Ed25519Signer shape is a strict superset of the old interface — all existing call sites compile unchanged_ + - _Requirements: 2.1, 3.1_ + + - [ ] 3.2 Update all `signer.publicKey` call sites in `lib/hooks/useStellar.ts` + - Replace every `signer.publicKey` / `signer?.publicKey` used as `trader:`, `holder:`, `from:`, or source account with `signerAddress(signer)` / `signerAddress(signer!)` + - _Requirements: 2.1, 3.1_ + + - [ ] 3.3 Verify bug condition exploration test now passes for Bug 1 + - **Property 1: Expected Behavior** - `trader` field uses `signerAddress(signer)` + - **IMPORTANT**: Re-run the SAME test from task 1 (assertion 1) — do NOT write a new test + - Run: `npx vitest run lib/stellar/__tests__/passkey.test.ts` + - **EXPECTED OUTCOME**: Assertion 1 passes (trader is `contractId`, not `undefined`) + - _Requirements: 2.1_ + + - [ ] 3.4 Verify preservation tests still pass + - **Property 2: Preservation** - ed25519 write paths unchanged + - Re-run preservation tests from task 2 + - **EXPECTED OUTCOME**: All preservation tests PASS (no regressions) + +- [ ] 4. Implement contract-account submission path + + - [ ] 4.1 Create `lib/stellar/contract-submit.ts` + - Export `submitAsContract(tx: AssembledTransaction, signer: ContractSigner): Promise<{ value, txHash }>` + - Re-simulate the assembled transaction to get fresh auth entries + - Collect auth entries and call `signer.signAuthEntry` for each + - Re-assemble and submit through the relayer fee-paying source account (not `tx.signAndSend()`) + - Poll `getTransaction` until confirmed; return `{ value, txHash }` + - Throw `UserFacingError` on relayer failure + - Guard: throw if called with a non-contract signer (`signer.kind !== "contract"`) + - Gate behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"` + - _Bug_Condition: isBugCondition(signer) where signer.kind === "contract"_ + - _Expected_Behavior: signAuthEntry called per auth entry; submitted via relayer; txHash returned_ + - _Preservation: signAndSend path in useStellar.ts is untouched for ed25519 signers_ + - _Requirements: 2.2, 3.1_ + + - [ ] 4.2 Route `send()` in `lib/hooks/useStellar.ts` by signer kind + - After simulation, branch: if `signer.kind === "contract"` call `submitAsContract(tx, signer)`; else call `tx.signAndSend()` + - _Requirements: 2.2, 3.1_ + + - [ ] 4.3 Add unit tests for `contract-submit.ts` in `lib/stellar/__tests__/contract-submit.test.ts` + - Test: throws if called with an ed25519 signer + - Test: calls simulation before signing (mock simulation, assert called) + - Test: calls `signAuthEntry` for each auth entry + - Test: polls `getTransaction` and returns `txHash` on success + - Test: throws `UserFacingError` on relayer failure + - _Requirements: 2.2_ + + - [ ] 4.4 Verify bug condition exploration test now passes for Bug 2 + - **Property 1: Expected Behavior** - `signAuthEntry` called instead of `signAndSend` for contract signers + - Re-run assertion 2 from task 1 + - **EXPECTED OUTCOME**: Assertion 2 passes + - _Requirements: 2.2_ + + - [ ] 4.5 Verify preservation tests still pass + - **Property 2: Preservation** - ed25519 `signAndSend` path unchanged + - Re-run preservation tests from task 2 + - **EXPECTED OUTCOME**: All preservation tests PASS + +- [ ] 5. Implement contract-account authentication + + - [ ] 5.1 Add `signInWithContractAccount()` to `lib/api/auth.ts` + - Export `async function signInWithContractAccount(contractId: string, signPayload: (payload: Uint8Array) => Promise): Promise` + - Call `POST /auth/contract-challenge` with `{ contractId }` → receive `{ payload: string, expiresAt }` + - Sign the decoded base64 payload via the `signPayload` callback + - Call `POST /auth/contract-verify` with `{ contractId, signature: base64 }` → return `AuthSession` + - Gate behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"` + - _Bug_Condition: isBugCondition(signer) where signer.kind === "contract"_ + - _Expected_Behavior: signs 32-byte payload hash via WebAuthn; never sends envelope to backend_ + - _Preservation: signInWithWallet is untouched; its call sites are unaffected_ + - _Requirements: 2.3, 3.2_ + + - [ ] 5.2 Document proposed backend endpoints in `docs/api.md` + - Add `POST /auth/contract-challenge` and `POST /auth/contract-verify` as "Proposed" endpoints with request/response shapes + - _Requirements: 2.3_ + + - [ ] 5.3 Branch `signIn()` in `lib/stellar/WalletProvider.tsx` + - Add `passkeyActive` state flag (set when a passkey signer is connected) + - In `signIn`: if `signer.kind === "contract"` call `signInWithContractAccount(signer.contractId, signer.signAuthEntry)`; else call `signInWithWallet()` as today + - _Requirements: 2.3, 3.2_ + + - [ ] 5.4 Verify bug condition exploration test now passes for Bug 3 + - **Property 1: Expected Behavior** - contract auth calls `/auth/contract-challenge` not `/auth/challenge` + - Re-run assertion 3 from task 1 + - **EXPECTED OUTCOME**: Assertion 3 passes + - _Requirements: 2.3_ + + - [ ] 5.5 Verify preservation tests still pass + - **Property 2: Preservation** - SEP-10 auth flow for ed25519 unchanged + - Re-run preservation tests from task 2 + - **EXPECTED OUTCOME**: All preservation tests PASS + +- [ ] 6. Suppress trustline errors for contract signers + + - [ ] 6.1 Update `describeError` in `lib/stellar/errors.ts` + - Add optional parameter `signer?: Signer` to `describeError` + - When `signer?.kind === "contract"`, skip the `no-trustline` classification and fall through to the generic `unknown` path + - All existing call sites omit the new parameter and compile unchanged + - _Bug_Condition: isBugCondition(signer) where signer.kind === "contract"_ + - _Expected_Behavior: describeError returns kind !== "no-trustline" for contract signers_ + - _Preservation: all existing call sites omit signer param; trustline messages intact for ed25519_ + - _Requirements: 2.4, 3.3_ + + - [ ] 6.2 Update `describeError` call sites in `lib/hooks/useStellar.ts` to pass `signer` + - Pass the current `signer` to `describeError` so the suppression logic activates for contract signers + - _Requirements: 2.4_ + + - [ ] 6.3 Verify bug condition exploration test now passes for Bug 4 + - **Property 1: Expected Behavior** - `describeError` with contract signer returns `kind !== "no-trustline"` + - Re-run assertion 4 from task 1 + - **EXPECTED OUTCOME**: Assertion 4 passes + - _Requirements: 2.4_ + + - [ ] 6.4 Verify preservation tests still pass + - **Property 2: Preservation** - trustline messages intact for ed25519 signers + - Re-run preservation tests from task 2 + - **EXPECTED OUTCOME**: All preservation tests PASS + +- [ ] 7. Fix WalletDropdown copy for contract addresses + + - [ ] 7.1 Update `components/layout/WalletDropdown.tsx` + - Replace the static `"Account history"` string with: `const historyLabel = address?.startsWith("C") ? "Wallet history" : "Account history"` + - Use `historyLabel` in the rendered JSX + - The `explorerAccount` href in `config.ts` already routes `C…` to `/contract/…` — no URL change needed + - _Bug_Condition: isBugCondition equivalent — address.startsWith("C")_ + - _Expected_Behavior: renders "Wallet history" label for C… addresses_ + - _Preservation: "Account history" label unchanged for G… addresses_ + - _Requirements: 2.5, 3.4_ + + - [ ] 7.2 Verify bug condition exploration test now passes for Bug 5 + - **Property 1: Expected Behavior** - WalletDropdown renders "Wallet history" for `C…` address + - Re-run assertion 5 from task 1 + - **EXPECTED OUTCOME**: Assertion 5 passes + - _Requirements: 2.5_ + + - [ ] 7.3 Verify preservation tests still pass + - **Property 2: Preservation** - "Account history" unchanged for G… addresses + - Re-run preservation tests from task 2 + - **EXPECTED OUTCOME**: All preservation tests PASS + +- [ ] 8. Bypass `isWrongNetwork` for passkey signers + + - [ ] 8.1 Update `useWriteGuard()` in `lib/hooks/useStellar.ts` + - Skip the `isWrongNetwork` check when `signer.kind === "contract"` (passkey signer has no wallets-kit passphrase) + - _Bug_Condition: isBugCondition(signer) where signer.kind === "contract"_ + - _Expected_Behavior: useWriteGuard does not throw wrong-network for contract signers_ + - _Preservation: isWrongNetwork check fully enforced for ed25519 signers_ + - _Requirements: 2.6, 3.6_ + + - [ ] 8.2 Update `isWrongNetwork` in `lib/stellar/WalletProvider.tsx` + - Return `false` unconditionally when `passkeyActive` is true + - _Requirements: 2.6_ + + - [ ] 8.3 Verify bug condition exploration test now passes for Bug 6 + - **Property 1: Expected Behavior** - `useWriteGuard` does not throw for contract signer regardless of passphrase + - Re-run assertion 6 from task 1 + - **EXPECTED OUTCOME**: Assertion 6 passes + - _Requirements: 2.6_ + + - [ ] 8.4 Verify preservation tests still pass + - **Property 2: Preservation** - `isWrongNetwork` enforced for ed25519 signers + - Re-run preservation tests from task 2 + - **EXPECTED OUTCOME**: All preservation tests PASS + +- [ ] 9. Implement `lib/stellar/passkey.ts` (WebAuthn integration) + + - [ ] 9.1 Create `lib/stellar/passkey.ts` + - Export `createPasskeyWallet(name: string): Promise` — calls `navigator.credentials.create` with WebAuthn options, extracts the COSE public key from the attestation, derives a `C…` contract address, returns a `ContractSigner` + - Export `connectPasskeyWallet(): Promise` — calls `navigator.credentials.get` to authenticate an existing credential, returns a `ContractSigner` + - Export `passkeySigner(contractId: string, credentialId: Uint8Array): Signer` — constructs a `ContractSigner` whose `signAuthEntry` implementation calls `navigator.credentials.get`, parses the DER-encoded secp256r1 signature, normalises to low-S (negate `s` if `s > n/2`), and returns the raw 64-byte `(r ‖ s)` pair + - Gate all exports behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"` + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 9.2 Add unit tests in `lib/stellar/__tests__/passkey.test.ts` + - DER → raw low-S normalisation: generate secp256r1 DER signatures with high-S; assert normalised output is canonical low-S + - COSE public key extraction: decode a known COSE_Key structure; assert extracted x/y bytes match expected coordinates + - Auth payload hash: build a known challenge; assert `sha256(payload)` matches expected digest + - `signerAddress` helper: assert returns `contractId` for `kind: "contract"` and `publicKey` for `kind: "ed25519"` + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 9.3 Expose `connectPasskey()` and `createPasskey()` in `lib/stellar/WalletProvider.tsx` + - Add `connectPasskey()`: calls `connectPasskeyWallet()`, sets signer with `kind: "contract"`, sets `passkeyActive = true` + - Add `createPasskey()`: calls `createPasskeyWallet(name)`, sets signer with `kind: "contract"`, sets `passkeyActive = true` + - Both methods gate behind `NEXT_PUBLIC_EXPERIMENTAL_PASSKEYS === "true"` + - _Requirements: 2.1, 2.3_ + +- [ ] 10. Write end-to-end tests + + - [ ] 10.1 Create `e2e/localnet/passkey.e2e.test.ts` + - Full buy flow with a passkey wallet: create credential, connect, authenticate via contract challenge, execute a buy on a localnet market, assert `txHash` returned and position updated + - Disconnect and reconnect: assert session is cleared and re-established correctly + - ed25519 wallet side-by-side: connect Freighter wallet in same suite, assert it still uses `signAndSend` (regression guard) + - WalletDropdown visual: render with `C…` address, assert "Wallet history" label and `/contract/` explorer URL + - _Requirements: 2.1, 2.2, 2.3, 2.5, 3.1, 3.2_ + +- [ ] 11. Checkpoint — Ensure all tests pass + - Run full test suite: `npx vitest run` + - Run e2e suite against localnet: `npx playwright test e2e/localnet/passkey.e2e.test.ts` + - Ensure all exploration tests (Property 1) pass — confirms all six bugs are fixed + - Ensure all preservation tests (Property 2) pass — confirms no regressions + - Ensure all unit tests pass (`passkey.test.ts`, `contract-submit.test.ts`) + - Ask the user if any questions arise diff --git a/.kiro/specs/typed-binding-results/.config.kiro b/.kiro/specs/typed-binding-results/.config.kiro new file mode 100644 index 0000000..60734bf --- /dev/null +++ b/.kiro/specs/typed-binding-results/.config.kiro @@ -0,0 +1 @@ +{"specId": "c5b2918e-a82c-4a39-8d03-06f478afe72e", "workflowType": "requirements-first", "specType": "bugfix"} diff --git a/.kiro/specs/typed-binding-results/bugfix.md b/.kiro/specs/typed-binding-results/bugfix.md new file mode 100644 index 0000000..fdd66bb --- /dev/null +++ b/.kiro/specs/typed-binding-results/bugfix.md @@ -0,0 +1,57 @@ +# Bugfix Requirements Document + +## Introduction + +The generated Stellar contract bindings (`market-core`, `reasoning-registry`) carry precise return types for every method, but the application discards this type information entirely. `lib/hooks/useStellar.ts` exposes two helpers — `simulated` and `send` — where the caller supplies `T` manually as a type parameter and the bodies use `as` casts or duck-typed `"isOk" in raw` checks instead of the SDK's real `Ok`/`Err` classes. Two call sites (`useVerifiedTrace` and `app/reasoning/verify/page.tsx`) inline the result shape by hand rather than importing `Trace` from the binding, and those shapes have already drifted (`trace_hash: Uint8Array` vs the binding's `Buffer`). The end-to-end test duplicates the same duck-typing locally. Nothing catches this drift: the generated files carry `// @ts-nocheck` and `npm run typecheck` passes even if a binding's return type changes completely. + +## Bug Analysis + +### Current Behavior (Defect) + +1.1 WHEN `simulated(tx, contract)` is called THEN the system accepts any caller-supplied `T` regardless of the actual return type of `tx`, providing no compile-time guarantee that `T` matches the binding's declared output type. + +1.2 WHEN `send(tx, contract)` is called THEN the system unwraps the result with `as` casts (`(value as { unwrap(): T }).unwrap()` and `value as T`) that are not verified against the binding's declared type, silently producing incorrect values if the binding changes. + +1.3 WHEN `simulated` detects a `Result` wrapper THEN the system duck-types it via `"isOk" in raw && typeof raw.isOk === "function"` instead of using `instanceof Ok`/`instanceof Err` from `@stellar/stellar-sdk/contract`, which can mis-classify plain objects that happen to have an `isOk` method. + +1.4 WHEN `useVerifiedTrace` reads a `Trace` from `reasoningRegistry().get_trace()` THEN the system writes out `{ market_id: bigint; agent: string; action: string; trace_hash: Uint8Array; ipfs_cid: string; published_at: bigint }` by hand instead of importing the `Trace` type from the binding, and declares `trace_hash` as `Uint8Array` while the binding declares it as `Buffer`. + +1.5 WHEN `app/reasoning/verify/page.tsx` reads a trace THEN the system writes out `{ trace_hash: Uint8Array; ipfs_cid: string; market_id: bigint }` by hand, again mistyping `trace_hash` as `Uint8Array` and omitting fields present in the binding's `Trace`. + +1.6 WHEN `e2e/testnet.e2e.test.ts` calls `reasoningRegistry().get_trace()` THEN the system uses a locally-defined `simulatedValue` helper with a hand-written `{ trace_hash: Uint8Array; ipfs_cid: string }` shape, duplicating the same drift-prone pattern outside the main codebase. + +1.7 WHEN a binding method's return type changes (e.g. `quote_buy` changes from `i128` to a struct) THEN the system does not surface a type error at any call site, so `npm run typecheck` passes with the wrong type silently flowing through the application. + +### Expected Behavior (Correct) + +2.1 WHEN `read(tx, contract)` is called THEN the system SHALL infer the return type directly from `tx`'s generic parameter, requiring no caller-supplied type argument and producing a compile error if the inferred type is misused. + +2.2 WHEN `submit(tx, contract)` is called THEN the system SHALL infer the return type from `tx` using the same type utilities, with no `as` casts in its body beyond one documented boundary at the XDR decode point. + +2.3 WHEN a `Result`-wrapped value is encountered at runtime THEN the system SHALL detect it using `instanceof Ok` / `instanceof Err` from `@stellar/stellar-sdk/contract` instead of duck-typing. + +2.4 WHEN `useVerifiedTrace` reads a trace THEN the system SHALL use the `Trace` type imported from the `reasoning-registry` binding, and the `trace_hash` field SHALL be typed as `Buffer` (matching the binding), with any `Uint8Array`-typed usage downstream handled by an explicit, documented conversion. + +2.5 WHEN `app/reasoning/verify/page.tsx` reads a trace THEN the system SHALL derive the trace shape from the binding's `Trace` type via the `Value` utility rather than an inline hand-written shape. + +2.6 WHEN `e2e/testnet.e2e.test.ts` reads a trace THEN the system SHALL use the new `read` helper (or a thin equivalent) instead of a locally-defined duck-typing helper, and the result type SHALL be inferred from the binding. + +2.7 WHEN a binding method's return type changes THEN the system SHALL produce a TypeScript compile error at every affected call site, and `npm run typecheck` SHALL fail. + +### Unchanged Behavior (Regression Prevention) + +3.1 WHEN a simulated call succeeds with a non-`Result` return (e.g. `market_count`, `token.balance`) THEN the system SHALL CONTINUE TO return the unwrapped value to the caller without errors. + +3.2 WHEN a simulated call returns a `Result::Ok` (e.g. `get_market`, `get_position`, `quote_buy`) THEN the system SHALL CONTINUE TO unwrap and return the inner value. + +3.3 WHEN a simulated call returns a `Result::Err` THEN the system SHALL CONTINUE TO throw a `UserFacingError` described via `describeError`. + +3.4 WHEN a simulation itself fails (host error in `tx.simulation`) THEN the system SHALL CONTINUE TO surface the error through `describeError` before the wallet is asked to sign anything. + +3.5 WHEN `send` / `submit` is called for `buy`, `sell`, or `redeem` THEN the system SHALL CONTINUE TO return `{ value, txHash }` with `txHash` sourced from `sent.sendTransactionResponse?.hash`. + +3.6 WHEN `token.balance` or `token.transfer` is called THEN the system SHALL CONTINUE TO work correctly; these use `AssembledTransaction.build` with explicit `parseResultXdr` and are already correctly typed at the source. + +3.7 WHEN the `USDC` transfer completes THEN the system SHALL CONTINUE TO throw `UserFacingError` if no `txHash` is returned. + +3.8 WHEN `useVerifiedTrace` converts `trace_hash` to a hex string THEN the system SHALL CONTINUE TO produce the same hex string from the bytes returned by the contract, regardless of whether the field is typed as `Buffer` or `Uint8Array` at the call site. diff --git a/.kiro/specs/typed-binding-results/design.md b/.kiro/specs/typed-binding-results/design.md new file mode 100644 index 0000000..84e6a24 --- /dev/null +++ b/.kiro/specs/typed-binding-results/design.md @@ -0,0 +1,354 @@ +# Typed Binding Results Bugfix Design + +## Overview + +The application's contract-call helpers (`simulated` / `send`) accept a +caller-supplied type argument and use `as` casts and duck-typed `"isOk" in raw` +checks internally. This means the TypeScript compiler never ties the inferred +type back to the binding's declared return type, so type drift (e.g. `Uint8Array` +vs `Buffer` for `trace_hash`) silently propagates and binding changes never fail +`npm run typecheck`. + +The fix introduces two new helpers in `lib/stellar/typed-call.ts` — `read` and +`submit` — whose return types are fully inferred from the `AssembledTransaction` +generic via utility types. `instanceof Ok/Err` replaces duck-typing, a +`bytesToHex` utility replaces the repeated `Array.from` pattern, and every +hand-written shape is replaced by an import of the binding's own type. A new +type-level test file (`typed-call.test-d.ts`) and ESLint `no-restricted-syntax` +rule prevent the drift from recurring. + +## Glossary + +- **Bug_Condition (C)**: Any call site where `simulated` or `send` is + invoked with an explicit type argument, OR where the return value is annotated + with a hand-written shape instead of a binding-imported type. +- **Property (P)**: The correct behaviour — `read(tx, contract)` returns a value + whose TypeScript type is identical to what the binding declares, with no + explicit type argument required at the call site. +- **Preservation**: All runtime behaviour (unwrapping `Result::Ok`, throwing on + `Result::Err`, returning `{ value, txHash }` from writes) must remain + byte-for-byte identical; only the static types and the `instanceof` check + change. +- **`AssembledTransaction`**: The SDK class returned by every binding method. + Its single type parameter `Tx` is the raw XDR-decoded value (may be + `Result`, `T`, `Position`, `u64`, …). +- **`TxValue`**: Utility type that extracts `T` from `Result`, or returns + `Tx` unchanged for non-Result types. +- **`Unwrapped`**: Tuple-wrapped form `[T]` used to prevent TypeScript's + conditional-type distribution over union members. +- **`MethodValue`**: Infers `TxValue` for a single binding method type + signature. +- **`Value`**: Looks up method `K` on client `C` and resolves its + `MethodValue` — the public API for deriving shapes at call sites. +- **`read`**: Replaces `simulated`. Infers return type from `tx`; no explicit `T` + allowed. +- **`submit`**: Replaces `send`. Same inference story for writes. +- **`bytesToHex`**: `(buf: Uint8Array | Buffer) => string` — shared conversion + replacing the repeated `Array.from` pattern. +- **`Ok` / `Err`**: Classes exported from `@stellar/stellar-sdk/contract` used + for `instanceof` checks instead of duck-typing. + +## Bug Details + +### Fault Condition + +The bug manifests at every call site where the caller must explicitly supply a +type argument (`simulated(…)`, `send(…)`) and wherever a +hand-written interface is used instead of the binding's own type. In both cases +the TypeScript compiler accepts whatever type the caller writes without checking +it against the binding's declared return type. + +**Formal Specification:** + +``` +FUNCTION isBugCondition(callSite) + INPUT: callSite — a TypeScript call expression in the source tree + OUTPUT: boolean + + RETURN ( + callSite.callee IN ["simulated", "send"] + AND callSite.hasExplicitTypeArgument() + ) + OR ( + callSite.annotatedType IS hand-written shape + AND correspondingBindingType EXISTS + AND callSite.annotatedType != correspondingBindingType + ) + OR ( + callSite.resultDiscriminator USES duck-type check "isOk" in value + ) +END FUNCTION +``` + +### Examples + +- `simulated<{ trace_hash: Uint8Array; ipfs_cid: string }>(await reasoningRegistry().get_trace(…))` — + `Uint8Array` vs binding's `Buffer`; TypeScript never complains. +- `send(tx)` in `useBuy` — if `buy` changed to `Result<{ shares: bigint }>` the caller + would silently receive a `Result` object typed as `bigint`. +- `"isOk" in raw && typeof raw.isOk === "function"` — a plain object `{ isOk: 42 }` passes + the first guard, then the second guard throws because `42()` is not a function. +- `npm run typecheck` passes today when `quote_buy` returns `Result`; if the binding + changes to `Result<{ shares: i128; fee: i128 }>` all callers still compile. + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors:** + +- A simulated call that returns a non-`Result` value (e.g. `market_count → u64`, + `get_position → Position`) SHALL continue to return that value directly. +- A simulated call that returns `Result::Ok(v)` SHALL continue to return `v` + unwrapped. +- A simulated call that returns `Result::Err` SHALL continue to throw a + `UserFacingError` via `describeError`. +- A simulation host-error (tx.simulation.error set) SHALL continue to surface via + `describeError` before `signAndSend` is called. +- `submit` (`buy`, `sell`, `redeem`) SHALL continue to return `{ value, txHash }` + with `txHash` from `sent.sendTransactionResponse?.hash`. +- `useUsdcTransfer` SHALL continue to throw `UserFacingError` when `txHash` is + null. +- `useVerifiedTrace` SHALL continue to produce the same hex string from the bytes + the contract returns; `Buffer` and `Uint8Array` share the same underlying + `Array.from` byte access so the hex output is identical. +- `token.balance` and `token.transfer` (built with explicit `parseResultXdr`) are + already correctly typed at source and SHALL NOT be changed. + +**Scope:** +All inputs that do NOT match the bug condition — mouse/touch interactions, +non-contract UI events, `token.*` calls, calls that already use the new `read`/ +`submit` — are completely unaffected. + +## Hypothesized Root Cause + +1. **Unconstrained generic on helpers**: `simulated` and `send` accept any + `T` because they are declared as `function simulated(tx: AssembledTransaction, …): T`. + The `unknown` severs the link between `T` and the binding's actual type + parameter. + +2. **Duck-typed Result detection**: The `"isOk" in raw` guard pre-dates the SDK + exporting `Ok`/`Err` classes. The SDK now exports them from + `@stellar/stellar-sdk/contract`; `instanceof` is both safer and more + idiomatic. + +3. **`// @ts-nocheck` in generated files**: The bindings opt out of type-checking + entirely. `npm run typecheck` only covers app code, so a mismatch between the + binding's declared type and the hand-written shape is invisible. + +4. **No type-level test coverage**: There are no `.test-d.ts` files using + `expectTypeOf` to assert that helpers return the right types. + +5. **No lint guardrail**: Nothing prevents a future developer from reintroducing + `read(…)` with an explicit type argument that defeats the inference. + +## Correctness Properties + +Property 1: Fault Condition — Inferred Return Type Matches Binding + +_For any_ call `read(tx, contract)` where `tx` is the return value of a binding +method, the TypeScript type of the result SHALL be identical to the type the +binding declares for that method (unwrapping `Result` to `T` where applicable), +with NO explicit type argument at the call site. A TypeScript compiler error SHALL +be produced if the inferred type is used incorrectly. + +**Validates: Requirements 2.1, 2.2, 2.7** + +Property 2: Preservation — Runtime Behaviour Unchanged + +_For any_ input where the bug condition does NOT hold (calls that already work +correctly at runtime — non-`Result` returns, `Result::Ok` unwrapping, +`Result::Err` throwing, `{ value, txHash }` from writes, hex conversion from +`trace_hash`), the fixed `read`/`submit` SHALL produce exactly the same runtime +output as the original `simulated`/`send`, preserving all observable side-effects. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.7, 3.8** + +## Fix Implementation + +### Changes Required + +**New file: `lib/stellar/typed-call.ts`** + +1. **Utility types**: + - `TxValue` — extracts `T` from `Result`; passes `Tx` through otherwise. + Uses tuple wrapping `[Tx] extends [Result] ? T : Tx` to avoid + conditional-type distribution over unions. + - `Unwrapped` — alias for `[T]` to make the tuple intent explicit. + - `MethodValue` — extracts `TxValue` from a method's `Promise>` signature. + - `Value` — `MethodValue` — the call-site API. + - `MarketCoreClient` and `RegistryClient` — type aliases for the generated clients (avoids importing the class just for `typeof`). + +2. **`bytesToHex(buf: Uint8Array | Buffer): string`** — shared utility replacing + the `Array.from(…, b => b.toString(16).padStart(2,'0')).join('')` pattern. + +3. **`read(tx: AssembledTransaction, contract?: ContractName)`** — infers + `TxValue` as the return type; uses `instanceof Ok` / `instanceof Err`; + surfaces errors via `describeError`. No caller-supplied type argument is + accepted (the parameter is on `tx`, not the function). + +4. **`submit(tx: AssembledTransaction, contract?: ContractName)`** — + calls `read` first (simulation gate), then `signAndSend`. The one necessary + `as` cast at the XDR-decode boundary is documented with an inline comment. + Returns `{ value: TxValue; txHash: string | null }`. + +5. **Deprecated re-export** — `export { read as simulated }` marked `@deprecated` + so existing imports continue to compile with a deprecation warning. + +**Changes to `lib/hooks/useStellar.ts`**: +- Replace all `import { simulated }` usages with `read` from `typed-call`. +- Replace all `send(tx)` calls with `submit(tx)`. +- In `useVerifiedTrace`: remove the inline shape annotation; import `Trace` from + `reasoning-registry` binding; replace `Array.from(trace.trace_hash, …)` with + `bytesToHex(trace.trace_hash)`. + +**Changes to `app/reasoning/verify/page.tsx`**: +- Replace `import { simulated }` with `import { read }` from `typed-call`. +- Remove the inline `{ trace_hash: Uint8Array; ipfs_cid: string; market_id: bigint }` annotation. +- Use `Value` (or let TypeScript infer) for the + `onChain` variable. +- Replace `Array.from(onChain.trace_hash, …)` with `bytesToHex`. + +**Changes to `e2e/testnet.e2e.test.ts`**: +- Remove the local `simulatedValue` helper. +- Import `read` from `lib/stellar/typed-call`. +- Replace `simulatedValue<…>(…)` with `read(…)` (no type argument). + +**New file: `lib/stellar/__tests__/typed-call.test-d.ts`**: +- Type-level assertions using `expectTypeOf` (vitest): + - `read(await marketCore().market_count())` → `bigint` + - `read(await marketCore().get_market(…))` → `Market` + - `read(await marketCore().get_position(…))` → `Position` + - `read(await marketCore().quote_buy(…))` → `bigint` + - `read(await marketCore().buy(…))` → `bigint` + - `read(await reasoningRegistry().get_trace(…))` → `Trace` + - All of the above: `.not.toBeAny()` guards + - `Value` resolves to `Market` + - `Value` resolves to `Trace` + +**New file: `lib/stellar/__tests__/typed-call.test.ts`**: +- Runtime unit tests for `read` and `submit` using mock `AssembledTransaction` objects. + +**Changes to `vitest.config.mts`**: +- Add `typecheck: { enabled: true, include: ["lib/**/*.test-d.ts"] }` inside `test`. + +**Changes to `eslint.config.mjs`**: +- Add a `no-restricted-syntax` rule that forbids explicit type arguments on calls + to `read`, `submit`, and `simulated`: + + ```js + { + selector: 'CallExpression[callee.name=/^(read|submit|simulated)$/] > TSTypeParameterInstantiation', + message: 'Do not pass explicit type args to read/submit/simulated — the type is inferred from the binding.' + } + ``` + +**Changes to `docs/architecture.md`**: +- Add a "Reading contract results" section documenting `read`/`submit`, the type + utilities, and the `no-restricted-syntax` guardrail. + +## Testing Strategy + +### Validation Approach + +Two-phase: first surface counterexamples on the unfixed code to confirm the root +cause, then verify the fix and preservation properties hold. + +### Exploratory Fault Condition Checking + +**Goal**: Surface counterexamples that demonstrate the drift BEFORE the fix. +Confirm that the type-parameter inference does not exist today, and that +`instanceof Ok` would work where duck-typing may not. + +**Test Plan**: Write a type-level test asserting `read` infers the correct type +without an explicit type argument, and run it against the current (unfixed) +`simulated` to observe a type error or `any` inference. Also write a runtime test +using an object `{ isOk: () => true, unwrap: () => 42 }` vs a genuine `Ok` +instance to confirm the duck-type gap. + +**Test Cases**: +1. **Type inference test** — assert `expectTypeOf(read(await marketCore().market_count())).toEqualTypeOf()` compiles with `read` but not with `simulated` dropped (will fail on unfixed code). +2. **`not.toBeAny()` guard** — assert the inferred type is not `any` (will fail today since `simulated` returns `T` which is `unknown` at the call site after stripping the annotation). +3. **`instanceof Ok` runtime test** — create `{ isOk: () => true, unwrap: () => 99 }` and show the duck-typed guard would accept it; `instanceof Ok` would not. +4. **`trace_hash` type test** — assert `Value` has `trace_hash: Buffer`, not `Uint8Array`. + +**Expected Counterexamples**: +- `simulated(…)` compiles even if the annotated `Market` is replaced with `{ wrong: true }`. +- The duck-typed guard accepts a plain object with an `isOk` method. + +### Fix Checking + +**Goal**: Verify that for all inputs where the bug condition holds (call sites +with explicit type args, or hand-written shapes), the fixed helpers enforce the +correct type. + +**Pseudocode:** +``` +FOR ALL callSite WHERE isBugCondition(callSite) DO + result := read(callSite.tx) -- no explicit type arg + ASSERT TypeOf(result) == binding.declaredReturnType(callSite.method) + ASSERT NOT TypeOf(result) == any +END FOR +``` + +### Preservation Checking + +**Goal**: Verify that for all inputs where the bug condition does NOT hold, `read` +and `submit` produce the same runtime output as `simulated` and `send`. + +**Pseudocode:** +``` +FOR ALL tx WHERE NOT isBugCondition(tx) DO + ASSERT read(tx) === simulated_original(tx) -- same unwrapped value + ASSERT submit(tx) deepEquals send_original(tx) -- same { value, txHash } +END FOR +``` + +**Testing Approach**: Property-based testing is recommended for the runtime +preservation because: +- It generates many `AssembledTransaction` mock shapes automatically. +- It catches edge cases (Result wrapping levels, null txHash, void inner type) + that unit tests might miss. +- It gives strong guarantees that the `instanceof` swap doesn't change any + observable runtime output. + +**Test Cases**: +1. **Non-Result preservation** — `read` with a mock tx whose `.result` is a plain + `bigint` returns that bigint; no `Ok`/`Err` path taken. +2. **`Result::Ok` preservation** — `read` with a mock `Ok(42n)` returns `42n`. +3. **`Result::Err` preservation** — `read` with a mock `Err(...)` throws + `UserFacingError`. +4. **Host-error preservation** — `read` with `tx.simulation.error` set throws + before attempting unwrap. +5. **`submit` returns `{ value, txHash }`** — mock `signAndSend` returning + `{ result: Ok(10n), sendTransactionResponse: { hash: "abc" } }` yields + `{ value: 10n, txHash: "abc" }`. +6. **`bytesToHex` round-trip** — `bytesToHex(Buffer.from([0x0a, 0xff]))` === + `"0aff"` matches the old `Array.from` path. + +### Unit Tests + +- `read` with plain value, `Ok`, `Err`, and host-error mock transactions. +- `submit` happy path, simulation-failure gate, and `signAndSend` error. +- `bytesToHex` with `Buffer`, `Uint8Array`, and empty input. +- `@deprecated` `simulated` re-export still delegates to `read` at runtime. + +### Property-Based Tests + +- Generate random `bigint` / `string` / `boolean` inner values; wrap each in + both a plain form and `Ok(value)`; assert `read` always returns the same inner + value regardless of wrapping. +- Generate random error codes; wrap in `Err`; assert `read` always throws + `UserFacingError` with a message matching `describeError`. +- Generate random byte arrays; assert `bytesToHex(Buffer.from(bytes))` === + `bytesToHex(new Uint8Array(bytes))` (Buffer/Uint8Array identity). + +### Integration Tests + +- `useVerifiedTrace` query function runs against a mock `reasoningRegistry` client + that returns a genuine `Ok(Trace)` — the full path from `read` through + `bytesToHex` to the `VerifiedTrace` shape is exercised. +- `app/reasoning/verify/page.tsx` (render test) — the `onChain` variable is + correctly typed as `Trace`; `storedHash` is a hex string. +- `e2e/testnet.e2e.test.ts` compiles without the local `simulatedValue` helper + after the import is swapped to `read`. diff --git a/.kiro/specs/typed-binding-results/tasks.md b/.kiro/specs/typed-binding-results/tasks.md new file mode 100644 index 0000000..b992334 --- /dev/null +++ b/.kiro/specs/typed-binding-results/tasks.md @@ -0,0 +1,114 @@ +# Implementation Plan + +- [-] 1. Write bug condition exploration test + - **Property 1: Fault Condition** - Unconstrained Generic / Duck-Type Gap + - **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bugs exist + - **DO NOT attempt to fix the test or the code when it fails** + - **NOTE**: This test encodes the expected behavior — it will validate the fix when it passes after implementation + - **GOAL**: Surface counterexamples that demonstrate all five root causes exist today + - **Scoped PBT Approach**: Scope assertions to the concrete failing cases listed below for reproducibility + - Create `lib/stellar/__tests__/typed-call.test-d.ts` using vitest `expectTypeOf` — assert against the EXISTING `simulated` helper (not the yet-to-be-written `read`): + - `simulated<{ wrong: true }>(await marketCore().market_count())` — assert this should NOT compile (it does today, proving unconstrained generic) + - `expectTypeOf(simulated(await marketCore().market_count())).not.toBeAny()` — assert result is not `any` (fails today: return type is the caller-supplied `T`, which is inferred as `unknown` when no arg is passed) + - `expectTypeOf(simulated(await reasoningRegistry().get_trace({…}))).toHaveProperty("trace_hash").toEqualTypeOf()` — assert `trace_hash` is `Buffer` not `Uint8Array` (fails today: hand-written shape uses `Uint8Array`) + - Create or extend `lib/stellar/__tests__/typed-call.test.ts` — runtime assertions against unfixed code: + - Construct `{ isOk: () => true, unwrap: () => 99 }` (a plain object, not an `Ok` instance); run it through the existing duck-typed guard; assert that the duck-type check incorrectly passes where `instanceof Ok` would return `false` + - Document the counterexample: "duck-typed guard accepts a plain `{ isOk: () => true }` object — `instanceof Ok` would reject it" + - Enable typecheck in `vitest.config.mts` for `.test-d.ts` files first (needed to observe the type-level failures): + - Add `typecheck: { enabled: true, include: ["lib/**/*.test-d.ts"] }` inside the `test` block + - Run `npx vitest typecheck --run` — **EXPECTED OUTCOME**: type errors surface on the `simulated` call sites + - Run `npx vitest run lib/stellar/__tests__/typed-call.test.ts` — **EXPECTED OUTCOME**: duck-type runtime assertion demonstrates the gap + - Document counterexamples found to understand root cause for the five areas + - Mark task complete when tests are written, run, and failures/gaps are documented + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7_ + +- [ ] 2. Write preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - Runtime Unwrap Behaviour Unchanged + - **IMPORTANT**: Follow observation-first methodology — observe `simulated`/`send` behaviour on non-buggy inputs before writing assertions + - Observe: `simulated(tx)` where `tx.result` is a plain `bigint` returns that bigint (no Ok/Err path) + - Observe: `simulated(tx)` where `tx.result` is a genuine `Ok(42n)` instance returns `42n` + - Observe: `simulated(tx)` where `tx.result` is a genuine `Err(...)` throws `UserFacingError` + - Observe: `simulated(tx)` where `tx.simulation.error` is set throws before unwrap + - Observe: `send(tx)` returns `{ value, txHash }` with `txHash` from `sendTransactionResponse.hash` + - Write property-based tests in `lib/stellar/__tests__/typed-call.test.ts` capturing these patterns from Preservation Requirements in design: + - **PBT**: Generate random `bigint` / `string` / `boolean` inner values; wrap each in plain form and as a genuine `Ok(value)` instance; assert `simulated` always returns the same inner value — confirms behaviour to preserve + - **PBT**: Generate random byte arrays (Uint8Array); assert `Array.from(bytes, b => b.toString(16).padStart(2,'0')).join('')` produces the same hex as `bytesToHex(Buffer.from(bytes))` will — baseline for hex identity preservation + - **Unit**: `simulated` with mock `Err(…)` throws `UserFacingError` (baseline) + - **Unit**: `simulated` with host-error `tx.simulation.error` throws before unwrap (baseline) + - **Unit**: `send` happy-path mock returns `{ value: 10n, txHash: "abc" }` (baseline) + - Run tests: `npx vitest run lib/stellar/__tests__/typed-call.test.ts` + - **EXPECTED OUTCOME**: All preservation tests PASS on unfixed code (confirms the baseline behaviour to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.7, 3.8_ + +- [ ] 3. Implement typed-call helpers and migrate call sites + + - [ ] 3.1 Create `lib/stellar/typed-call.ts` with utility types and `read`/`submit` + - Define `TxValue` using tuple wrapping: `[Tx] extends [Result] ? T : Tx` + - Define `Unwrapped` as `[T]` alias, `MethodValue` extracting TxValue from a method's Promise return, `Value` as `MethodValue` + - Define `MarketCoreClient` and `RegistryClient` type aliases (avoids importing classes just for `typeof`) + - Implement `bytesToHex(buf: Uint8Array | Buffer): string` replacing all `Array.from(…, b => b.toString(16).padStart(2,'0')).join('')` patterns + - Implement `read(tx: AssembledTransaction, contract?: ContractName): Promise>` using `instanceof Ok` / `instanceof Err` (NOT duck-typing); surface errors via `describeError` + - Implement `submit(tx: AssembledTransaction, contract?: ContractName): Promise<{ value: TxValue; txHash: string | null }>` — calls `read` for simulation gate, then `signAndSend`; document the one necessary `as` cast at the XDR-decode boundary with inline comment + - Add `@deprecated` re-export: `export { read as simulated }` + - _Bug_Condition: isBugCondition(callSite) where callSite.callee IN ["simulated","send"] AND callSite.hasExplicitTypeArgument(), OR annotatedType is hand-written shape_ + - _Expected_Behavior: read(tx, contract) return type === binding.declaredReturnType(method); no explicit T at call site; instanceof Ok/Err used for discrimination_ + - _Preservation: non-Result returns pass through; Result::Ok unwraps; Result::Err throws UserFacingError; host-error throws before signAndSend; submit returns { value, txHash }_ + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_ + + - [ ] 3.2 Migrate `lib/hooks/useStellar.ts` + - Replace `import { simulated }` with `import { read, submit, bytesToHex }` from `../stellar/typed-call` + - Replace all `simulated(tx, contract)` calls with `read(tx, contract)` (remove explicit type args) + - Replace all `send(tx, contract)` calls with `submit(tx, contract)` (remove explicit type args) + - In `useVerifiedTrace`: remove inline shape annotation; import `Trace` from `reasoning-registry` binding; replace `Array.from(trace.trace_hash, …)` with `bytesToHex(trace.trace_hash)` + - _Requirements: 2.1, 2.2, 2.4_ + + - [ ] 3.3 Migrate `app/reasoning/verify/page.tsx` + - Replace `import { simulated }` with `import { read, bytesToHex }` from `lib/stellar/typed-call` + - Remove the inline `{ trace_hash: Uint8Array; ipfs_cid: string; market_id: bigint }` annotation; let TypeScript infer from `read` or use `Value` + - Replace `Array.from(onChain.trace_hash, …)` with `bytesToHex(onChain.trace_hash)` + - _Requirements: 2.5_ + + - [ ] 3.4 Migrate `e2e/testnet.e2e.test.ts` + - Remove local `simulatedValue` helper and its hand-written `{ trace_hash: Uint8Array; ipfs_cid: string }` shape + - Import `read` from `lib/stellar/typed-call` + - Replace `simulatedValue<…>(…)` with `read(…)` (no type argument) + - _Requirements: 2.6_ + + - [ ] 3.5 Add ESLint guardrail in `eslint.config.mjs` + - Add `no-restricted-syntax` rule forbidding explicit type arguments on `read`, `submit`, and `simulated` calls: + ```js + { + selector: 'CallExpression[callee.name=/^(read|submit|simulated)$/] > TSTypeParameterInstantiation', + message: 'Do not pass explicit type args to read/submit/simulated — the type is inferred from the binding.' + } + ``` + - _Requirements: 2.1, 2.2_ + + - [ ] 3.6 Document in `docs/architecture.md` + - Add "Reading contract results" section covering `read`/`submit`, the type utilities (`TxValue`, `Value`), `bytesToHex`, and the ESLint `no-restricted-syntax` guardrail + - _Requirements: 2.1, 2.7_ + + - [ ] 3.7 Verify bug condition exploration test now passes + - **Property 1: Expected Behavior** - Inferred Return Type Matches Binding + - **IMPORTANT**: Re-run the SAME tests from task 1 — do NOT write new tests + - The type-level test from task 1 now asserts `read` (not `simulated`) infers the correct binding type + - Update the `.test-d.ts` assertions to reference `read` instead of `simulated` where appropriate (the test was written against `simulated` to show the bug; now assert `read` satisfies the same shape correctly) + - Run: `npx vitest typecheck --run` + - **EXPECTED OUTCOME**: All type-level tests PASS — `read` infers correct types, `not.toBeAny()` guards pass, `trace_hash` resolves to `Buffer` + - Confirm `simulated<{ wrong: true }>(…)` now produces a lint error (ESLint guardrail) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7_ + + - [ ] 3.8 Verify preservation tests still pass + - **Property 2: Preservation** - Runtime Behaviour Unchanged + - **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests + - Run: `npx vitest run lib/stellar/__tests__/typed-call.test.ts` + - **EXPECTED OUTCOME**: All runtime preservation tests PASS — unwrap behaviour, error throwing, `{ value, txHash }` shape, and `bytesToHex` hex identity are all preserved + - Confirm no regressions across non-Result, Result::Ok, Result::Err, host-error, and submit paths + +- [ ] 4. Checkpoint — Ensure all tests pass + - Run full type-check: `npx vitest typecheck --run` + - Run full unit/PBT suite: `npx vitest run lib/stellar/__tests__/` + - Run lint: `npx eslint lib/stellar/typed-call.ts lib/hooks/useStellar.ts app/reasoning/verify/page.tsx` + - Confirm `npm run typecheck` (project-wide) fails if a binding return type is changed to something incompatible — the goal of requirement 2.7 is now enforced + - Ensure all tests pass; ask the user if questions arise diff --git a/app/reasoning/verify/page.tsx b/app/reasoning/verify/page.tsx index 53751fd..8ab4a93 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 { read, bytesToHex } 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/docs/architecture.md b/docs/architecture.md index e3c4e85..1d8e4b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,6 +29,16 @@ 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 + +All contract results must be read through `read(tx, contract)` for simulations and `submit(tx, contract)` for transactions (`lib/stellar/typed-call.ts`). + +- **Type inference**: `read` and `submit` automatically infer their return types from the binding method's return type using the `Unwrapped>` type utility. Contract `Result` values are unwrapped to `T`, while plain return values (such as `Position` or `u64`) are passed through unchanged. +- **No manual type arguments or hand-written shapes**: Never pass explicit generic type parameters to `read` or `submit` (e.g. `read(...)`), and never write out return interfaces by hand. An ESLint `no-restricted-syntax` rule enforces that call sites cannot pass type arguments to `read`, `submit`, or `simulated`. +- **Runtime Result detection**: Contract `Result` values are identified using SDK `instanceof Ok` and `instanceof Err` checks from `@stellar/stellar-sdk/contract`. Plain objects that happen to have an `isOk` property are never mistaken for Results. +- **Trace hash conversion**: The byte buffer (`Buffer` or `Uint8Array`) returned from `get_trace` must be converted to hex using `bytesToHex(trace.trace_hash)` rather than ad-hoc `Array.from` routines. +- **Pinning types**: Whenever the application starts using a new contract binding method, add corresponding type assertions to `lib/stellar/__tests__/typed-call.test-d.ts` (using `Value` and argument parameter shapes) with `.not.toBeAny()` guards. This ensures that any upstream contract signature changes fail the test suite immediately. + ## 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/testnet.e2e.test.ts b/e2e/testnet.e2e.test.ts index 2e74341..b8e8655 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 { read, bytesToHex } 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..bff489a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -16,6 +16,19 @@ const eslintConfig = defineConfig([ "contracts/**", "lib/stellar/generated/**", ]), + { + files: ["app/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}", "e2e/**/*.{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 explicit type args to read/submit/simulated — the type is inferred from the binding.", + }, + ], + }, + }, ]); export default eslintConfig; diff --git a/lib/hooks/useStellar.ts b/lib/hooks/useStellar.ts index 574738f..b8b239c 100644 --- a/lib/hooks/useStellar.ts +++ b/lib/hooks/useStellar.ts @@ -1,15 +1,17 @@ "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 { 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 { read, submit, bytesToHex, simulated } from "@/lib/stellar/typed-call"; import { maxIn, minOut } from "@/lib/stellar/units"; +export { read, submit, simulated, bytesToHex }; + /** Query keys all start with "stellar" so a disconnect can drop them in one go. */ export const stellarKeys = { marketCount: ["stellar", "marketCount"] as const, @@ -23,47 +25,12 @@ 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)". - */ -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); - } -} - // ─── 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 +43,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 +82,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 +91,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 +103,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 +127,8 @@ 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(), @@ -229,7 +189,7 @@ export function useBuy() { const signer = guard(); 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); @@ -240,7 +200,7 @@ export function useBuy() { collateral_in: collateralIn, min_shares_out: minSharesOut, }); - const { value, txHash } = await send(tx); + const { value, txHash } = await submit(tx); return { sharesOut: value, quotedShares: quote, minSharesOut, txHash, holder: signer.publicKey }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), @@ -285,7 +245,7 @@ export function useSell() { collateral_out: collateralOut, max_shares_in: maxSharesIn, }); - const { value, txHash } = await send(tx); + const { value, txHash } = await submit(tx); return { sharesSold: value, collateralOut, maxSharesIn, txHash, holder: signer.publicKey }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), @@ -299,7 +259,7 @@ export function useRedeem() { 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); + const { value, txHash } = await submit(tx); return { payout: value, txHash, holder: signer.publicKey }; }, onSuccess: (d, v) => invalidate(String(v.marketId), d.holder), @@ -315,7 +275,7 @@ export function useUsdcTransfer() { const signer = guard(); 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 { txHash } = await submit(tx); if (!txHash) throw new UserFacingError({ kind: "unknown", message: "The payment was sent but no transaction hash came back." }); return { txHash, holder: signer.publicKey }; }, 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..a99323c --- /dev/null +++ b/lib/stellar/__tests__/typed-call.test-d.ts @@ -0,0 +1,90 @@ +import { describe, expectTypeOf, it } from "vitest"; +import { read } from "../typed-call"; +import type { MarketCoreClient, RegistryClient, Value } from "../typed-call"; +import { marketCore, reasoningRegistry } from "../clients"; +import type { Market, Position, Outcome } from "../generated/market-core"; +import type { Trace } from "../generated/reasoning-registry"; + +type MC = MarketCoreClient; +type RR = RegistryClient; + +describe("Type inference for binding return types via Value", () => { + it("pins return types of all binding methods used by the application", () => { + 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; + }>(); + expectTypeOf>().toEqualTypeOf(); + }); + + it("guards against inference collapsing to any", () => { + 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(); + }); + + it("pins argument shapes for every contract write", () => { + 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; + }>(); + }); + + it("infers return types directly when calling read() without type arguments", async () => { + const marketCount = read(await marketCore().market_count()); + expectTypeOf(marketCount).toEqualTypeOf(); + expectTypeOf(marketCount).not.toBeAny(); + + const market = read(await marketCore().get_market({ market_id: 0n })); + expectTypeOf(market).toEqualTypeOf(); + expectTypeOf(market).not.toBeAny(); + + const position = read( + await marketCore().get_position({ market_id: 0n, holder: "" }), + ); + expectTypeOf(position).toEqualTypeOf(); + expectTypeOf(position).not.toBeAny(); + + const trace = read( + await reasoningRegistry().get_trace({ trace_id: 0n }), + "reasoningRegistry", + ); + expectTypeOf(trace).toEqualTypeOf(); + expectTypeOf(trace.trace_hash).toEqualTypeOf(); + expectTypeOf(trace).not.toBeAny(); + }); +}); diff --git a/lib/stellar/__tests__/typed-call.test.ts b/lib/stellar/__tests__/typed-call.test.ts new file mode 100644 index 0000000..759d44b --- /dev/null +++ b/lib/stellar/__tests__/typed-call.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it, vi } from "vitest"; +import { Ok, Err } from "@stellar/stellar-sdk/contract"; +import type { AssembledTransaction } from "@stellar/stellar-sdk/contract"; +import { UserFacingError } from "../errors"; +import { bytesToHex, read, simulated, submit } from "../typed-call"; + +function mockTx( + result: T, + options?: { + simulation?: { error?: string }; + signAndSendResult?: unknown; + txHash?: string | null; + }, +): AssembledTransaction { + return { + result, + simulation: options?.simulation, + signAndSend: vi.fn().mockResolvedValue({ + result: options?.signAndSendResult ?? result, + sendTransactionResponse: { + hash: options?.txHash !== undefined ? options?.txHash : "test-tx-hash-abc", + }, + }), + } as unknown as AssembledTransaction; +} + +function mockThrowingTx( + errorToThrow: unknown, + simulation?: { error?: string }, +): AssembledTransaction { + const signAndSend = vi.fn(); + return { + get result() { + throw errorToThrow; + }, + simulation, + signAndSend, + } as unknown as AssembledTransaction; +} + +describe("bytesToHex utility", () => { + it("converts a Buffer to a lowercase hex string", () => { + const buf = Buffer.from([0x01, 0x0f, 0xa3, 0xff]); + expect(bytesToHex(buf)).toBe("010fa3ff"); + }); + + it("converts a Uint8Array to a lowercase hex string", () => { + const uint8 = new Uint8Array([0x00, 0x12, 0xab, 0xcd]); + expect(bytesToHex(uint8)).toBe("0012abcd"); + }); + + it("handles empty buffers", () => { + expect(bytesToHex(new Uint8Array([]))).toBe(""); + }); +}); + +describe("read() runtime helper", () => { + it("returns plain non-Result values unchanged (e.g. u64, Position)", () => { + const plainBigInt = 123456789n; + const tx = mockTx(plainBigInt); + expect(read(tx)).toBe(plainBigInt); + + const plainPosition = { shares_yes: 100n, shares_no: 0n }; + const posTx = mockTx(plainPosition); + expect(read(posTx)).toEqual(plainPosition); + }); + + it("unwraps Ok instances into their inner value", () => { + const okVal = 42n; + const tx = mockTx(new Ok(okVal)); + expect(read(tx)).toBe(okVal); + }); + + it("throws UserFacingError on Err with contract error code mapped", () => { + const tx = mockTx(new Err("Error(Contract, #12)"), { + simulation: { error: "HostError Error(Contract, #12)" }, + }); + expect(() => read(tx, "marketCore")).toThrow(UserFacingError); + + try { + read(tx, "marketCore"); + } catch (err) { + expect(err).toBeInstanceOf(UserFacingError); + const ufe = err as UserFacingError; + expect(ufe.described.kind).toBe("contract"); + expect(ufe.described.code).toBe("SlippageExceeded"); + } + }); + + it("does not treat an object with an isOk property as a Result", () => { + const duckTypedObject = { + isOk: () => true, + unwrap: () => 99, + }; + const tx = mockTx(duckTypedObject); + // Because duckTypedObject is NOT instanceof Ok, read() must not unwrap it. + const result = read(tx); + expect(result).toBe(duckTypedObject); + expect(result).not.toBe(99); + }); + + it("maps a throwing tx.result through describeError with the contract table", () => { + const tx = mockThrowingTx( + new Error("Simulation failed: Error(Contract, #7)"), + { error: "Error(Contract, #7)" }, + ); + + expect(() => read(tx, "marketCore")).toThrow(UserFacingError); + + try { + read(tx, "marketCore"); + } catch (err) { + expect(err).toBeInstanceOf(UserFacingError); + const ufe = err as UserFacingError; + expect(ufe.described.kind).toBe("contract"); + expect(ufe.described.code).toBe("MarketNotFound"); + } + }); + + it("simulated is a deprecated alias to read", () => { + const tx = mockTx(100n); + expect(simulated(tx)).toBe(100n); + }); +}); + +describe("submit() runtime helper", () => { + it("surfaces simulation failure before calling signAndSend", async () => { + const tx = mockThrowingTx( + new Error("Error(Contract, #2)"), + { error: "Error(Contract, #2)" }, + ); + + await expect(submit(tx, "marketCore")).rejects.toThrow(UserFacingError); + expect(tx.signAndSend).not.toHaveBeenCalled(); + }); + + it("returns { value, txHash } from sendTransactionResponse on success", async () => { + const okValue = 999n; + const tx = mockTx(new Ok(okValue), { + signAndSendResult: new Ok(okValue), + txHash: "mocked-hash-xyz", + }); + + const res = await submit(tx, "marketCore"); + expect(res).toEqual({ + value: okValue, + txHash: "mocked-hash-xyz", + }); + expect(tx.signAndSend).toHaveBeenCalledTimes(1); + }); + + it("returns null txHash when sendTransactionResponse has no hash", async () => { + const tx = mockTx(42n, { + signAndSendResult: 42n, + txHash: null, + }); + + const res = await submit(tx); + expect(res).toEqual({ + value: 42n, + txHash: null, + }); + }); +}); diff --git a/lib/stellar/typed-call.ts b/lib/stellar/typed-call.ts new file mode 100644 index 0000000..b80e0ee --- /dev/null +++ b/lib/stellar/typed-call.ts @@ -0,0 +1,98 @@ +import { Ok, Err } from "@stellar/stellar-sdk/contract"; +import type { AssembledTransaction, Result } from "@stellar/stellar-sdk/contract"; +import type { ContractName } from "./errors"; +import { describeError, UserFacingError } from "./errors"; +import type { marketCore, reasoningRegistry } from "./clients"; + +/** + * Converts a byte buffer (Buffer or Uint8Array) into a lowercase hex string. + * Used for trace_hash conversion across the app. + */ +export function bytesToHex(buf: Uint8Array): string { + return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** T from AssembledTransaction */ +export type TxValue = Tx extends AssembledTransaction ? T : never; + +/** + * Unwrapped value: Result -> V, otherwise T itself. + * Wrapped in a tuple to prevent distributive conditional typing over unions. + */ +export type Unwrapped = [T] extends [Result] + ? V + : [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; + +/** + * Reads the simulated result of an AssembledTransaction. + * Infers the unwrapped return type directly from the transaction parameter. + * Detects contract Result types using SDK Ok/Err instances (not duck typing). + */ +export function read>( + tx: Tx, + contract: ContractName = "marketCore", +): Unwrapped> { + 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 instanceof Ok) { + return raw.unwrap() as Unwrapped>; + } + if (raw instanceof Err) { + const errPayload = sim?.error ? `${sim.error} ${String(raw)}` : raw; + throw new UserFacingError(describeError(errPayload, contract), raw); + } + + return raw as Unwrapped>; +} + +/** + * Simulates, signs, and sends an AssembledTransaction. + * First verifies simulation success via `read(tx)`, then calls `signAndSend()`. + * Returns `{ value, txHash }` with the value inferred from the transaction. + */ +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 = sent.result as unknown; + // Single documented cast at the boundary where the SDK's SentTransaction.result is loosely typed. + let unwrapped: unknown = value; + if (value instanceof Ok) { + unwrapped = value.unwrap(); + } else if (value instanceof Err) { + throw new UserFacingError(describeError(value, contract), value); + } + return { + value: unwrapped as Unwrapped>, + txHash: sent.sendTransactionResponse?.hash ?? null, + }; + } catch (err) { + if (err instanceof UserFacingError) throw err; + throw new UserFacingError(describeError(err, contract), err); + } +} + +/** + * @deprecated Use `read` instead. `read` infers the result type directly from + * the binding method signature without requiring explicit type arguments. + */ +export const simulated = read; diff --git a/vitest.config.mts b/vitest.config.mts index e80c8c3..6056082 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,5 +8,6 @@ export default defineConfig({ test: { environment: "node", include: ["lib/**/*.test.ts"], + typecheck: { enabled: true, include: ["lib/**/*.test-d.ts"], tsconfig: "./tsconfig.json" }, }, });