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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,75 @@ pnpm test -- --run # Run tests once without watch
pnpm test -- sdk/src/client/Charge.test.ts # Run a single test file
```

## Verification Checklist

After any code change, run **all** of the following to ensure nothing is broken:

### 1. Offline checks (always run)

```bash
pnpm test -- --run # 140 unit tests
pnpm run check:types # TypeScript type check
pnpm run build # Compile to dist/
```

### 2. Example scripts (always run)

Each example must load and execute without import/type errors. Expected behavior noted inline.

```bash
# Charge server — should start and return 402 on requests
PORT=3099 STELLAR_RECIPIENT=GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKUVJ3LKEWI4ZNVPP5EFC \
npx tsx examples/server.ts
# → "Stellar MPP server running on http://localhost:3099" — Ctrl+C to stop

# Channel server — should start and return 402 on requests
CHANNEL_CONTRACT=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC \
COMMITMENT_PUBKEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
SOURCE_ACCOUNT=GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKUVJ3LKEWI4ZNVPP5EFC \
PORT=3098 \
npx tsx examples/channel-server.ts
# → "Stellar MPP Channel server running on http://localhost:3098" — Ctrl+C to stop

# Client — should load, create keypair, fail on network (no server running)
STELLAR_SECRET=$(npx tsx -e "import{Keypair}from'@stellar/stellar-sdk';console.log(Keypair.random().secret())" 2>/dev/null) \
SERVER_URL=http://localhost:9999 \
npx tsx examples/client.ts
# → "Using Stellar account: G..." then ECONNREFUSED (expected)

# Channel client — should load, create commitment key, fail on network
COMMITMENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
SOURCE_ACCOUNT=GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKUVJ3LKEWI4ZNVPP5EFC \
SERVER_URL=http://localhost:9999 \
npx tsx examples/channel-client.ts
# → "Using commitment key: G..." then ECONNREFUSED (expected)

# Channel open — should exit with env var validation error
npx tsx examples/channel-open.ts
# → "Set OPEN_TX_XDR to..." (expected)

# Channel close — should exit with env var validation error
npx tsx examples/channel-close.ts
# → "Set CLOSE_SECRET to..." (expected)
```

### 3. E2E demo (run when channel logic changes)

Requires: `stellar` CLI, Node.js 20+, and the one-way-channel WASM binary.

```bash
WASM_PATH=/Users/marcelosantos/Workspace/one-way-channel/target/wasm32v1-none/release/channel.wasm \
./demo/run-channel-e2e.sh
# Full lifecycle: deploy → 2 off-chain payments → on-chain close → balance verified at 0
```

### 4. Interactive demos (run manually with funded testnet accounts)

```bash
./demo/run.sh # Charge demo — prompts for STELLAR_RECIPIENT + STELLAR_SECRET
./demo/run-channel.sh # Channel demo — prompts for CHANNEL_CONTRACT + COMMITMENT keys
```

## Architecture

### Twin Client/Server Pattern
Expand All @@ -36,7 +105,7 @@ Methods.ts (Zod schema) → client/ (create credentials) + server/ (verify crede

| Path | Role |
|------|------|
| `sdk/src/Methods.ts` | Charge method schema (Zod discriminated union: `xdr` vs `signature` credentials) |
| `sdk/src/Methods.ts` | Charge method schema (Zod discriminated union: `transaction` vs `hash` credentials) |
| `sdk/src/constants.ts` | SAC addresses (USDC/XLM), RPC URLs, network passphrases, defaults |
| `sdk/src/scval.ts` | Soroban ScVal ↔ BigInt conversion |
| `sdk/src/client/Charge.ts` | Creates SAC `transfer` invocations; handles pull (send XDR) and push (broadcast + send hash) flows |
Expand Down
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,8 @@ stellar.charge({
network?: 'testnet' | 'public', // default: 'testnet'
decimals?: number, // default: 7
rpcUrl?: string, // custom Soroban RPC URL
feePayer?: Keypair | string, // sponsor tx fees via FeeBumpTransaction
signer?: Keypair | string, // source account for sponsored tx signing
feeBumpSigner?: Keypair | string, // wraps all txs in FeeBumpTransaction
store?: Store.Store, // replay protection
})
```
Expand Down Expand Up @@ -264,9 +265,9 @@ The `onProgress` callback receives events at each stage:

| Event | Fields | When |
|-------|--------|------|
| `challenge` | `recipient`, `amount`, `currency`, `feePayerKey?` | Challenge received |
| `challenge` | `recipient`, `amount`, `currency` | Challenge received |
| `signing` | — | Before signing |
| `signed` | `xdr` | After signing |
| `signed` | `transaction` | After signing |
| `paying` | — | Before broadcast (push mode) |
| `confirming` | `hash` | Polling for confirmation (push mode) |
| `paid` | `hash` | Transaction confirmed (push mode) |
Expand All @@ -281,17 +282,21 @@ The `onProgress` callback receives events at each stage:

### Fee sponsorship

The server can sponsor transaction fees using Stellar's `FeeBumpTransaction`:
The server can decouple sequence-number management from fee payment:

- **`signer`** — keypair providing the source account and sequence number for sponsored transactions.
- **`feeBumpSigner`** — optional dedicated fee payer. When set, all submitted transactions are wrapped in a `FeeBumpTransaction` signed by this key.

```ts
stellar.charge({
recipient: 'G...',
currency: USDC_SAC_TESTNET,
feePayer: Keypair.fromSecret('S...'),
signer: Keypair.fromSecret('S...'), // source account
feeBumpSigner: Keypair.fromSecret('S...'), // pays all fees
})
```

The client is automatically informed via `methodDetails.feePayer` in the challenge.
The client is automatically informed of fee sponsorship via `methodDetails.feePayer` in the challenge.

### Replay protection

Expand Down
2 changes: 1 addition & 1 deletion examples/channel-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ const txHash = await close({
channel: CHANNEL_CONTRACT,
amount: AMOUNT,
signature,
closeKey,
signer: closeKey,
network: NETWORK,
})

Expand Down
8 changes: 3 additions & 5 deletions sdk/src/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,9 @@ describe('Methods.charge', () => {
recipient: 'GBXYZ',
methodDetails: {
feePayer: true,
feePayerKey: 'GXYZ...',
},
})
expect(result.methodDetails?.feePayer).toBe(true)
expect(result.methodDetails?.feePayerKey).toBe('GXYZ...')
})

it('request schema allows omitting methodDetails', () => {
Expand All @@ -135,12 +133,12 @@ describe('Methods.charge', () => {
expect(result.methodDetails).toBeUndefined()
})

it('credential payload accepts signature type (push)', () => {
it('credential payload accepts hash type (push)', () => {
const result = Methods.charge.schema.credential.payload.parse({
type: 'signature',
type: 'hash',
hash: 'abc123',
})
expect(result.type).toBe('signature')
expect(result.type).toBe('hash')
expect(result.hash).toBe('abc123')
})

Expand Down
6 changes: 2 additions & 4 deletions sdk/src/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { z } from 'zod/mini'
* - `type: "transaction"` — **server-broadcast** (pull mode):
* Client signs a Soroban SAC `transfer` invocation and sends the
* serialised XDR as `payload.transaction`. The server broadcasts it.
* - `type: "signature"` — **client-broadcast** (push mode):
* - `type: "hash"` — **client-broadcast** (push mode):
* Client broadcasts itself and sends the transaction hash.
* The server looks it up on-chain for verification.
*
Expand All @@ -21,7 +21,7 @@ export const charge = Method.from({
credential: {
payload: z.discriminatedUnion('type', [
/** Push mode: client broadcasts and sends the tx hash. */
z.object({ hash: z.string(), type: z.literal('signature') }),
z.object({ hash: z.string(), type: z.literal('hash') }),
/** Pull mode: client sends signed XDR as `payload.transaction`, server broadcasts. */
z.object({ transaction: z.string(), type: z.literal('transaction') }),
]),
Expand All @@ -48,8 +48,6 @@ export const charge = Method.from({
memo: z.optional(z.string()),
/** Whether the server will sponsor transaction fees. */
feePayer: z.optional(z.boolean()),
/** Public key of the server's fee payer account. */
feePayerKey: z.optional(z.string()),
}),
),
}),
Expand Down
19 changes: 15 additions & 4 deletions sdk/src/channel/server/Channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,22 @@ describe('stellar server channel', () => {
expect(method.name).toBe('stellar')
})

it('accepts closeKey parameter', () => {
it('accepts signer for close transaction signing', () => {
const method = channel({
channel: CHANNEL_ADDRESS,
commitmentKey: COMMITMENT_KEY.publicKey(),
closeKey: Keypair.random(),
signer: Keypair.random(),
store: Store.memory(),
})
expect(method.name).toBe('stellar')
})

it('accepts feeBumpSigner for channel transactions', () => {
const method = channel({
channel: CHANNEL_ADDRESS,
commitmentKey: COMMITMENT_KEY.publicKey(),
feeBumpSigner: Keypair.random(),
store: Store.memory(),
})
expect(method.name).toBe('stellar')
})
Expand Down Expand Up @@ -538,7 +549,7 @@ describe('stellar server channel verification', () => {
).rejects.toThrow('Replay rejected')
})

it('rejects close action when closeKey is not configured', async () => {
it('rejects close action when signer is not configured', async () => {
const commitmentBytes = Buffer.from('close-test-bytes')
mockSimulateTransaction.mockResolvedValueOnce(
successSimResult(commitmentBytes),
Expand All @@ -561,7 +572,7 @@ describe('stellar server channel verification', () => {
credential: credential as any,
request: credential.challenge.request,
}),
).rejects.toThrow('Close action requires a closeKey')
).rejects.toThrow('Close action requires a signer')
})
})

Expand Down
Loading
Loading