Skip to content

Commit a540183

Browse files
feat: decouple fee payment from sequence number management (#23)
* feat: add signer pool utilities (roundRobinSelector, resolveSigners) * feat: rename push-mode credential type from 'signature' to 'hash' per MPP spec * feat: client sends type='hash', server verifies type='hash' for push mode * feat: remove feePayerKey from schema and client progress events * feat: replace feePayer with signers/selectSigner/feeBumpSigner in server charge * feat: replace closeKey with signers/selectSigner/feeBumpSigner in channel server * feat: export roundRobinSelector, resolveSigners, and SignerPool from package * docs: update README for signer pool, fee bump, and type='hash' changes * docs: update channel-close example for signer parameter rename * refactor: simplify signer pool to single signer + feeBumpSigner * address PR comments * docs: add verification checklist to CLAUDE.md
1 parent e905bca commit a540183

File tree

16 files changed

+273
-101
lines changed

16 files changed

+273
-101
lines changed

CLAUDE.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,75 @@ pnpm test -- --run # Run tests once without watch
2222
pnpm test -- sdk/src/client/Charge.test.ts # Run a single test file
2323
```
2424

25+
## Verification Checklist
26+
27+
After any code change, run **all** of the following to ensure nothing is broken:
28+
29+
### 1. Offline checks (always run)
30+
31+
```bash
32+
pnpm test -- --run # 140 unit tests
33+
pnpm run check:types # TypeScript type check
34+
pnpm run build # Compile to dist/
35+
```
36+
37+
### 2. Example scripts (always run)
38+
39+
Each example must load and execute without import/type errors. Expected behavior noted inline.
40+
41+
```bash
42+
# Charge server — should start and return 402 on requests
43+
PORT=3099 STELLAR_RECIPIENT=GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKUVJ3LKEWI4ZNVPP5EFC \
44+
npx tsx examples/server.ts
45+
# → "Stellar MPP server running on http://localhost:3099" — Ctrl+C to stop
46+
47+
# Channel server — should start and return 402 on requests
48+
CHANNEL_CONTRACT=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC \
49+
COMMITMENT_PUBKEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
50+
SOURCE_ACCOUNT=GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKUVJ3LKEWI4ZNVPP5EFC \
51+
PORT=3098 \
52+
npx tsx examples/channel-server.ts
53+
# → "Stellar MPP Channel server running on http://localhost:3098" — Ctrl+C to stop
54+
55+
# Client — should load, create keypair, fail on network (no server running)
56+
STELLAR_SECRET=$(npx tsx -e "import{Keypair}from'@stellar/stellar-sdk';console.log(Keypair.random().secret())" 2>/dev/null) \
57+
SERVER_URL=http://localhost:9999 \
58+
npx tsx examples/client.ts
59+
# → "Using Stellar account: G..." then ECONNREFUSED (expected)
60+
61+
# Channel client — should load, create commitment key, fail on network
62+
COMMITMENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
63+
SOURCE_ACCOUNT=GBHEGW3KWOY2OFH767EDALFGCUTBOEVBDQMCKUVJ3LKEWI4ZNVPP5EFC \
64+
SERVER_URL=http://localhost:9999 \
65+
npx tsx examples/channel-client.ts
66+
# → "Using commitment key: G..." then ECONNREFUSED (expected)
67+
68+
# Channel open — should exit with env var validation error
69+
npx tsx examples/channel-open.ts
70+
# → "Set OPEN_TX_XDR to..." (expected)
71+
72+
# Channel close — should exit with env var validation error
73+
npx tsx examples/channel-close.ts
74+
# → "Set CLOSE_SECRET to..." (expected)
75+
```
76+
77+
### 3. E2E demo (run when channel logic changes)
78+
79+
Requires: `stellar` CLI, Node.js 20+, and the one-way-channel WASM binary.
80+
81+
```bash
82+
WASM_PATH=/Users/marcelosantos/Workspace/one-way-channel/target/wasm32v1-none/release/channel.wasm \
83+
./demo/run-channel-e2e.sh
84+
# Full lifecycle: deploy → 2 off-chain payments → on-chain close → balance verified at 0
85+
```
86+
87+
### 4. Interactive demos (run manually with funded testnet accounts)
88+
89+
```bash
90+
./demo/run.sh # Charge demo — prompts for STELLAR_RECIPIENT + STELLAR_SECRET
91+
./demo/run-channel.sh # Channel demo — prompts for CHANNEL_CONTRACT + COMMITMENT keys
92+
```
93+
2594
## Architecture
2695

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

37106
| Path | Role |
38107
|------|------|
39-
| `sdk/src/Methods.ts` | Charge method schema (Zod discriminated union: `xdr` vs `signature` credentials) |
108+
| `sdk/src/Methods.ts` | Charge method schema (Zod discriminated union: `transaction` vs `hash` credentials) |
40109
| `sdk/src/constants.ts` | SAC addresses (USDC/XLM), RPC URLs, network passphrases, defaults |
41110
| `sdk/src/scval.ts` | Soroban ScVal ↔ BigInt conversion |
42111
| `sdk/src/client/Charge.ts` | Creates SAC `transfer` invocations; handles pull (send XDR) and push (broadcast + send hash) flows |

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ stellar.charge({
212212
network?: 'testnet' | 'public', // default: 'testnet'
213213
decimals?: number, // default: 7
214214
rpcUrl?: string, // custom Soroban RPC URL
215-
feePayer?: Keypair | string, // sponsor tx fees via FeeBumpTransaction
215+
signer?: Keypair | string, // source account for sponsored tx signing
216+
feeBumpSigner?: Keypair | string, // wraps all txs in FeeBumpTransaction
216217
store?: Store.Store, // replay protection
217218
})
218219
```
@@ -264,9 +265,9 @@ The `onProgress` callback receives events at each stage:
264265

265266
| Event | Fields | When |
266267
|-------|--------|------|
267-
| `challenge` | `recipient`, `amount`, `currency`, `feePayerKey?` | Challenge received |
268+
| `challenge` | `recipient`, `amount`, `currency` | Challenge received |
268269
| `signing` || Before signing |
269-
| `signed` | `xdr` | After signing |
270+
| `signed` | `transaction` | After signing |
270271
| `paying` || Before broadcast (push mode) |
271272
| `confirming` | `hash` | Polling for confirmation (push mode) |
272273
| `paid` | `hash` | Transaction confirmed (push mode) |
@@ -281,17 +282,21 @@ The `onProgress` callback receives events at each stage:
281282

282283
### Fee sponsorship
283284

284-
The server can sponsor transaction fees using Stellar's `FeeBumpTransaction`:
285+
The server can decouple sequence-number management from fee payment:
286+
287+
- **`signer`** — keypair providing the source account and sequence number for sponsored transactions.
288+
- **`feeBumpSigner`** — optional dedicated fee payer. When set, all submitted transactions are wrapped in a `FeeBumpTransaction` signed by this key.
285289

286290
```ts
287291
stellar.charge({
288292
recipient: 'G...',
289293
currency: USDC_SAC_TESTNET,
290-
feePayer: Keypair.fromSecret('S...'),
294+
signer: Keypair.fromSecret('S...'), // source account
295+
feeBumpSigner: Keypair.fromSecret('S...'), // pays all fees
291296
})
292297
```
293298

294-
The client is automatically informed via `methodDetails.feePayer` in the challenge.
299+
The client is automatically informed of fee sponsorship via `methodDetails.feePayer` in the challenge.
295300

296301
### Replay protection
297302

examples/channel-close.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const txHash = await close({
9191
channel: CHANNEL_CONTRACT,
9292
amount: AMOUNT,
9393
signature,
94-
closeKey,
94+
signer: closeKey,
9595
network: NETWORK,
9696
})
9797

sdk/src/Methods.test.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,9 @@ describe('Methods.charge', () => {
119119
recipient: 'GBXYZ',
120120
methodDetails: {
121121
feePayer: true,
122-
feePayerKey: 'GXYZ...',
123122
},
124123
})
125124
expect(result.methodDetails?.feePayer).toBe(true)
126-
expect(result.methodDetails?.feePayerKey).toBe('GXYZ...')
127125
})
128126

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

138-
it('credential payload accepts signature type (push)', () => {
136+
it('credential payload accepts hash type (push)', () => {
139137
const result = Methods.charge.schema.credential.payload.parse({
140-
type: 'signature',
138+
type: 'hash',
141139
hash: 'abc123',
142140
})
143-
expect(result.type).toBe('signature')
141+
expect(result.type).toBe('hash')
144142
expect(result.hash).toBe('abc123')
145143
})
146144

sdk/src/Methods.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { z } from 'zod/mini'
88
* - `type: "transaction"` — **server-broadcast** (pull mode):
99
* Client signs a Soroban SAC `transfer` invocation and sends the
1010
* serialised XDR as `payload.transaction`. The server broadcasts it.
11-
* - `type: "signature"` — **client-broadcast** (push mode):
11+
* - `type: "hash"` — **client-broadcast** (push mode):
1212
* Client broadcasts itself and sends the transaction hash.
1313
* The server looks it up on-chain for verification.
1414
*
@@ -21,7 +21,7 @@ export const charge = Method.from({
2121
credential: {
2222
payload: z.discriminatedUnion('type', [
2323
/** Push mode: client broadcasts and sends the tx hash. */
24-
z.object({ hash: z.string(), type: z.literal('signature') }),
24+
z.object({ hash: z.string(), type: z.literal('hash') }),
2525
/** Pull mode: client sends signed XDR as `payload.transaction`, server broadcasts. */
2626
z.object({ transaction: z.string(), type: z.literal('transaction') }),
2727
]),
@@ -48,8 +48,6 @@ export const charge = Method.from({
4848
memo: z.optional(z.string()),
4949
/** Whether the server will sponsor transaction fees. */
5050
feePayer: z.optional(z.boolean()),
51-
/** Public key of the server's fee payer account. */
52-
feePayerKey: z.optional(z.string()),
5351
}),
5452
),
5553
}),

sdk/src/channel/server/Channel.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,22 @@ describe('stellar server channel', () => {
276276
expect(method.name).toBe('stellar')
277277
})
278278

279-
it('accepts closeKey parameter', () => {
279+
it('accepts signer for close transaction signing', () => {
280280
const method = channel({
281281
channel: CHANNEL_ADDRESS,
282282
commitmentKey: COMMITMENT_KEY.publicKey(),
283-
closeKey: Keypair.random(),
283+
signer: Keypair.random(),
284+
store: Store.memory(),
285+
})
286+
expect(method.name).toBe('stellar')
287+
})
288+
289+
it('accepts feeBumpSigner for channel transactions', () => {
290+
const method = channel({
291+
channel: CHANNEL_ADDRESS,
292+
commitmentKey: COMMITMENT_KEY.publicKey(),
293+
feeBumpSigner: Keypair.random(),
294+
store: Store.memory(),
284295
})
285296
expect(method.name).toBe('stellar')
286297
})
@@ -538,7 +549,7 @@ describe('stellar server channel verification', () => {
538549
).rejects.toThrow('Replay rejected')
539550
})
540551

541-
it('rejects close action when closeKey is not configured', async () => {
552+
it('rejects close action when signer is not configured', async () => {
542553
const commitmentBytes = Buffer.from('close-test-bytes')
543554
mockSimulateTransaction.mockResolvedValueOnce(
544555
successSimResult(commitmentBytes),
@@ -561,7 +572,7 @@ describe('stellar server channel verification', () => {
561572
credential: credential as any,
562573
request: credential.challenge.request,
563574
}),
564-
).rejects.toThrow('Close action requires a closeKey')
575+
).rejects.toThrow('Close action requires a signer')
565576
})
566577
})
567578

0 commit comments

Comments
 (0)