Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
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
96 changes: 71 additions & 25 deletions sdk/src/channel/server/Channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
Contract,
FeeBumpTransaction,
Keypair,
Transaction,
TransactionBuilder,
nativeToScVal,
rpc,
Expand All @@ -13,6 +15,7 @@ import {
type NetworkId,
} from '../../constants.js'
import { toBaseUnits } from '../../Methods.js'
import { resolveKeypair } from '../../signers.js'
import { channel as ChannelMethod } from '../Methods.js'
import { getChannelState, type ChannelState } from './State.js'

Expand Down Expand Up @@ -44,12 +47,13 @@ export function channel(parameters: channel.Parameters) {
const {
channel: channelAddress,
checkOnChainState = false,
closeKey: closeKeyParam,
commitmentKey: commitmentKeyParam,
decimals = DEFAULT_DECIMALS,
feeBumpSigner: feeBumpSignerParam,
network = 'testnet',
onDisputeDetected,
rpcUrl,
signer: signerParam,
sourceAccount,
store,
} = parameters
Expand All @@ -66,14 +70,10 @@ export function channel(parameters: channel.Parameters) {
return commitmentKeyParam
})()

// Parse the optional close signer key
const closeKeypair = (() => {
if (!closeKeyParam) return undefined
if (typeof closeKeyParam === 'string') {
return Keypair.fromSecret(closeKeyParam)
}
return closeKeyParam
})()
const signerKeypair = signerParam ? resolveKeypair(signerParam) : undefined
const feeBumpKeypair = feeBumpSignerParam
? resolveKeypair(feeBumpSignerParam)
: undefined

// Track cumulative amounts per channel in the store
const cumulativeKey = `stellar:channel:cumulative:${channelAddress}`
Expand Down Expand Up @@ -358,9 +358,9 @@ export function channel(parameters: channel.Parameters) {

// Dispatch on action
if (action === 'close') {
if (!closeKeypair) {
if (!signerKeypair) {
throw new ChannelVerificationError(
'Close action requires a closeKey to be configured.',
'Close action requires a signer to be configured.',
{},
)
}
Expand All @@ -372,7 +372,7 @@ export function channel(parameters: channel.Parameters) {
nativeToScVal(Buffer.from(signatureBytes), { type: 'bytes' }),
)

const closeAccount = await server.getAccount(closeKeypair.publicKey())
const closeAccount = await server.getAccount(signerKeypair.publicKey())
const closeTx = new TransactionBuilder(closeAccount, {
fee: '100',
networkPassphrase,
Expand All @@ -382,9 +382,22 @@ export function channel(parameters: channel.Parameters) {
.build()

const prepared = await server.prepareTransaction(closeTx)
prepared.sign(closeKeypair)
prepared.sign(signerKeypair)

let txToSubmit: Transaction | FeeBumpTransaction = prepared
if (feeBumpKeypair) {
const MAX_FEE_BUMP = 10_000_000
const bumpFee = Math.min(Number(prepared.fee) * 10, MAX_FEE_BUMP)
txToSubmit = TransactionBuilder.buildFeeBumpTransaction(
feeBumpKeypair,
bumpFee.toString(),
prepared,
networkPassphrase,
)
txToSubmit.sign(feeBumpKeypair)
}

const sendResult = await server.sendTransaction(prepared)
const sendResult = await server.sendTransaction(txToSubmit)

const MAX_POLL_ATTEMPTS = 60
let txResult = await server.getTransaction(sendResult.hash)
Expand Down Expand Up @@ -564,7 +577,22 @@ export function channel(parameters: channel.Parameters) {
)
}

const sendResult = await server.sendTransaction(openTx)
let txToSubmit = openTx
if (feeBumpKeypair) {
const innerTx = openTx instanceof FeeBumpTransaction
? openTx.innerTransaction
: (openTx as Transaction)
const MAX_FEE_BUMP = 10_000_000
const bumpFee = Math.min(Number(innerTx.fee) * 10, MAX_FEE_BUMP)
txToSubmit = TransactionBuilder.buildFeeBumpTransaction(
feeBumpKeypair,
bumpFee.toString(),
innerTx,
networkPassphrase,
)
;(txToSubmit as FeeBumpTransaction).sign(feeBumpKeypair)
}
const sendResult = await server.sendTransaction(txToSubmit)

const MAX_POLL_ATTEMPTS = 60
let txResult = await server.getTransaction(sendResult.hash)
Expand Down Expand Up @@ -616,8 +644,10 @@ export async function close(parameters: {
amount: bigint
/** Ed25519 signature for the commitment. */
signature: Uint8Array
/** Keypair to sign the close transaction. */
closeKey: Keypair
/** Keypair to sign the close transaction (source account). */
signer: Keypair
/** Optional fee bump signer. */
feeBumpSigner?: Keypair
/** Network identifier. */
network?: NetworkId
/** Custom RPC URL. */
Expand All @@ -627,7 +657,8 @@ export async function close(parameters: {
channel: channelAddress,
amount,
signature,
closeKey,
signer,
feeBumpSigner,
network = 'testnet',
rpcUrl,
} = parameters
Expand All @@ -643,7 +674,7 @@ export async function close(parameters: {
nativeToScVal(Buffer.from(signature), { type: 'bytes' }),
)

const account = await server.getAccount(closeKey.publicKey())
const account = await server.getAccount(signer.publicKey())
const tx = new TransactionBuilder(account, {
fee: '100',
networkPassphrase,
Expand All @@ -653,9 +684,22 @@ export async function close(parameters: {
.build()

const prepared = await server.prepareTransaction(tx)
prepared.sign(closeKey)
prepared.sign(signer)

let txToSubmit: Transaction | FeeBumpTransaction = prepared
if (feeBumpSigner) {
const MAX_FEE_BUMP = 10_000_000
const bumpFee = Math.min(Number(prepared.fee) * 10, MAX_FEE_BUMP)
txToSubmit = TransactionBuilder.buildFeeBumpTransaction(
feeBumpSigner,
bumpFee.toString(),
prepared,
networkPassphrase,
)
txToSubmit.sign(feeBumpSigner)
}

const result = await server.sendTransaction(prepared)
const result = await server.sendTransaction(txToSubmit)

const MAX_POLL_ATTEMPTS = 60
let txResult = await server.getTransaction(result.hash)
Expand Down Expand Up @@ -697,11 +741,13 @@ export declare namespace channel {
*/
checkOnChainState?: boolean
/**
* Keypair for signing close transactions. Required when handling
* close credential actions. Accepts a Stellar secret key string (S...)
* or a Keypair instance.
* Keypair for signing close transactions (provides sequence number).
* Required when handling close credential actions.
* Accepts a Stellar secret key string (S...) or a Keypair instance.
*/
closeKey?: string | Keypair
signer?: Keypair | string
/** Optional fee bump signer for close/open transactions. */
feeBumpSigner?: Keypair | string
/**
* Ed25519 public key for verifying commitment signatures.
* Accepts a Stellar public key string (G...) or a Keypair instance.
Expand Down
1 change: 1 addition & 0 deletions sdk/src/channel/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export type { ChannelState } from './State.js'
export { stellar } from './Methods.js'
export { watchChannel } from './Watcher.js'
export type { ChannelEvent } from './Watcher.js'
export { resolveKeypair } from '../../signers.js'
export { Expires, Mppx, Store } from 'mppx/server'
7 changes: 2 additions & 5 deletions sdk/src/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,11 @@ export function charge(parameters: charge.Parameters) {
const network: NetworkId =
(request.methodDetails?.network as NetworkId) ?? 'testnet'
const memo = request.methodDetails?.memo as string | undefined
const feePayerKey = request.methodDetails?.feePayerKey

onProgress?.({
type: 'challenge',
recipient,
amount: fromBaseUnits(amount, decimals),
currency,
...(feePayerKey ? { feePayerKey } : {}),
})

const resolvedRpcUrl = rpcUrl ?? SOROBAN_RPC_URLS[network]
Expand Down Expand Up @@ -239,7 +236,7 @@ export function charge(parameters: charge.Parameters) {

return Credential.serialize({
challenge,
payload: { type: 'signature' as const, hash: result.hash },
payload: { type: 'hash' as const, hash: result.hash },
})
}

Expand All @@ -254,7 +251,7 @@ export function charge(parameters: charge.Parameters) {

export declare namespace charge {
type ProgressEvent =
| { type: 'challenge'; recipient: string; amount: string; currency: string; feePayerKey?: string }
| { type: 'challenge'; recipient: string; amount: string; currency: string }
| { type: 'signing' }
| { type: 'signed'; transaction: string }
| { type: 'paying' }
Expand Down
1 change: 1 addition & 0 deletions sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export {
type NetworkId,
} from './constants.js'
export { fromBaseUnits, toBaseUnits } from './Methods.js'
export { resolveKeypair } from './signers.js'
Loading
Loading