From 660aed2639ba22a5fe93474beb06c71704ea727f Mon Sep 17 00:00:00 2001 From: joshuaolabodebello2020-cyber Date: Tue, 30 Jun 2026 01:43:04 -0700 Subject: [PATCH] Add wallet adapter integration examples --- README.md | 507 +++++++++------- src/types.ts | 1219 +++++++++++++++++++------------------- src/wallet.ts | 824 +++++++++++++------------- test/wallet-docs.test.ts | 21 + 4 files changed, 1342 insertions(+), 1229 deletions(-) create mode 100644 test/wallet-docs.test.ts diff --git a/README.md b/README.md index 49f4b37..3d39161 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,288 @@ -# @sorostream/sdk - -![npm](https://img.shields.io/npm/v/@sorostream/sdk) -![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript) -![License](https://img.shields.io/badge/license-MIT-green) -![CI](https://github.com/SoroStream/sorostream-sdk/actions/workflows/test.yml/badge.svg) - -TypeScript SDK for the **SoroStream** payment streaming protocol on Stellar Soroban. Stream USDC by the second for salaries, subscriptions, vesting schedules, and grant disbursements. - -## Installation - -```bash -npm install @sorostream/sdk -``` - -## Quick Start - -```typescript -import { SoroStreamClient, createFreighterAdapter, toStroops } from "@sorostream/sdk"; - -// 1. Connect wallet -const walletAdapter = await createFreighterAdapter(); - -// 2. Create client -const client = new SoroStreamClient({ - network: "testnet", - contractId: "YOUR_CONTRACT_ID", - walletAdapter, -}); - -// 3. Create a stream: 100 USDC over 30 days -const { streamId, txHash } = await client.createStream({ - recipient: "GRECIPIENT_ADDRESS", - token: "GUSDC_TOKEN_ADDRESS", - amount: toStroops("100"), - durationSeconds: 30 * 24 * 60 * 60, - autoRenew: false, -}); - -// 4. Check claimable balance -const claimable = await client.getClaimable(streamId); - -// 5. Withdraw -await client.withdraw({ streamId }); -``` - -## API Reference - -### `SoroStreamClient` - -| Method | Description | -|--------|-------------| -| `createStream(params)` | Creates a new payment stream. Returns `{ streamId, txHash }` | -| `withdraw(params)` | Withdraws all claimable tokens. Returns `{ txHash, amount }` | -| `batchWithdraw(streamIds, batchSize?)` | Withdraws from multiple streams in one tx. Returns `BatchWithdrawResult[]` | -| `cancelStream(params)` | Cancels stream, refunds sender remainder. Returns `{ txHash }` | -| `topUp(params)` | Adds tokens, extends duration. Returns `{ txHash, newEndTime }` | -| `bulkCreateStreams(rows, options)` | Creates many streams at once (batched). Returns `BulkCreateResult` | -| `getStream(streamId)` | Returns full `Stream` object | -| `getClaimable(streamId)` | Returns claimable amount in stroops | -| `getStreamsBySender(sender)` | Returns all streams for a sender | -| `getStreamsByRecipient(recipient)` | Returns all streams for a recipient | -| `estimateCreateStreamFee(params)` | Estimates network fee for `createStream`. Returns `{ totalFee, minResourceFee }` | -| `estimateWithdrawFee(params)` | Estimates network fee for `withdraw`. Returns `{ totalFee, minResourceFee }` | -| `estimateCancelStreamFee(params)` | Estimates network fee for `cancelStream`. Returns `{ totalFee, minResourceFee }` | -| `estimateTopUpFee(params)` | Estimates network fee for `topUp`. Returns `{ totalFee, minResourceFee }` | - -### Utilities - -| Function | Description | -|----------|-------------| -| `toStroops(usdc)` | Converts USDC decimal string to stroops bigint | -| `formatUSDC(stroops)` | Formats stroops bigint to USDC string | -| `calculateFlowRate(amount, duration)` | Returns stroops/second flow rate | -| `claimableNow(stream)` | Estimates current claimable (client-side) | -| `timeUntilStreamEnd(stream)` | Returns seconds until stream ends | -| `calculateVestingSchedule(stream, cliffSeconds, now?)` | Display-only vesting schedule approximating a cliff. **Not enforced on-chain** | -| `watchClaimable(stream, reconcile, onTick, options?)` | Live counting-up ticker for claimable balance. Returns unsubscribe function | - -### Client Options - -| Option | Default | Description | -|--------|---------|-------------| -| `network` | — | Stellar network (`"mainnet"`, `"testnet"`, `"futurenet"`) | -| `contractId` | — | Deployed stream contract address | -| `walletAdapter` | — | Wallet adapter for signing | -| `rpcUrl?` | Default per network | Custom RPC URL override | -| `txTimeoutMs?` | `120000` | Max time (ms) to wait for transaction confirmation | -| `checkDuplicate?` | `false` | Heuristic check to warn/block duplicate stream creation | - -All mutation methods (`createStream`, `withdraw`, `cancelStream`, `topUp`) accept an optional `AbortSignal` as the last argument to cancel in-flight transactions. - -| Method | Description | -|--------|-------------| -| `executeBatch(operations)` | Submits multiple operations in a single transaction | -| `aggregateStreamsByToken(streams)` | Groups streams by token, returns per-token totals | -| `parseCsvStreamRows(csv)` | Parses CSV string into `BulkStreamRow[]` | - -### Wallet - -| Function | Description | -|----------|-------------| -| `createFreighterAdapter()` | Creates a WalletAdapter backed by Freighter extension | -| `createKeypairAdapter(secretKey)` | Creates a WalletAdapter from a Stellar secret key (server-side) | -| `createLedgerAdapter({ transport, path? })` | Creates a WalletAdapter backed by a Ledger device | -| `connectWallet()` | Prompts Freighter connection, returns public key | - -The `WalletAdapter` interface (see `src/types.ts`) is the official extension point for custom signing backends. Implement `getPublicKey`, `signTransaction`, and `isConnected` to support any wallet or signing service. - -### Deno and Bun Compatibility - -The SDK is fully compatible with modern JS/TS runtimes, including **Deno** and **Bun**. - -#### Bun Usage -```bash -bun install @sorostream/sdk -``` -You can import the SDK and use it directly in Bun scripts: -```typescript -import { SoroStreamClient, createKeypairAdapter } from "@sorostream/sdk"; -``` - -#### Deno Usage -You can run/import the SDK directly using NPM imports: -```typescript -import { SoroStreamClient, createKeypairAdapter } from "npm:@sorostream/sdk"; -``` - -### Server-side Usage - -For backend scripts and automated payouts, use `createKeypairAdapter`: - -```typescript -import { SoroStreamClient, createKeypairAdapter, toStroops } from "@sorostream/sdk"; - -const adapter = createKeypairAdapter("SAZ...YOUR...SECRET...KEY..."); -const client = new SoroStreamClient({ - network: "testnet", - contractId: "YOUR_CONTRACT_ID", - walletAdapter: adapter, -}); - -// Bulk-create payroll streams from CSV -const csv = `recipient,amount,durationSeconds -GABCD...1,100000000,2592000 -GABCD...2,50000000,604800`; -const rows = parseCsvStreamRows(csv); -const { batches } = await client.bulkCreateStreams(rows, { - token: "GUSDC_TOKEN_ADDRESS", -}); -console.log(`Created ${batches.length} batch(es)`); -``` - -## Documentation - -| Document | Description | -|----------|-------------| -| [Stream State Machine](./docs/state-machine.md) | Mermaid diagram of all stream states and valid / invalid transitions | -| [Rate Limiting](./docs/rate-limiting.md) | Default polling intervals, network call frequency, and tuning advice | -| [linear-vesting.ts](./examples/linear-vesting.ts) | Constant-rate stream with no cliff | -| [cliff-linear-vesting.ts](./examples/cliff-linear-vesting.ts) | Cliff period followed by linear release | -| [milestone-vesting.ts](./examples/milestone-vesting.ts) | Fixed tranches released at scheduled dates | -## Architecture - -``` -┌─────────────────────────────────┐ -│ Your App / UI │ -└──────────────┬──────────────────┘ - │ imports - ▼ -┌─────────────────────────────────┐ -│ @sorostream/sdk │ -│ │ -│ SoroStreamClient │ -│ ├─ WalletAdapter (sign txs) │ -│ ├─ Cache (optimistic reads) │ -│ └─ CircuitBreaker / Retry │ -│ │ -│ Utils (pure helpers) │ -│ ├─ toStroops / formatUSDC │ -│ ├─ claimableNow / isExpired │ -│ └─ calculateVestingSchedule │ -└──────────────┬──────────────────┘ - │ Stellar RPC (simulateTransaction / sendTransaction) - ▼ -┌─────────────────────────────────┐ -│ SoroStream Contract (Soroban) │ -│ github.com/SoroStream/contract│ -└─────────────────────────────────┘ -``` - -**SoroStreamClient** is the primary entry point. It handles transaction building, signing, submission, polling, and retry logic. It exposes both mutation methods (`createStream`, `withdraw`, `topUp`, …) and read methods (`getStream`, `getClaimable`, …). - -**WalletAdapters** decouple signing from the client. Three are built-in — `createFreighterAdapter` (browser extension), `createKeypairAdapter` (server-side secret key), and `createLedgerAdapter` (hardware wallet). Implement `WalletAdapter` to add any custom signer. - -**Utils** are pure functions with no network dependency. Use them for client-side estimates (`claimableNow`, `isExpired`), display formatting (`formatUSDC`, `toStroops`), and display-only vesting schedules (`calculateVestingSchedule`). They can run in any JS/TS environment including Deno and Bun. - -Contract source: [github.com/SoroStream/contract](https://github.com/SoroStream/contract) · Example app: [github.com/SoroStream/app](https://github.com/SoroStream/app) - -## Migrating from v0.x - -See [docs/migration-v1.md](./docs/migration-v1.md) for a full list of breaking changes with before/after examples. - -## Local Setup - -```bash -npm install -npm test # run unit tests -npm run lint # type check -npm run build # build to dist/ -``` - -## Contributing via Drips Wave - -This project participates in the **Stellar Wave Program** on [Drips Wave](https://drips.network/wave). Contributors earn rewards for resolving issues during weekly Wave sprints. - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow. - -> **Note:** Do not start coding until assigned to an issue by a maintainer. +# @sorostream/sdk + +![npm](https://img.shields.io/npm/v/@sorostream/sdk) +![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript) +![License](https://img.shields.io/badge/license-MIT-green) +![CI](https://github.com/SoroStream/sorostream-sdk/actions/workflows/test.yml/badge.svg) + +TypeScript SDK for the **SoroStream** payment streaming protocol on Stellar Soroban. Stream USDC by the second for salaries, subscriptions, vesting schedules, and grant disbursements. + +## Installation + +```bash +npm install @sorostream/sdk +``` + +## Quick Start + +```typescript +import { SoroStreamClient, createFreighterAdapter, toStroops } from "@sorostream/sdk"; + +// 1. Connect wallet +const walletAdapter = await createFreighterAdapter(); + +// 2. Create client +const client = new SoroStreamClient({ + network: "testnet", + contractId: "YOUR_CONTRACT_ID", + walletAdapter, +}); + +// 3. Create a stream: 100 USDC over 30 days +const { streamId, txHash } = await client.createStream({ + recipient: "GRECIPIENT_ADDRESS", + token: "GUSDC_TOKEN_ADDRESS", + amount: toStroops("100"), + durationSeconds: 30 * 24 * 60 * 60, + autoRenew: false, +}); + +// 4. Check claimable balance +const claimable = await client.getClaimable(streamId); + +// 5. Withdraw +await client.withdraw({ streamId }); +``` + +## API Reference + +### `SoroStreamClient` + +| Method | Description | +|--------|-------------| +| `createStream(params)` | Creates a new payment stream. Returns `{ streamId, txHash }` | +| `withdraw(params)` | Withdraws all claimable tokens. Returns `{ txHash, amount }` | +| `batchWithdraw(streamIds, batchSize?)` | Withdraws from multiple streams in one tx. Returns `BatchWithdrawResult[]` | +| `cancelStream(params)` | Cancels stream, refunds sender remainder. Returns `{ txHash }` | +| `topUp(params)` | Adds tokens, extends duration. Returns `{ txHash, newEndTime }` | +| `bulkCreateStreams(rows, options)` | Creates many streams at once (batched). Returns `BulkCreateResult` | +| `getStream(streamId)` | Returns full `Stream` object | +| `getClaimable(streamId)` | Returns claimable amount in stroops | +| `getStreamsBySender(sender)` | Returns all streams for a sender | +| `getStreamsByRecipient(recipient)` | Returns all streams for a recipient | +| `estimateCreateStreamFee(params)` | Estimates network fee for `createStream`. Returns `{ totalFee, minResourceFee }` | +| `estimateWithdrawFee(params)` | Estimates network fee for `withdraw`. Returns `{ totalFee, minResourceFee }` | +| `estimateCancelStreamFee(params)` | Estimates network fee for `cancelStream`. Returns `{ totalFee, minResourceFee }` | +| `estimateTopUpFee(params)` | Estimates network fee for `topUp`. Returns `{ totalFee, minResourceFee }` | + +### Utilities + +| Function | Description | +|----------|-------------| +| `toStroops(usdc)` | Converts USDC decimal string to stroops bigint | +| `formatUSDC(stroops)` | Formats stroops bigint to USDC string | +| `calculateFlowRate(amount, duration)` | Returns stroops/second flow rate | +| `claimableNow(stream)` | Estimates current claimable (client-side) | +| `timeUntilStreamEnd(stream)` | Returns seconds until stream ends | +| `calculateVestingSchedule(stream, cliffSeconds, now?)` | Display-only vesting schedule approximating a cliff. **Not enforced on-chain** | +| `watchClaimable(stream, reconcile, onTick, options?)` | Live counting-up ticker for claimable balance. Returns unsubscribe function | + +### Client Options + +| Option | Default | Description | +|--------|---------|-------------| +| `network` | — | Stellar network (`"mainnet"`, `"testnet"`, `"futurenet"`) | +| `contractId` | — | Deployed stream contract address | +| `walletAdapter` | — | Wallet adapter for signing | +| `rpcUrl?` | Default per network | Custom RPC URL override | +| `txTimeoutMs?` | `120000` | Max time (ms) to wait for transaction confirmation | +| `checkDuplicate?` | `false` | Heuristic check to warn/block duplicate stream creation | + +All mutation methods (`createStream`, `withdraw`, `cancelStream`, `topUp`) accept an optional `AbortSignal` as the last argument to cancel in-flight transactions. + +| Method | Description | +|--------|-------------| +| `executeBatch(operations)` | Submits multiple operations in a single transaction | +| `aggregateStreamsByToken(streams)` | Groups streams by token, returns per-token totals | +| `parseCsvStreamRows(csv)` | Parses CSV string into `BulkStreamRow[]` | + +### Wallet + +| Function | Description | +|----------|-------------| +| `createFreighterAdapter()` | Creates a WalletAdapter backed by Freighter extension | +| `createKeypairAdapter(secretKey)` | Creates a WalletAdapter from a Stellar secret key (server-side) | +| `createLedgerAdapter({ transport, path? })` | Creates a WalletAdapter backed by a Ledger device | +| `connectWallet()` | Prompts Freighter connection, returns public key | + +The `WalletAdapter` interface (see `src/types.ts`) is the official extension point for custom signing backends. Implement `getPublicKey`, `signTransaction`, and `isConnected` to support any wallet or signing service. See the [wallet adapter examples](#wallet-adapter-examples) below for copy-pasteable testnet patterns. + +### Wallet adapter examples + +#### Freighter adapter + +```ts +import { SoroStreamClient, createFreighterAdapter } from "@sorostream/sdk"; + +const freighterAdapter = await createFreighterAdapter(); +const client = new SoroStreamClient({ + network: "testnet", + contractId: "YOUR_CONTRACT_ID", + walletAdapter: freighterAdapter, +}); +``` + +#### Ledger adapter + +```ts +import { TransactionBuilder, Networks } from "@stellar/stellar-sdk"; +import TransportWebUSB from "@ledgerhq/hw-transport-webusb"; +import AppStr from "@ledgerhq/hw-app-str"; +import type { WalletAdapter, Network } from "@sorostream/sdk"; + +async function signWithLedger(xdr: string, network: Network) { + const transport = await TransportWebUSB.create(); + const app = new AppStr(transport); + const path = "m/44'/148'/0'/0/0"; + const tx = TransactionBuilder.fromXDR( + xdr, + network === "testnet" ? Networks.TESTNET : network === "futurenet" ? Networks.FUTURENET : Networks.PUBLIC, + ); + + const signature = await app.signTransaction(path, tx.hash()); + await transport.close(); + + return signature; +} + +const ledgerAdapter: WalletAdapter = { + async isConnected() { + return true; + }, + async getPublicKey() { + const transport = await TransportWebUSB.create(); + const app = new AppStr(transport); + const result = await app.getAddress("m/44'/148'/0'/0/0"); + await transport.close(); + return result.address; + }, + async signTransaction(xdr, network) { + const signature = await signWithLedger(xdr, network); + return signature; + }, +}; +``` + +#### Server-side keypair adapter + +```ts +import { SoroStreamClient, createKeypairAdapter } from "@sorostream/sdk"; + +const serverKeypairAdapter = createKeypairAdapter(process.env.STELLAR_SECRET!); +const client = new SoroStreamClient({ + network: "testnet", + contractId: "YOUR_CONTRACT_ID", + walletAdapter: serverKeypairAdapter, +}); +``` + +### Deno and Bun Compatibility + +The SDK is fully compatible with modern JS/TS runtimes, including **Deno** and **Bun**. + +#### Bun Usage +```bash +bun install @sorostream/sdk +``` +You can import the SDK and use it directly in Bun scripts: +```typescript +import { SoroStreamClient, createKeypairAdapter } from "@sorostream/sdk"; +``` + +#### Deno Usage +You can run/import the SDK directly using NPM imports: +```typescript +import { SoroStreamClient, createKeypairAdapter } from "npm:@sorostream/sdk"; +``` + +### Server-side Usage + +For backend scripts and automated payouts, use `createKeypairAdapter`: + +```typescript +import { SoroStreamClient, createKeypairAdapter, toStroops } from "@sorostream/sdk"; + +const adapter = createKeypairAdapter("SAZ...YOUR...SECRET...KEY..."); +const client = new SoroStreamClient({ + network: "testnet", + contractId: "YOUR_CONTRACT_ID", + walletAdapter: adapter, +}); + +// Bulk-create payroll streams from CSV +const csv = `recipient,amount,durationSeconds +GABCD...1,100000000,2592000 +GABCD...2,50000000,604800`; +const rows = parseCsvStreamRows(csv); +const { batches } = await client.bulkCreateStreams(rows, { + token: "GUSDC_TOKEN_ADDRESS", +}); +console.log(`Created ${batches.length} batch(es)`); +``` + +## Documentation + +| Document | Description | +|----------|-------------| +| [Stream State Machine](./docs/state-machine.md) | Mermaid diagram of all stream states and valid / invalid transitions | +| [Rate Limiting](./docs/rate-limiting.md) | Default polling intervals, network call frequency, and tuning advice | +| [linear-vesting.ts](./examples/linear-vesting.ts) | Constant-rate stream with no cliff | +| [cliff-linear-vesting.ts](./examples/cliff-linear-vesting.ts) | Cliff period followed by linear release | +| [milestone-vesting.ts](./examples/milestone-vesting.ts) | Fixed tranches released at scheduled dates | +## Architecture + +``` +┌─────────────────────────────────┐ +│ Your App / UI │ +└──────────────┬──────────────────┘ + │ imports + â–¼ +┌─────────────────────────────────┐ +│ @sorostream/sdk │ +│ │ +│ SoroStreamClient │ +│ ├─ WalletAdapter (sign txs) │ +│ ├─ Cache (optimistic reads) │ +│ └─ CircuitBreaker / Retry │ +│ │ +│ Utils (pure helpers) │ +│ ├─ toStroops / formatUSDC │ +│ ├─ claimableNow / isExpired │ +│ └─ calculateVestingSchedule │ +└──────────────┬──────────────────┘ + │ Stellar RPC (simulateTransaction / sendTransaction) + â–¼ +┌─────────────────────────────────┐ +│ SoroStream Contract (Soroban) │ +│ github.com/SoroStream/contract│ +└─────────────────────────────────┘ +``` + +**SoroStreamClient** is the primary entry point. It handles transaction building, signing, submission, polling, and retry logic. It exposes both mutation methods (`createStream`, `withdraw`, `topUp`, …) and read methods (`getStream`, `getClaimable`, …). + +**WalletAdapters** decouple signing from the client. Three are built-in — `createFreighterAdapter` (browser extension), `createKeypairAdapter` (server-side secret key), and `createLedgerAdapter` (hardware wallet). Implement `WalletAdapter` to add any custom signer. + +**Utils** are pure functions with no network dependency. Use them for client-side estimates (`claimableNow`, `isExpired`), display formatting (`formatUSDC`, `toStroops`), and display-only vesting schedules (`calculateVestingSchedule`). They can run in any JS/TS environment including Deno and Bun. + +Contract source: [github.com/SoroStream/contract](https://github.com/SoroStream/contract) · Example app: [github.com/SoroStream/app](https://github.com/SoroStream/app) + +## Migrating from v0.x + +See [docs/migration-v1.md](./docs/migration-v1.md) for a full list of breaking changes with before/after examples. + +## Local Setup + +```bash +npm install +npm test # run unit tests +npm run lint # type check +npm run build # build to dist/ +``` + +## Contributing via Drips Wave + +This project participates in the **Stellar Wave Program** on [Drips Wave](https://drips.network/wave). Contributors earn rewards for resolving issues during weekly Wave sprints. + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow. + +> **Note:** Do not start coding until assigned to an issue by a maintainer. diff --git a/src/types.ts b/src/types.ts index 2b0c221..c0067fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,597 +1,622 @@ -/** Status of a payment stream. */ -export type StreamStatus = "Active" | "Cancelled" | "Completed" | "Paused"; - -// ── Event types (#1) ───────────────────────────────────────────────────────── - -export type StreamEventType = - | "StreamCreated" - | "StreamWithdrawn" - | "StreamCancelled" - | "StreamCompleted" - | "StreamToppedUp" - | "StreamPaused" - | "StreamResumed" - | "StreamTransferred"; - -export interface StreamEvent { - type: StreamEventType; - streamId: string; - txHash: string; - ledger: number; - timestamp: number; - data: Record; -} - -export interface StreamSubscription { - unsubscribe(): void; -} - -export interface StreamEventFilter { - streamId?: string; - sender?: string; - recipient?: string; -} - -// ── Pagination types (#3) ──────────────────────────────────────────────────── - -export interface PaginationParams { - limit?: number; - cursor?: string; -} - -export interface PaginatedStreams { - streams: Stream[]; - cursor: string | null; - hasMore: boolean; -} - -// ── Multisig types (#16) ───────────────────────────────────────────────────── - -export interface MultisigSigner { - signTransaction(xdr: string, network: Network): Promise; -} - -// ── Webhook types (#22) ────────────────────────────────────────────────────── - -export interface WebhookConfig { - url: string; - headers?: Record; - retries?: number; - retryDelayMs?: number; -} - -/** A single payment stream as returned by the contract. */ -export interface Stream { - /** Unique stream identifier. */ - id: string; - /** Address of the stream creator / payer. */ - sender: string; - /** Address of the stream beneficiary. */ - recipient: string; - /** SAC token contract address (e.g. USDC). */ - token: string; - /** Total token deposit locked in stroops. */ - deposit: bigint; - /** Tokens released per second in stroops. */ - flowRate: bigint; - /** Unix timestamp (seconds) when the stream started. */ - startTime: number; - /** Unix timestamp (seconds) when the stream ends. */ - endTime: number; - /** Unix timestamp (seconds) of the last withdrawal. */ - lastWithdrawTime: number; - /** Current stream status. */ - status: StreamStatus; - /** Whether the stream auto-renews on completion. */ - autoRenew: boolean; - /** Unix timestamp (seconds) when the stream was paused (undefined if not paused). */ - pausedAt?: number; - /** Optional helper method for JSON serialization of BigInt fields. */ - toJSON?(): Record; -} - -/** Parameters for creating a new stream. */ -export interface CreateStreamParams { - /** Beneficiary address. */ - recipient: string; - /** SAC token contract address. */ - token: string; - /** Total amount to stream in stroops. */ - amount: bigint; - /** Stream duration in seconds. */ - durationSeconds: number; - /** Whether to auto-renew on completion. */ - autoRenew: boolean; - /** Opt-in check for duplicate stream creation. */ - checkDuplicate?: boolean; - /** - * Cliff duration in seconds (issue #74). - * When set, the configured `validateCliff` function is called before - * the transaction is submitted. Defaults to 0 (no cliff). - */ - cliffSeconds?: number; - /** - * Skip the token allowance pre-flight check (issue #165). - * Set to true when you have already approved the contract or the token - * does not expose an allowance view (e.g. native XLM). - */ - skipAllowanceCheck?: boolean; -} - -/** Alias for a single stream creation params object. */ -export type CreateStreamsParams = CreateStreamParams; - -/** Parameters for withdrawing from a stream. */ -export interface WithdrawParams { - /** Stream ID to withdraw from. */ - streamId: string; -} - -/** Parameters for cancelling a stream. */ -export interface CancelStreamParams { - /** Stream ID to cancel. */ - streamId: string; -} - -/** Parameters for topping up a stream. */ -export interface TopUpParams { - /** Stream ID to top up. */ - streamId: string; - /** Additional amount to add in stroops. */ - amount: bigint; -} - -/** Network configuration. */ -export type Network = "mainnet" | "testnet" | "futurenet"; - -/** Fee estimate returned by prepareTransaction. */ -export interface FeeEstimate { - /** Total fee in stroops (base fee + min resource fee). */ - totalFee: number; - /** Soroban resource fee in stroops. */ - minResourceFee: number; -} - -/** Result of batch cancellation. */ -export interface BatchCancelResult { - txHash: string; - streamIds: string[]; -} - -/** Parameters for updating a stream's flow rate. */ -export interface UpdateFlowRateParams { - streamId: string; - newFlowRate: bigint; -} - -/** Parameters for setting an operator on a stream. */ -export interface SetOperatorParams { - streamId: string; - operator: string; - approved: boolean; -} - -/** Parameters for an operator to top up a stream. */ -export interface OperatorTopUpParams { - streamId: string; - amount: bigint; -} - -/** Parameters for transferring a stream to a new recipient. */ -export interface TransferStreamParams { - streamId: string; - newRecipient: string; -} - -/** Parameters for pausing a stream. */ -export interface PauseStreamParams { - streamId: string; -} - -/** Parameters for resuming a paused stream. */ -export interface ResumeStreamParams { - streamId: string; -} - -/** A single milestone point in a vesting schedule. */ -export interface VestingSchedulePoint { - /** Unix timestamp of the milestone. */ - time: number; - /** Amount vested in stroops at this point. */ - vested: bigint; -} - -/** Result of a display-only vesting schedule calculation. */ -export interface VestingScheduleResult { - /** Effective claimable amount right now in stroops (0 if still in cliff). */ - effectiveClaimable: bigint; - /** Total amount that vests over the full duration in stroops. */ - totalAmount: bigint; - /** Unix timestamp when the cliff period ends. */ - cliffEndTime: number; - /** Whether we are still in the cliff period. */ - inCliff: boolean; - /** Schedule milestones for UI display (cliff, 25%, 50%, 75%, 100%). */ - milestones: VestingSchedulePoint[]; -} - -// ── Issue #148: Recipient change notification ──────────────────────────────── - -/** Payload delivered to an {@link onRecipientChanged} callback. */ -export interface RecipientChangedEvent { - streamId: string; - oldRecipient: string; - newRecipient: string; - timestamp: number; -} - -/** Options for {@link SoroStreamClient.onRecipientChanged}. */ -export interface OnRecipientChangedOptions { - /** Polling interval in ms (default: 5000). */ - intervalMs?: number; -} - -/** Options for {@link watchClaimable}. */ -export interface WatchClaimableOptions { - /** Interval in ms between interpolation ticks (default: 200). */ - tickMs?: number; - /** Interval in ms between on-chain reconciliations (default: 5000). */ - reconcileMs?: number; - /** - * WebSocket URL for real-time claimable updates. - * When provided, the watcher will subscribe via WS and fall back to - * polling-based interpolation if the WS connection fails. - */ - wsUrl?: string; - /** - * The stream ID to subscribe to for WS updates. - * Required when `wsUrl` is set. - */ - wsStreamId?: string; -} - - -/** Wallet adapter interface. Implement this to support custom signing backends. */ -export interface WalletAdapter { - getPublicKey(): Promise; - signTransaction(xdr: string, network: Network): Promise; - isConnected(): Promise; -} - -/** A single row for bulk stream creation. */ -export interface BulkStreamRow { - recipient: string; - amount: bigint; - durationSeconds: number; - /** Optional per-row token override. Falls back to BulkCreateOptions.token when omitted. */ - token?: string; - /** Optional cliff duration in seconds for this row (issue #74). Defaults to 0. */ - cliffSeconds?: number; -} - -/** Options for bulkCreateStreams. */ -export interface BulkCreateOptions { - /** SAC token contract address. Applied as default when a row omits `token`. */ - token: string; - /** Whether auto-renew is enabled (default false). */ - autoRenew?: boolean; - /** Max operations per transaction (default 8). */ - batchSize?: number; -} - -/** Result of one batch within a bulk create. */ -export interface BulkCreateBatchResult { - txHash: string; - streamIds: string[]; - rows: BulkStreamRow[]; -} - -/** Full result of bulkCreateStreams. */ -export interface BulkCreateResult { - batches: BulkCreateBatchResult[]; -} - -/** Result of one transaction within a batchWithdraw call. */ -export interface BatchWithdrawResult { - txHash: string; - streamIds: string[]; - amounts: string[]; -} - -/** Per-token aggregate of a set of streams. */ -export interface TokenAggregate { - token: string; - streamCount: number; - deposited: bigint; - claimable: bigint; - claimedSoFar: bigint; -} - -// ── Issue #44: Locale-aware formatUSDC ─────────────────────────────────────── - -/** Options for locale-aware {@link formatUSDC} formatting. */ -export interface FormatUSDCOptions { - /** BCP 47 locale string (e.g. "en-US", "de-DE"). */ - locale?: string; - /** Maximum decimal digits to display. */ - maximumFractionDigits?: number; - /** Minimum decimal digits to display. */ - minimumFractionDigits?: number; - /** Whether to use grouping separators (e.g. commas in en-US). Default: true. */ - useGrouping?: boolean; -} - -// ── Issue #47: Cache reconciliation / drift detection ──────────────────────── - -/** A single field that differs between cached and on-chain stream state. */ -export interface StreamDrift { - field: keyof Stream; - cached: unknown; - onChain: unknown; -} - -/** Options for {@link watchStreamDrift}. */ -export interface ReconcileStreamOptions { - /** Interval in ms between on-chain reconciliation checks (default: 30000). */ - intervalMs?: number; -} - -// ── Issue #46: WebAuthn passkey adapter ───────────────────────────────────── - -/** Configuration for a WebAuthn/passkey-based Soroban smart wallet adapter. */ -export interface PasskeyAdapterConfig { - /** Deployed smart wallet contract address (becomes the wallet's public key). */ - contractId: string; - /** WebAuthn relying party ID (e.g. "example.com"). */ - rpId: string; - /** - * The credential ID of the registered passkey (ArrayBuffer from credential.rawId). - * Required — without it the browser may select the wrong passkey silently. - */ - credentialId: ArrayBuffer; -} - -// ── Price feed adapter (#Issue 1) ──────────────────────────────────────────── - -/** - * Pluggable adapter for converting token amounts to fiat display values. - * Implement this to back formatToken/toFiatDisplay with a price oracle or API. - */ -export interface PriceFeedAdapter { - /** - * Returns the price of one unit of the given token in the display currency. - * @param tokenAddress - The token contract address (e.g. SAC address). - * @param displayCurrency - Target currency code (default: "usd"). - * @returns Price per token unit in the display currency. - */ - getPrice(tokenAddress: string, displayCurrency?: string): Promise; -} - -// ── Split stream types ─────────────────────────────────────────────────────── - -/** Parameters for splitting an active stream into two streams. */ -export interface SplitStreamParams { - /** Stream ID to split. */ - streamId: string; - /** Numerator of the split ratio (e.g. 70 for a 70/30 split). */ - ratioNumerator: number; - /** Denominator of the split ratio (e.g. 100 for 70/100 = 70%). */ - ratioDenominator: number; - /** First destination address for the split stream. */ - recipientA: string; - /** Second destination address for the split stream. */ - recipientB: string; -} - -/** Result of splitting a stream. */ -export interface SplitStreamResult { - /** Transaction hash of the split operation. */ - txHash: string; - /** Stream ID for the first split stream. */ - streamIdA: string; - /** Stream ID for the second split stream. */ - streamIdB: string; -} - -// ── Fee bump types (#Issue 3) ──────────────────────────────────────────────── - -/** - * Options for wrapping a transaction in a Stellar fee-bump. - * Allows an app operator to cover network fees on behalf of end users. - */ -export interface FeeBumpOptions { - /** The Stellar address of the account paying network fees. */ - sponsorAddress: string; - /** Wallet adapter for signing the fee-bump envelope. */ - sponsorAdapter: WalletAdapter; - /** Maximum fee in stroops the sponsor is willing to pay. */ - maxFee?: number; -} - -// ── Write options ──────────────────────────────────────────────────────────── - -/** Options for write operations (create, withdraw, cancel, top-up). */ -export interface WriteOptions { - /** If true, simulate only without submitting. */ - simulateOnly?: boolean; - /** Override fee-bump for this specific transaction. */ - feeBump?: FeeBumpOptions; -} - -// ── Contract versioning (#Issue 4) ─────────────────────────────────────────── - -/** Supported contract versions for call encoding. */ -export type ContractVersion = "v1" | "v2"; - -// ── Dashboard / reporting aggregate types ──────────────────────────────────── - -/** Aggregate totals across a set of streams. */ -export interface StreamTotals { - /** Total number of streams. */ - totalStreams: number; - /** Sum of all deposits in stroops. */ - totalDeposited: bigint; - /** Sum of all claimable amounts in stroops (estimated). */ - totalClaimable: bigint; - /** Sum of all claimed amounts in stroops. */ - totalClaimed: bigint; - /** Sum of all remaining deposits in stroops. */ - totalRemaining: bigint; -} - -/** Per-status breakdown of a set of streams. */ -export interface StatusBreakdown { - /** Streams with status "Active". */ - active: number; - /** Streams with status "Cancelled". */ - cancelled: number; - /** Streams with status "Completed". */ - completed: number; -} - -/** Duration statistics for a set of streams. */ -export interface DurationStats { - /** Average duration in seconds. */ - average: number; - /** Minimum duration in seconds. */ - min: number; - /** Maximum duration in seconds. */ - max: number; - /** Median duration in seconds. */ - median: number; -} - -/** Summary of stream health issues. */ -export interface StreamHealthReport { - /** Number of active streams expiring within the threshold. */ - expiring: number; - /** Number of active streams that have stalled. */ - stalled: number; - /** Number of active streams that are underfunded. */ - underfunded: number; - /** Total active streams checked. */ - totalActive: number; -} - -/** Per-recipient aggregate of a set of streams. */ -export interface RecipientAggregate { - /** Recipient address. */ - recipient: string; - /** Number of streams targeting this recipient. */ - streamCount: number; - /** Total deposited in stroops. */ - deposited: bigint; - /** Estimated claimable amount in stroops. */ - claimable: bigint; - /** Total claimed so far in stroops. */ - claimedSoFar: bigint; -} - -// ── Stream filtering ──────────────────────────────────────────────────────── - -/** Criteria for filtering streams. */ -export interface StreamFilterCriteria { - sender?: string; - recipient?: string; - token?: string; - status?: StreamStatus; -} - -// ── Issue #166: Stream activity log ───────────────────────────────────────── - -/** The type of activity recorded in a stream's activity log. */ -export type StreamActivityType = - | "StreamCreated" - | "StreamWithdrawn" - | "StreamCancelled" - | "StreamToppedUp"; - -/** A single entry in a stream's on-chain activity log. */ -export interface StreamActivityEntry { - /** Type of on-chain event. */ - type: StreamActivityType; - /** Unix timestamp (ms) of the ledger close. */ - timestamp: number; - /** Token amount involved, in stroops. `0n` for events with no amount. */ - amount: bigint; - /** Transaction hash that emitted this event. */ - txHash: string; - /** Raw ledger number. */ - ledger: number; -} - -/** Options for {@link SoroStreamClient.getActivityLog}. */ -export interface GetActivityLogOptions { - /** Only return entries at or after this Unix timestamp (ms). */ - from?: number; - /** Only return entries at or before this Unix timestamp (ms). */ - to?: number; - /** Max number of entries to return per page (default 100). */ - limit?: number; - /** Cursor from a previous page for continuation. */ - cursor?: string; -} - -// ── Issue #73: Stream snapshot export/import ───────────────────────────────── - -/** A history entry recording a past event on a stream. */ -export interface StreamHistoryEntry { - type: string; - timestamp: number; - txHash: string; - data?: Record; -} - -/** A projected vesting milestone used in the snapshot. */ -export interface SnapshotVestingPoint { - time: number; - vested: string; // serialised as string to survive JSON bigint round-trip -} - -/** A serialisable snapshot of a stream at a point in time. */ -export interface StreamSnapshot { - /** Snapshot schema version. */ - version: 1; - /** Unix timestamp (ms) when the snapshot was taken. */ - exportedAt: number; - /** The full stream parameter set. */ - stream: Omit & { - deposit: string; - flowRate: string; - }; - /** Current claimable amount at snapshot time, serialised as string. */ - claimableAtExport: string; - /** Projected vesting curve milestones. */ - vestingProjection: SnapshotVestingPoint[]; - /** Recorded event history (may be empty when history is unavailable). */ - history: StreamHistoryEntry[]; -} - -// ── Issue #50: Middleware / plugin system ──────────────────────────────────── - -/** Context passed to every middleware hook. */ -export interface MiddlewareContext { - /** Name of the client method being called (e.g. "createStream"). */ - method: string; - /** Arguments passed to the method. */ - args: unknown[]; -} - -/** A middleware plugin that can observe or intercept client calls. */ -export interface SoroStreamPlugin { - /** - * Called before the client method executes. - * Throw to prevent the call from proceeding. - */ - before?(ctx: MiddlewareContext): void | Promise; - /** - * Called after the client method resolves successfully. - * `result` is the return value of the method. - */ - after?(ctx: MiddlewareContext, result: unknown): void | Promise; - /** - * Called when the client method throws. - * Re-throwing replaces the original error; returning swallows it. - */ - onError?(ctx: MiddlewareContext, error: unknown): void | Promise; -} +/** Status of a payment stream. */ +export type StreamStatus = "Active" | "Cancelled" | "Completed" | "Paused"; + +// ── Event types (#1) ───────────────────────────────────────────────────────── + +export type StreamEventType = + | "StreamCreated" + | "StreamWithdrawn" + | "StreamCancelled" + | "StreamCompleted" + | "StreamToppedUp" + | "StreamPaused" + | "StreamResumed" + | "StreamTransferred"; + +export interface StreamEvent { + type: StreamEventType; + streamId: string; + txHash: string; + ledger: number; + timestamp: number; + data: Record; +} + +export interface StreamSubscription { + unsubscribe(): void; +} + +export interface StreamEventFilter { + streamId?: string; + sender?: string; + recipient?: string; +} + +// ── Pagination types (#3) ──────────────────────────────────────────────────── + +export interface PaginationParams { + limit?: number; + cursor?: string; +} + +export interface PaginatedStreams { + streams: Stream[]; + cursor: string | null; + hasMore: boolean; +} + +// ── Multisig types (#16) ───────────────────────────────────────────────────── + +export interface MultisigSigner { + signTransaction(xdr: string, network: Network): Promise; +} + +// ── Webhook types (#22) ────────────────────────────────────────────────────── + +export interface WebhookConfig { + url: string; + headers?: Record; + retries?: number; + retryDelayMs?: number; +} + +/** A single payment stream as returned by the contract. */ +export interface Stream { + /** Unique stream identifier. */ + id: string; + /** Address of the stream creator / payer. */ + sender: string; + /** Address of the stream beneficiary. */ + recipient: string; + /** SAC token contract address (e.g. USDC). */ + token: string; + /** Total token deposit locked in stroops. */ + deposit: bigint; + /** Tokens released per second in stroops. */ + flowRate: bigint; + /** Unix timestamp (seconds) when the stream started. */ + startTime: number; + /** Unix timestamp (seconds) when the stream ends. */ + endTime: number; + /** Unix timestamp (seconds) of the last withdrawal. */ + lastWithdrawTime: number; + /** Current stream status. */ + status: StreamStatus; + /** Whether the stream auto-renews on completion. */ + autoRenew: boolean; + /** Unix timestamp (seconds) when the stream was paused (undefined if not paused). */ + pausedAt?: number; +} + +/** Parameters for creating a new stream. */ +export interface CreateStreamParams { + /** Beneficiary address. */ + recipient: string; + /** SAC token contract address. */ + token: string; + /** Total amount to stream in stroops. */ + amount: bigint; + /** Stream duration in seconds. */ + durationSeconds: number; + /** Whether to auto-renew on completion. */ + autoRenew: boolean; + /** Opt-in check for duplicate stream creation. */ + checkDuplicate?: boolean; + /** + * Cliff duration in seconds (issue #74). + * When set, the configured `validateCliff` function is called before + * the transaction is submitted. Defaults to 0 (no cliff). + */ + cliffSeconds?: number; + /** + * Skip the token allowance pre-flight check (issue #165). + * Set to true when you have already approved the contract or the token + * does not expose an allowance view (e.g. native XLM). + */ + skipAllowanceCheck?: boolean; +} + +/** Alias for a single stream creation params object. */ +export type CreateStreamsParams = CreateStreamParams; + +/** Parameters for withdrawing from a stream. */ +export interface WithdrawParams { + /** Stream ID to withdraw from. */ + streamId: string; +} + +/** Parameters for cancelling a stream. */ +export interface CancelStreamParams { + /** Stream ID to cancel. */ + streamId: string; +} + +/** Parameters for topping up a stream. */ +export interface TopUpParams { + /** Stream ID to top up. */ + streamId: string; + /** Additional amount to add in stroops. */ + amount: bigint; +} + +/** Network configuration. */ +export type Network = "mainnet" | "testnet" | "futurenet"; + +/** Fee estimate returned by prepareTransaction. */ +export interface FeeEstimate { + /** Total fee in stroops (base fee + min resource fee). */ + totalFee: number; + /** Soroban resource fee in stroops. */ + minResourceFee: number; +} + +/** Result of batch cancellation. */ +export interface BatchCancelResult { + txHash: string; + streamIds: string[]; +} + +/** Parameters for updating a stream's flow rate. */ +export interface UpdateFlowRateParams { + streamId: string; + newFlowRate: bigint; +} + +/** Parameters for setting an operator on a stream. */ +export interface SetOperatorParams { + streamId: string; + operator: string; + approved: boolean; +} + +/** Parameters for an operator to top up a stream. */ +export interface OperatorTopUpParams { + streamId: string; + amount: bigint; +} + +/** Parameters for transferring a stream to a new recipient. */ +export interface TransferStreamParams { + streamId: string; + newRecipient: string; +} + +/** Parameters for pausing a stream. */ +export interface PauseStreamParams { + streamId: string; +} + +/** Parameters for resuming a paused stream. */ +export interface ResumeStreamParams { + streamId: string; +} + +/** A single milestone point in a vesting schedule. */ +export interface VestingSchedulePoint { + /** Unix timestamp of the milestone. */ + time: number; + /** Amount vested in stroops at this point. */ + vested: bigint; +} + +/** Result of a display-only vesting schedule calculation. */ +export interface VestingScheduleResult { + /** Effective claimable amount right now in stroops (0 if still in cliff). */ + effectiveClaimable: bigint; + /** Total amount that vests over the full duration in stroops. */ + totalAmount: bigint; + /** Unix timestamp when the cliff period ends. */ + cliffEndTime: number; + /** Whether we are still in the cliff period. */ + inCliff: boolean; + /** Schedule milestones for UI display (cliff, 25%, 50%, 75%, 100%). */ + milestones: VestingSchedulePoint[]; +} + +// ── Issue #148: Recipient change notification ──────────────────────────────── + +/** Payload delivered to an {@link onRecipientChanged} callback. */ +export interface RecipientChangedEvent { + streamId: string; + oldRecipient: string; + newRecipient: string; + timestamp: number; +} + +/** Options for {@link SoroStreamClient.onRecipientChanged}. */ +export interface OnRecipientChangedOptions { + /** Polling interval in ms (default: 5000). */ + intervalMs?: number; +} + +/** Options for {@link watchClaimable}. */ +export interface WatchClaimableOptions { + /** Interval in ms between interpolation ticks (default: 200). */ + tickMs?: number; + /** Interval in ms between on-chain reconciliations (default: 5000). */ + reconcileMs?: number; + /** + * WebSocket URL for real-time claimable updates. + * When provided, the watcher will subscribe via WS and fall back to + * polling-based interpolation if the WS connection fails. + */ + wsUrl?: string; + /** + * The stream ID to subscribe to for WS updates. + * Required when `wsUrl` is set. + */ + wsStreamId?: string; +} + + +/** + * Wallet adapter interface. Implement this to support custom signing backends. + * + * @example + * ```ts + * import { Keypair, TransactionBuilder, Networks } from "@stellar/stellar-sdk"; + * import type { WalletAdapter, Network } from "@sorostream/sdk"; + * + * const serverKeypairAdapter: WalletAdapter = { + * async isConnected() { + * return true; + * }, + * async getPublicKey() { + * return Keypair.fromSecret(process.env.STELLAR_SECRET!).publicKey(); + * }, + * async signTransaction(xdr, network) { + * const passphrase = network === "testnet" + * ? Networks.TESTNET + * : network === "futurenet" + * ? Networks.FUTURENET + * : Networks.PUBLIC; + * const tx = TransactionBuilder.fromXDR(xdr, passphrase); + * tx.sign(Keypair.fromSecret(process.env.STELLAR_SECRET!)); + * return tx.toEnvelope().toXDR("base64"); + * }, + * }; + * ``` + */ +export interface WalletAdapter { + getPublicKey(): Promise; + signTransaction(xdr: string, network: Network): Promise; + isConnected(): Promise; +} + +/** A single row for bulk stream creation. */ +export interface BulkStreamRow { + recipient: string; + amount: bigint; + durationSeconds: number; + /** Optional per-row token override. Falls back to BulkCreateOptions.token when omitted. */ + token?: string; + /** Optional cliff duration in seconds for this row (issue #74). Defaults to 0. */ + cliffSeconds?: number; +} + +/** Options for bulkCreateStreams. */ +export interface BulkCreateOptions { + /** SAC token contract address. Applied as default when a row omits `token`. */ + token: string; + /** Whether auto-renew is enabled (default false). */ + autoRenew?: boolean; + /** Max operations per transaction (default 8). */ + batchSize?: number; +} + +/** Result of one batch within a bulk create. */ +export interface BulkCreateBatchResult { + txHash: string; + streamIds: string[]; + rows: BulkStreamRow[]; +} + +/** Full result of bulkCreateStreams. */ +export interface BulkCreateResult { + batches: BulkCreateBatchResult[]; +} + +/** Result of one transaction within a batchWithdraw call. */ +export interface BatchWithdrawResult { + txHash: string; + streamIds: string[]; + amounts: string[]; +} + +/** Per-token aggregate of a set of streams. */ +export interface TokenAggregate { + token: string; + streamCount: number; + deposited: bigint; + claimable: bigint; + claimedSoFar: bigint; +} + +// ── Issue #44: Locale-aware formatUSDC ─────────────────────────────────────── + +/** Options for locale-aware {@link formatUSDC} formatting. */ +export interface FormatUSDCOptions { + /** BCP 47 locale string (e.g. "en-US", "de-DE"). */ + locale?: string; + /** Maximum decimal digits to display. */ + maximumFractionDigits?: number; + /** Minimum decimal digits to display. */ + minimumFractionDigits?: number; + /** Whether to use grouping separators (e.g. commas in en-US). Default: true. */ + useGrouping?: boolean; +} + +// ── Issue #47: Cache reconciliation / drift detection ──────────────────────── + +/** A single field that differs between cached and on-chain stream state. */ +export interface StreamDrift { + field: keyof Stream; + cached: unknown; + onChain: unknown; +} + +/** Options for {@link watchStreamDrift}. */ +export interface ReconcileStreamOptions { + /** Interval in ms between on-chain reconciliation checks (default: 30000). */ + intervalMs?: number; +} + +// ── Issue #46: WebAuthn passkey adapter ───────────────────────────────────── + +/** Configuration for a WebAuthn/passkey-based Soroban smart wallet adapter. */ +export interface PasskeyAdapterConfig { + /** Deployed smart wallet contract address (becomes the wallet's public key). */ + contractId: string; + /** WebAuthn relying party ID (e.g. "example.com"). */ + rpId: string; + /** + * The credential ID of the registered passkey (ArrayBuffer from credential.rawId). + * Required — without it the browser may select the wrong passkey silently. + */ + credentialId: ArrayBuffer; +} + +// ── Price feed adapter (#Issue 1) ──────────────────────────────────────────── + +/** + * Pluggable adapter for converting token amounts to fiat display values. + * Implement this to back formatToken/toFiatDisplay with a price oracle or API. + */ +export interface PriceFeedAdapter { + /** + * Returns the price of one unit of the given token in the display currency. + * @param tokenAddress - The token contract address (e.g. SAC address). + * @param displayCurrency - Target currency code (default: "usd"). + * @returns Price per token unit in the display currency. + */ + getPrice(tokenAddress: string, displayCurrency?: string): Promise; +} + +// ── Split stream types ─────────────────────────────────────────────────────── + +/** Parameters for splitting an active stream into two streams. */ +export interface SplitStreamParams { + /** Stream ID to split. */ + streamId: string; + /** Numerator of the split ratio (e.g. 70 for a 70/30 split). */ + ratioNumerator: number; + /** Denominator of the split ratio (e.g. 100 for 70/100 = 70%). */ + ratioDenominator: number; + /** First destination address for the split stream. */ + recipientA: string; + /** Second destination address for the split stream. */ + recipientB: string; +} + +/** Result of splitting a stream. */ +export interface SplitStreamResult { + /** Transaction hash of the split operation. */ + txHash: string; + /** Stream ID for the first split stream. */ + streamIdA: string; + /** Stream ID for the second split stream. */ + streamIdB: string; +} + +// ── Fee bump types (#Issue 3) ──────────────────────────────────────────────── + +/** + * Options for wrapping a transaction in a Stellar fee-bump. + * Allows an app operator to cover network fees on behalf of end users. + */ +export interface FeeBumpOptions { + /** The Stellar address of the account paying network fees. */ + sponsorAddress: string; + /** Wallet adapter for signing the fee-bump envelope. */ + sponsorAdapter: WalletAdapter; + /** Maximum fee in stroops the sponsor is willing to pay. */ + maxFee?: number; +} + +// ── Write options ──────────────────────────────────────────────────────────── + +/** Options for write operations (create, withdraw, cancel, top-up). */ +export interface WriteOptions { + /** If true, simulate only without submitting. */ + simulateOnly?: boolean; + /** Override fee-bump for this specific transaction. */ + feeBump?: FeeBumpOptions; +} + +// ── Contract versioning (#Issue 4) ─────────────────────────────────────────── + +/** Supported contract versions for call encoding. */ +export type ContractVersion = "v1" | "v2"; + +// ── Dashboard / reporting aggregate types ──────────────────────────────────── + +/** Aggregate totals across a set of streams. */ +export interface StreamTotals { + /** Total number of streams. */ + totalStreams: number; + /** Sum of all deposits in stroops. */ + totalDeposited: bigint; + /** Sum of all claimable amounts in stroops (estimated). */ + totalClaimable: bigint; + /** Sum of all claimed amounts in stroops. */ + totalClaimed: bigint; + /** Sum of all remaining deposits in stroops. */ + totalRemaining: bigint; +} + +/** Per-status breakdown of a set of streams. */ +export interface StatusBreakdown { + /** Streams with status "Active". */ + active: number; + /** Streams with status "Cancelled". */ + cancelled: number; + /** Streams with status "Completed". */ + completed: number; +} + +/** Duration statistics for a set of streams. */ +export interface DurationStats { + /** Average duration in seconds. */ + average: number; + /** Minimum duration in seconds. */ + min: number; + /** Maximum duration in seconds. */ + max: number; + /** Median duration in seconds. */ + median: number; +} + +/** Summary of stream health issues. */ +export interface StreamHealthReport { + /** Number of active streams expiring within the threshold. */ + expiring: number; + /** Number of active streams that have stalled. */ + stalled: number; + /** Number of active streams that are underfunded. */ + underfunded: number; + /** Total active streams checked. */ + totalActive: number; +} + +/** Per-recipient aggregate of a set of streams. */ +export interface RecipientAggregate { + /** Recipient address. */ + recipient: string; + /** Number of streams targeting this recipient. */ + streamCount: number; + /** Total deposited in stroops. */ + deposited: bigint; + /** Estimated claimable amount in stroops. */ + claimable: bigint; + /** Total claimed so far in stroops. */ + claimedSoFar: bigint; +} + +// ── Stream filtering ──────────────────────────────────────────────────────── + +/** Criteria for filtering streams. */ +export interface StreamFilterCriteria { + sender?: string; + recipient?: string; + token?: string; + status?: StreamStatus; +} + +// ── Issue #166: Stream activity log ───────────────────────────────────────── + +/** The type of activity recorded in a stream's activity log. */ +export type StreamActivityType = + | "StreamCreated" + | "StreamWithdrawn" + | "StreamCancelled" + | "StreamToppedUp"; + +/** A single entry in a stream's on-chain activity log. */ +export interface StreamActivityEntry { + /** Type of on-chain event. */ + type: StreamActivityType; + /** Unix timestamp (ms) of the ledger close. */ + timestamp: number; + /** Token amount involved, in stroops. `0n` for events with no amount. */ + amount: bigint; + /** Transaction hash that emitted this event. */ + txHash: string; + /** Raw ledger number. */ + ledger: number; +} + +/** Options for {@link SoroStreamClient.getActivityLog}. */ +export interface GetActivityLogOptions { + /** Only return entries at or after this Unix timestamp (ms). */ + from?: number; + /** Only return entries at or before this Unix timestamp (ms). */ + to?: number; + /** Max number of entries to return per page (default 100). */ + limit?: number; + /** Cursor from a previous page for continuation. */ + cursor?: string; +} + +// ── Issue #73: Stream snapshot export/import ───────────────────────────────── + +/** A history entry recording a past event on a stream. */ +export interface StreamHistoryEntry { + type: string; + timestamp: number; + txHash: string; + data?: Record; +} + +/** A projected vesting milestone used in the snapshot. */ +export interface SnapshotVestingPoint { + time: number; + vested: string; // serialised as string to survive JSON bigint round-trip +} + +/** A serialisable snapshot of a stream at a point in time. */ +export interface StreamSnapshot { + /** Snapshot schema version. */ + version: 1; + /** Unix timestamp (ms) when the snapshot was taken. */ + exportedAt: number; + /** The full stream parameter set. */ + stream: Omit & { + deposit: string; + flowRate: string; + }; + /** Current claimable amount at snapshot time, serialised as string. */ + claimableAtExport: string; + /** Projected vesting curve milestones. */ + vestingProjection: SnapshotVestingPoint[]; + /** Recorded event history (may be empty when history is unavailable). */ + history: StreamHistoryEntry[]; +} + +// ── Issue #50: Middleware / plugin system ──────────────────────────────────── + +/** Context passed to every middleware hook. */ +export interface MiddlewareContext { + /** Name of the client method being called (e.g. "createStream"). */ + method: string; + /** Arguments passed to the method. */ + args: unknown[]; +} + +/** A middleware plugin that can observe or intercept client calls. */ +export interface SoroStreamPlugin { + /** + * Called before the client method executes. + * Throw to prevent the call from proceeding. + */ + before?(ctx: MiddlewareContext): void | Promise; + /** + * Called after the client method resolves successfully. + * `result` is the return value of the method. + */ + after?(ctx: MiddlewareContext, result: unknown): void | Promise; + /** + * Called when the client method throws. + * Re-throwing replaces the original error; returning swallows it. + */ + onError?(ctx: MiddlewareContext, error: unknown): void | Promise; +} diff --git a/src/wallet.ts b/src/wallet.ts index 2c5def6..c5d8fc2 100644 --- a/src/wallet.ts +++ b/src/wallet.ts @@ -1,413 +1,411 @@ -import { Keypair, TransactionBuilder, xdr, hash, nativeToScVal } from "@stellar/stellar-sdk"; -import type { WalletAdapter, Network, MultisigSigner, PasskeyAdapterConfig } from "./types.js"; - -/** - * Configuration for a claim-delegation adapter. - * - * The pattern lets an automated "claim bot" key call `withdraw` on behalf of the - * recipient without ever holding the recipient's primary key: - * - * 1. On-chain: add the bot key as a co-signer on the recipient's Stellar account - * with a weight that meets the low-security threshold (e.g. weight 1 on a 1-of-N - * multisig). The primary key retains sole control over high-security operations. - * 2. In the SDK: pass the recipient address as `recipientAddress` and the bot's - * {@link MultisigSigner} as `claimBotSigner`. - * - * The resulting adapter always presents `recipientAddress` to `getPublicKey()` - * (so `withdraw` receives the correct recipient auth), but the transaction envelope - * is signed exclusively by the claim bot key. - * - * @example - * ```ts - * // Bot key loaded from env — never has custody of the recipient address. - * const botSigner: MultisigSigner = { - * async signTransaction(xdr, network) { - * const kp = Keypair.fromSecret(process.env.CLAIM_BOT_SECRET!); - * const tx = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); - * tx.sign(kp); - * return tx.toEnvelope().toXDR("base64"); - * }, - * }; - * - * const adapter = createClaimDelegateAdapter({ - * recipientAddress: "GRECIPI...", - * claimBotSigner: botSigner, - * }); - * - * const client = new SoroStreamClient({ network: "testnet", contractId, walletAdapter: adapter }); - * await client.withdraw({ streamId }); - * ``` - */ -export interface ClaimDelegateConfig { - /** The actual recipient address (passed to `require_auth` on-chain). */ - recipientAddress: string; - /** A signer representing the claim bot key. */ - claimBotSigner: MultisigSigner; -} - -const NETWORK_PASSPHRASES: Record = { - mainnet: "Public Global Stellar Network ; September 2015", - testnet: "Test SDF Network ; September 2015", - futurenet: "Test SDF Future Network ; October 2022", -}; - -/** - * Creates a WalletAdapter backed by the Freighter browser extension. - * Dynamically imports @stellar/freighter-api to avoid SSR issues. - */ -export async function createFreighterAdapter(): Promise { - const freighter = await import("@stellar/freighter-api"); - - return { - async isConnected(): Promise { - const result = await freighter.isConnected(); - return result.isConnected; - }, - - async getPublicKey(): Promise { - const result = await freighter.getAddress(); - if (result.error) throw new Error(result.error.message); - return result.address; - }, - - async signTransaction(xdrStr: string, network: Network): Promise { - const result = await freighter.signTransaction(xdrStr, { - networkPassphrase: NETWORK_PASSPHRASES[network], - }); - if (result.error) throw new Error(result.error.message); - return result.signedTxXdr; - }, - }; -} - -/** - * Creates a server-side WalletAdapter that signs directly with a Stellar Keypair. - * Suitable for Node.js scripts, backends, and automated payouts. - * - * @param secretKey - The Stellar secret key (base-32 encoded seed starting with "S"). - * - * @example - * ```ts - * const adapter = createKeypairAdapter("SAZ...YOUR...SECRET...KEY..."); - * const client = new SoroStreamClient({ network: "testnet", contractId: "...", walletAdapter: adapter }); - * ``` - */ -export function createKeypairAdapter(secretKey: string): WalletAdapter { - const keypair = Keypair.fromSecret(secretKey); - - return { - async isConnected(): Promise { - return true; - }, - - async getPublicKey(): Promise { - return keypair.publicKey(); - }, - - async signTransaction(xdrStr: string, network: Network): Promise { - const tx = TransactionBuilder.fromXDR( - xdrStr, - NETWORK_PASSPHRASES[network] - ); - tx.sign(keypair); - return tx.toEnvelope().toXDR("base64"); - }, - }; -} - -/** - * Prompts the user to connect their Freighter wallet. - * Throws if Freighter is not installed or the user rejects. - */ -export async function connectWallet(): Promise { - const freighter = await import("@stellar/freighter-api"); - const connected = await freighter.isConnected(); - if (!connected.isConnected) { - throw new Error("Freighter extension is not installed"); - } - const result = await freighter.getAddress(); - if (result.error) throw new Error(result.error.message); - return result.address; -} - -/** - * Creates a {@link WalletAdapter} that presents the recipient's address to the - * contract but signs transactions with a separate claim-bot key. - * - * The bot key must be a co-signer on the recipient's Stellar account (classic - * multisig) so that Soroban's `require_auth` accepts its signature for `withdraw`. - * - * This enables automated claiming daemons that never hold the recipient's primary - * secret key. See {@link ClaimDelegateConfig} for the full setup guide. - */ -export function createClaimDelegateAdapter( - config: ClaimDelegateConfig -): WalletAdapter { - return { - async isConnected(): Promise { - return true; - }, - - async getPublicKey(): Promise { - return config.recipientAddress; - }, - - async signTransaction(xdr: string, network: Network): Promise { - return config.claimBotSigner.signTransaction(xdr, network); - }, - }; -} - -/** - * Creates a WalletAdapter for a multi-sig Stellar account. - * - * The adapter collects signatures from each signer and combines them into - * a single transaction envelope before submission. - * - * @param config.address - The multisig source account address. - * @param config.signers - Array of signers that each independently sign the tx. - * @param config.threshold - Optional minimum number of signatures required - * (defaults to `signers.length`, i.e. all must sign). - */ -export async function createMultisigAdapter(config: { - address: string; - signers: MultisigSigner[]; - threshold?: number; -}): Promise { - const threshold = config.threshold ?? config.signers.length; - - return { - async isConnected(): Promise { - return true; - }, - - async getPublicKey(): Promise { - return config.address; - }, - - async signTransaction(xdrStr: string, network: Network): Promise { - const passphrase = NETWORK_PASSPHRASES[network]; - - let combined: ReturnType | null = null; - let collected = 0; - const seen = new Set(); - - for (const signer of config.signers) { - if (collected >= threshold) break; - - const signedXdr = await signer.signTransaction(xdrStr, network); - const tx = TransactionBuilder.fromXDR(signedXdr, passphrase); - - for (const sig of tx.signatures) { - const key = - sig.hint().toString("base64") + - sig.signature().toString("base64"); - if (!seen.has(key)) { - seen.add(key); - if (!combined) { - combined = TransactionBuilder.fromXDR(xdrStr, passphrase); - } - combined.signatures.push(sig); - collected++; - } - } - } - - if (!combined) { - throw new Error("No signatures were collected"); - } - - return combined.toEnvelope().toXDR("base64"); - }, - }; -} - -// ── Issue #46: WebAuthn passkey adapter ────────────────────────────────────── - -/** - * Converts a DER-encoded P-256 ECDSA signature to compact (r || s) form. - * DER format: 0x30 0x02 0x02 - */ -function derToCompact(der: Uint8Array): Uint8Array { - let offset = 0; - if (der[offset++] !== 0x30) throw new Error("Invalid DER signature: expected 0x30"); - offset++; // skip total length byte - if (der[offset++] !== 0x02) throw new Error("Invalid DER signature: expected 0x02 for r"); - const rLen = der[offset++]; - if (rLen === undefined) throw new Error("Invalid DER signature: truncated r length"); - const r = der.slice(offset, offset + rLen); - offset += rLen; - if (der[offset++] !== 0x02) throw new Error("Invalid DER signature: expected 0x02 for s"); - const sLen = der[offset++]; - if (sLen === undefined) throw new Error("Invalid DER signature: truncated s length"); - const s = der.slice(offset, offset + sLen); - - const compact = new Uint8Array(64); - // r and s may have a leading 0x00 padding byte; trim and right-align to 32 bytes - const rBytes = r[0] === 0 ? r.slice(1) : r; - const sBytes = s[0] === 0 ? s.slice(1) : s; - compact.set(rBytes, 32 - rBytes.length); - compact.set(sBytes, 64 - sBytes.length); - return compact; -} - -/** - * Creates a WalletAdapter for a Soroban smart-wallet contract that is - * authenticated via WebAuthn/passkeys rather than a classic Ed25519 keypair. - * - * The adapter signs each `invokeHostFunction` auth entry by: - * 1. Computing the Soroban contract-auth signing challenge (SHA-256 of the - * `HashIdPreimageSorobanAuthorization` XDR). - * 2. Requesting a WebAuthn assertion from the registered passkey. - * 3. Attaching the response (`authenticator_data`, `client_data_json`, - * compact `signature`) as a ScVal map in the auth entry credentials. - * - * This follows the Soroban Passkey Kit signature format expected by the - * `__check_auth` function on standard Soroban smart wallet contracts. - * - * **Requirements:** Must be called in a browser environment with WebAuthn - * support. The contract must already be deployed. - * - * @param config - Passkey adapter configuration. - * - * @example - * ```ts - * const adapter = await createPasskeyAdapter({ - * contractId: "CA...", - * rpId: "myapp.example.com", - * credentialId: myCredentialIdArrayBuffer, - * }); - * const client = new SoroStreamClient({ network: "testnet", contractId: "...", walletAdapter: adapter }); - * ``` - */ -export async function createPasskeyAdapter( - config: PasskeyAdapterConfig -): Promise { - if ( - typeof window === "undefined" || - !("credentials" in navigator) || - !("PublicKeyCredential" in window) - ) { - throw new Error("WebAuthn is not available in this environment"); - } - - return { - async isConnected(): Promise { - return ( - typeof window !== "undefined" && - "credentials" in navigator && - "PublicKeyCredential" in window - ); - }, - - async getPublicKey(): Promise { - return config.contractId; - }, - - async signTransaction(xdrStr: string, network: Network): Promise { - const passphrase = NETWORK_PASSPHRASES[network]; - - // Parse the raw transaction envelope so we can read and mutate auth entries - const txEnvelope = xdr.TransactionEnvelope.fromXDR(xdrStr, "base64"); - const v1Body = txEnvelope.v1().tx(); - let modified = false; - - for (const op of v1Body.operations()) { - const body = op.body(); - if (body.switch().name !== "invokeHostFunction") continue; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const invokeOp = (body as any).invokeHostFunction() as xdr.InvokeHostFunctionOp; - const authArr = invokeOp.auth(); - - for (let i = 0; i < authArr.length; i++) { - const entry = authArr[i]; - if (!entry) continue; - const creds = entry.credentials(); - if (creds.switch().name !== "sorobanCredentialsAddress") continue; - - const addrCreds = creds.address(); - - // Build the Soroban authorization signing preimage - // (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) - const networkId = hash(Buffer.from(passphrase)); - const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( - new xdr.HashIdPreimageSorobanAuthorization({ - networkId, - nonce: addrCreds.nonce(), - signatureExpirationLedger: addrCreds.signatureExpirationLedger(), - invocation: entry.rootInvocation(), - }) - ); - const challengeHash = hash(preimage.toXDR()); - // Convert to a plain Uint8Array backed by a fresh ArrayBuffer (required by WebAuthn API) - const challenge = Uint8Array.from(challengeHash); - - // Request WebAuthn assertion using the signing challenge - const assertion = (await navigator.credentials.get({ - publicKey: { - challenge, - rpId: config.rpId, - allowCredentials: [ - { type: "public-key" as const, id: config.credentialId }, - ], - userVerification: "required", - }, - })) as PublicKeyCredential | null; - - if (!assertion) { - throw new Error("WebAuthn: authentication was cancelled or failed"); - } - - const response = assertion.response as AuthenticatorAssertionResponse; - const compactSig = derToCompact(new Uint8Array(response.signature)); - - // Replace auth entry in-place with the WebAuthn credential. - // Signature map format required by Soroban Passkey Kit __check_auth: - // { authenticator_data: Bytes, client_data_json: Bytes, signature: Bytes } - authArr[i] = new xdr.SorobanAuthorizationEntry({ - credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( - new xdr.SorobanAddressCredentials({ - address: addrCreds.address(), - nonce: addrCreds.nonce(), - signatureExpirationLedger: addrCreds.signatureExpirationLedger(), - signature: nativeToScVal({ - authenticator_data: Buffer.from(response.authenticatorData), - client_data_json: Buffer.from(response.clientDataJSON), - signature: Buffer.from(compactSig), - }), - }) - ), - rootInvocation: entry.rootInvocation(), - }); - - modified = true; - } - } - - if (!modified) return xdrStr; - - return txEnvelope.toXDR("base64"); - }, - }; -} - -declare const require: any; - -export function createLedgerAdapter(options: { transport: any }): WalletAdapter { - return { - async isConnected(): Promise { - return true; - }, - async getPublicKey(): Promise { - const hwAppStrModule = require("@ledgerhq/hw-app-str"); - const StrClass = hwAppStrModule.default || hwAppStrModule; - const str = new StrClass(options.transport); - const res = await str.getPublicKey("44'/148'/0'"); - return res.publicKey; - }, - async signTransaction(xdrStr: string): Promise { - return xdrStr; - }, - }; -} +import { Keypair, TransactionBuilder, xdr, hash, nativeToScVal } from "@stellar/stellar-sdk"; +import type { WalletAdapter, Network, MultisigSigner, PasskeyAdapterConfig } from "./types.js"; + +/** + * Configuration for a claim-delegation adapter. + * + * The pattern lets an automated "claim bot" key call `withdraw` on behalf of the + * recipient without ever holding the recipient's primary key: + * + * 1. On-chain: add the bot key as a co-signer on the recipient's Stellar account + * with a weight that meets the low-security threshold (e.g. weight 1 on a 1-of-N + * multisig). The primary key retains sole control over high-security operations. + * 2. In the SDK: pass the recipient address as `recipientAddress` and the bot's + * {@link MultisigSigner} as `claimBotSigner`. + * + * The resulting adapter always presents `recipientAddress` to `getPublicKey()` + * (so `withdraw` receives the correct recipient auth), but the transaction envelope + * is signed exclusively by the claim bot key. + * + * @example + * ```ts + * // Bot key loaded from env — never has custody of the recipient address. + * const botSigner: MultisigSigner = { + * async signTransaction(xdr, network) { + * const kp = Keypair.fromSecret(process.env.CLAIM_BOT_SECRET!); + * const tx = TransactionBuilder.fromXDR(xdr, Networks.TESTNET); + * tx.sign(kp); + * return tx.toEnvelope().toXDR("base64"); + * }, + * }; + * + * const adapter = createClaimDelegateAdapter({ + * recipientAddress: "GRECIPI...", + * claimBotSigner: botSigner, + * }); + * + * const client = new SoroStreamClient({ network: "testnet", contractId, walletAdapter: adapter }); + * await client.withdraw({ streamId }); + * ``` + */ +export interface ClaimDelegateConfig { + /** The actual recipient address (passed to `require_auth` on-chain). */ + recipientAddress: string; + /** A signer representing the claim bot key. */ + claimBotSigner: MultisigSigner; +} + +const NETWORK_PASSPHRASES: Record = { + mainnet: "Public Global Stellar Network ; September 2015", + testnet: "Test SDF Network ; September 2015", + futurenet: "Test SDF Future Network ; October 2022", +}; + +/** + * Creates a WalletAdapter backed by the Freighter browser extension. + * Dynamically imports @stellar/freighter-api to avoid SSR issues. + * + * @example + * ```ts + * import { SoroStreamClient, createFreighterAdapter } from "@sorostream/sdk"; + * + * const freighterAdapter = await createFreighterAdapter(); + * const client = new SoroStreamClient({ + * network: "testnet", + * contractId: "YOUR_CONTRACT_ID", + * walletAdapter: freighterAdapter, + * }); + * ``` + */ +export async function createFreighterAdapter(): Promise { + const freighter = await import("@stellar/freighter-api"); + + return { + async isConnected(): Promise { + const result = await freighter.isConnected(); + return result.isConnected; + }, + + async getPublicKey(): Promise { + const result = await freighter.getAddress(); + if (result.error) throw new Error(result.error.message); + return result.address; + }, + + async signTransaction(xdrStr: string, network: Network): Promise { + const result = await freighter.signTransaction(xdrStr, { + networkPassphrase: NETWORK_PASSPHRASES[network], + }); + if (result.error) throw new Error(result.error.message); + return result.signedTxXdr; + }, + }; +} + +/** + * Creates a server-side WalletAdapter that signs directly with a Stellar Keypair. + * Suitable for Node.js scripts, backends, and automated payouts. + * + * @param secretKey - The Stellar secret key (base-32 encoded seed starting with "S"). + * + * @example + * ```ts + * import { SoroStreamClient, createKeypairAdapter } from "@sorostream/sdk"; + * + * const serverKeypairAdapter = createKeypairAdapter(process.env.STELLAR_SECRET!); + * const client = new SoroStreamClient({ + * network: "testnet", + * contractId: "YOUR_CONTRACT_ID", + * walletAdapter: serverKeypairAdapter, + * }); + * ``` + */ +export function createKeypairAdapter(secretKey: string): WalletAdapter { + const keypair = Keypair.fromSecret(secretKey); + + return { + async isConnected(): Promise { + return true; + }, + + async getPublicKey(): Promise { + return keypair.publicKey(); + }, + + async signTransaction(xdrStr: string, network: Network): Promise { + const tx = TransactionBuilder.fromXDR( + xdrStr, + NETWORK_PASSPHRASES[network] + ); + tx.sign(keypair); + return tx.toEnvelope().toXDR("base64"); + }, + }; +} + +/** + * Prompts the user to connect their Freighter wallet. + * Throws if Freighter is not installed or the user rejects. + */ +export async function connectWallet(): Promise { + const freighter = await import("@stellar/freighter-api"); + const connected = await freighter.isConnected(); + if (!connected.isConnected) { + throw new Error("Freighter extension is not installed"); + } + const result = await freighter.getAddress(); + if (result.error) throw new Error(result.error.message); + return result.address; +} + +/** + * Creates a {@link WalletAdapter} that presents the recipient's address to the + * contract but signs transactions with a separate claim-bot key. + * + * The bot key must be a co-signer on the recipient's Stellar account (classic + * multisig) so that Soroban's `require_auth` accepts its signature for `withdraw`. + * + * This enables automated claiming daemons that never hold the recipient's primary + * secret key. See {@link ClaimDelegateConfig} for the full setup guide. + */ +export function createClaimDelegateAdapter( + config: ClaimDelegateConfig +): WalletAdapter { + return { + async isConnected(): Promise { + return true; + }, + + async getPublicKey(): Promise { + return config.recipientAddress; + }, + + async signTransaction(xdr: string, network: Network): Promise { + return config.claimBotSigner.signTransaction(xdr, network); + }, + }; +} + +/** + * Creates a WalletAdapter for a multi-sig Stellar account. + * + * The adapter collects signatures from each signer and combines them into + * a single transaction envelope before submission. + * + * @param config.address - The multisig source account address. + * @param config.signers - Array of signers that each independently sign the tx. + * @param config.threshold - Optional minimum number of signatures required + * (defaults to `signers.length`, i.e. all must sign). + */ +export async function createMultisigAdapter(config: { + address: string; + signers: MultisigSigner[]; + threshold?: number; +}): Promise { + const threshold = config.threshold ?? config.signers.length; + + return { + async isConnected(): Promise { + return true; + }, + + async getPublicKey(): Promise { + return config.address; + }, + + async signTransaction(xdrStr: string, network: Network): Promise { + const passphrase = NETWORK_PASSPHRASES[network]; + + let combined: ReturnType | null = null; + let collected = 0; + const seen = new Set(); + + for (const signer of config.signers) { + if (collected >= threshold) break; + + const signedXdr = await signer.signTransaction(xdrStr, network); + const tx = TransactionBuilder.fromXDR(signedXdr, passphrase); + + for (const sig of tx.signatures) { + const key = + sig.hint().toString("base64") + + sig.signature().toString("base64"); + if (!seen.has(key)) { + seen.add(key); + if (!combined) { + combined = TransactionBuilder.fromXDR(xdrStr, passphrase); + } + combined.signatures.push(sig); + collected++; + } + } + } + + if (!combined) { + throw new Error("No signatures were collected"); + } + + return combined.toEnvelope().toXDR("base64"); + }, + }; +} + +// ── Issue #46: WebAuthn passkey adapter ────────────────────────────────────── + +/** + * Converts a DER-encoded P-256 ECDSA signature to compact (r || s) form. + * DER format: 0x30 0x02 0x02 + */ +function derToCompact(der: Uint8Array): Uint8Array { + let offset = 0; + if (der[offset++] !== 0x30) throw new Error("Invalid DER signature: expected 0x30"); + offset++; // skip total length byte + if (der[offset++] !== 0x02) throw new Error("Invalid DER signature: expected 0x02 for r"); + const rLen = der[offset++]; + if (rLen === undefined) throw new Error("Invalid DER signature: truncated r length"); + const r = der.slice(offset, offset + rLen); + offset += rLen; + if (der[offset++] !== 0x02) throw new Error("Invalid DER signature: expected 0x02 for s"); + const sLen = der[offset++]; + if (sLen === undefined) throw new Error("Invalid DER signature: truncated s length"); + const s = der.slice(offset, offset + sLen); + + const compact = new Uint8Array(64); + // r and s may have a leading 0x00 padding byte; trim and right-align to 32 bytes + const rBytes = r[0] === 0 ? r.slice(1) : r; + const sBytes = s[0] === 0 ? s.slice(1) : s; + compact.set(rBytes, 32 - rBytes.length); + compact.set(sBytes, 64 - sBytes.length); + return compact; +} + +/** + * Creates a WalletAdapter for a Soroban smart-wallet contract that is + * authenticated via WebAuthn/passkeys rather than a classic Ed25519 keypair. + * + * The adapter signs each `invokeHostFunction` auth entry by: + * 1. Computing the Soroban contract-auth signing challenge (SHA-256 of the + * `HashIdPreimageSorobanAuthorization` XDR). + * 2. Requesting a WebAuthn assertion from the registered passkey. + * 3. Attaching the response (`authenticator_data`, `client_data_json`, + * compact `signature`) as a ScVal map in the auth entry credentials. + * + * This follows the Soroban Passkey Kit signature format expected by the + * `__check_auth` function on standard Soroban smart wallet contracts. + * + * **Requirements:** Must be called in a browser environment with WebAuthn + * support. The contract must already be deployed. + * + * @param config - Passkey adapter configuration. + * + * @example + * ```ts + * const adapter = await createPasskeyAdapter({ + * contractId: "CA...", + * rpId: "myapp.example.com", + * credentialId: myCredentialIdArrayBuffer, + * }); + * const client = new SoroStreamClient({ network: "testnet", contractId: "...", walletAdapter: adapter }); + * ``` + */ +export async function createPasskeyAdapter( + config: PasskeyAdapterConfig +): Promise { + if ( + typeof window === "undefined" || + !("credentials" in navigator) || + !("PublicKeyCredential" in window) + ) { + throw new Error("WebAuthn is not available in this environment"); + } + + return { + async isConnected(): Promise { + return ( + typeof window !== "undefined" && + "credentials" in navigator && + "PublicKeyCredential" in window + ); + }, + + async getPublicKey(): Promise { + return config.contractId; + }, + + async signTransaction(xdrStr: string, network: Network): Promise { + const passphrase = NETWORK_PASSPHRASES[network]; + + // Parse the raw transaction envelope so we can read and mutate auth entries + const txEnvelope = xdr.TransactionEnvelope.fromXDR(xdrStr, "base64"); + const v1Body = txEnvelope.v1().tx(); + let modified = false; + + for (const op of v1Body.operations()) { + const body = op.body(); + if (body.switch().name !== "invokeHostFunction") continue; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invokeOp = (body as any).invokeHostFunction() as xdr.InvokeHostFunctionOp; + const authArr = invokeOp.auth(); + + for (let i = 0; i < authArr.length; i++) { + const entry = authArr[i]; + if (!entry) continue; + const creds = entry.credentials(); + if (creds.switch().name !== "sorobanCredentialsAddress") continue; + + const addrCreds = creds.address(); + + // Build the Soroban authorization signing preimage + // (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) + const networkId = hash(Buffer.from(passphrase)); + const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( + new xdr.HashIdPreimageSorobanAuthorization({ + networkId, + nonce: addrCreds.nonce(), + signatureExpirationLedger: addrCreds.signatureExpirationLedger(), + invocation: entry.rootInvocation(), + }) + ); + const challengeHash = hash(preimage.toXDR()); + // Convert to a plain Uint8Array backed by a fresh ArrayBuffer (required by WebAuthn API) + const challenge = Uint8Array.from(challengeHash); + + // Request WebAuthn assertion using the signing challenge + const assertion = (await navigator.credentials.get({ + publicKey: { + challenge, + rpId: config.rpId, + allowCredentials: [ + { type: "public-key" as const, id: config.credentialId }, + ], + userVerification: "required", + }, + })) as PublicKeyCredential | null; + + if (!assertion) { + throw new Error("WebAuthn: authentication was cancelled or failed"); + } + + const response = assertion.response as AuthenticatorAssertionResponse; + const compactSig = derToCompact(new Uint8Array(response.signature)); + + // Replace auth entry in-place with the WebAuthn credential. + // Signature map format required by Soroban Passkey Kit __check_auth: + // { authenticator_data: Bytes, client_data_json: Bytes, signature: Bytes } + authArr[i] = new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress( + new xdr.SorobanAddressCredentials({ + address: addrCreds.address(), + nonce: addrCreds.nonce(), + signatureExpirationLedger: addrCreds.signatureExpirationLedger(), + signature: nativeToScVal({ + authenticator_data: Buffer.from(response.authenticatorData), + client_data_json: Buffer.from(response.clientDataJSON), + signature: Buffer.from(compactSig), + }), + }) + ), + rootInvocation: entry.rootInvocation(), + }); + + modified = true; + } + } + + if (!modified) return xdrStr; + + return txEnvelope.toXDR("base64"); + }, + }; +} diff --git a/test/wallet-docs.test.ts b/test/wallet-docs.test.ts new file mode 100644 index 0000000..06d52c9 --- /dev/null +++ b/test/wallet-docs.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("wallet adapter documentation", () => { + it("documents freighter, ledger, and server-side examples in the README", () => { + const readme = readFileSync(resolve(__dirname, "../README.md"), "utf8"); + + expect(readme).toContain("### Wallet adapter examples"); + expect(readme).toContain("#### Freighter adapter"); + expect(readme).toContain("#### Ledger adapter"); + expect(readme).toContain("#### Server-side keypair adapter"); + }); + + it("includes a minimal WalletAdapter example in the public interface docs", () => { + const types = readFileSync(resolve(__dirname, "../src/types.ts"), "utf8"); + + expect(types).toContain("@example"); + expect(types).toContain("const serverKeypairAdapter: WalletAdapter = {"); + }); +});