Skip to content

Commit 1bbae94

Browse files
fix: align charge module with draft-stellar-charge-00 spec (#25)
## What - Switch network identifiers to CAIP-2 format (`stellar:testnet` / `stellar:pubnet`) in `methodDetails.network` and credential `source` field; add `CAIP2_NETWORK` / `CAIP2_TO_NETWORK` constants exported from root index - Remove `reference` and `memo` fields from `methodDetails` schema - Move `source` (DID-PKH) from inside `payload` to the top-level credential field - Add `SettlementError` (exported) to distinguish broadcast/confirmation failures from verification errors - Add server-side validation for spec gaps: - Exactly one `invokeHostFunction` operation - Auth entries must not contain sub-invocations - Auth entry expiration must not exceed `challenge.expires` - Server address must not appear in auth entries or simulation transfer events - `timeBounds.maxTime` must not exceed `challenge.expires` on unsponsored path - Pre-submission simulation with CAP-46 transfer event validation - SettlementError for broadcast and confirmation failures - Derive ledger expiration from `challenge.expires` instead of a fixed timeout - Add `DEFAULT_LEDGER_CLOSE_TIME` and `DEFAULT_CHALLENGE_EXPIRY` constants ## Why The charge module had several gaps relative to `draft-stellar-charge-00`. This PR closes them to improve interoperability and security: CAIP-2 is the spec's standard chain reference format; the DID-PKH `source` must be a top-level credential field (the `mppx` `Credential` type already modeled this correctly but the client was placing it inside `payload`); the server must validate transaction structure and auth entries before broadcasting to prevent abuse.
1 parent d993bc1 commit 1bbae94

File tree

11 files changed

+627
-142
lines changed

11 files changed

+627
-142
lines changed

sdk/src/channel/server/Channel.ts

Lines changed: 48 additions & 48 deletions
Large diffs are not rendered by default.

sdk/src/charge/Methods.test.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,30 +96,31 @@ describe('Methods.charge', () => {
9696
expect(result.externalId).toBe('order-123')
9797
})
9898

99-
it('request schema accepts methodDetails with reference', () => {
99+
it('request schema accepts methodDetails with network and feePayer', () => {
100100
const result = Methods.charge.schema.request.parse({
101101
amount: '100000',
102102
currency: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC',
103103
recipient: 'GBXYZ',
104104
methodDetails: {
105-
reference: 'abc-123',
106-
network: 'testnet',
105+
network: 'stellar:testnet',
106+
feePayer: true,
107107
},
108108
})
109-
expect(result.methodDetails?.reference).toBe('abc-123')
110-
expect(result.methodDetails?.network).toBe('testnet')
109+
expect(result.methodDetails?.network).toBe('stellar:testnet')
110+
expect(result.methodDetails?.feePayer).toBe(true)
111111
})
112112

113-
it('request schema accepts methodDetails with feePayer', () => {
113+
it('request schema accepts methodDetails with network only', () => {
114114
const result = Methods.charge.schema.request.parse({
115115
amount: '100000',
116116
currency: 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC',
117117
recipient: 'GBXYZ',
118118
methodDetails: {
119-
feePayer: true,
119+
network: 'stellar:pubnet',
120120
},
121121
})
122-
expect(result.methodDetails?.feePayer).toBe(true)
122+
expect(result.methodDetails?.network).toBe('stellar:pubnet')
123+
expect(result.methodDetails?.feePayer).toBeUndefined()
123124
})
124125

125126
it('request schema allows omitting methodDetails', () => {
@@ -137,7 +138,9 @@ describe('Methods.charge', () => {
137138
hash: 'abc123',
138139
})
139140
expect(result.type).toBe('hash')
140-
expect(result.hash).toBe('abc123')
141+
if (result.type === 'hash') {
142+
expect(result.hash).toBe('abc123')
143+
}
141144
})
142145

143146
it('credential payload accepts transaction type (pull)', () => {
@@ -146,6 +149,8 @@ describe('Methods.charge', () => {
146149
transaction: 'AAAA...',
147150
})
148151
expect(result.type).toBe('transaction')
149-
expect(result.transaction).toBe('AAAA...')
152+
if (result.type === 'transaction') {
153+
expect(result.transaction).toBe('AAAA...')
154+
}
150155
})
151156
})

sdk/src/charge/Methods.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,11 @@ export const charge = Method.from({
3737
description: z.optional(z.string()),
3838
/** Merchant-provided reconciliation ID (e.g. order ID, invoice number). */
3939
externalId: z.optional(z.string()),
40-
/** Method-specific details injected by the server. */
40+
/** Method-specific details injected by the server via request(). */
4141
methodDetails: z.optional(
4242
z.object({
43-
/** Server-generated unique tracking ID. */
44-
reference: z.optional(z.string()),
45-
/** Stellar network identifier ("public" | "testnet"). */
46-
network: z.optional(z.string()),
47-
/** Optional memo text to attach to the transaction. */
48-
memo: z.optional(z.string()),
43+
/** CAIP-2 network identifier (e.g. "stellar:testnet", "stellar:pubnet"). */
44+
network: z.string(),
4945
/** Whether the server will sponsor transaction fees. */
5046
feePayer: z.optional(z.boolean()),
5147
}),

sdk/src/charge/client/Charge.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Keypair } from '@stellar/stellar-sdk'
22
import { describe, expect, it } from 'vitest'
33
import { charge } from './Charge.js'
4+
import { CAIP2_TO_NETWORK } from '../../constants.js'
45

56
const TEST_KEYPAIR = Keypair.random()
67

@@ -55,4 +56,42 @@ describe('stellar client charge', () => {
5556
const method = charge({ keypair: TEST_KEYPAIR, mode: 'push' })
5657
expect(method.name).toBe('stellar')
5758
})
59+
60+
it('throws when neither keypair nor secretKey is provided', () => {
61+
expect(() => charge({} as any)).toThrow('Either keypair or secretKey must be provided')
62+
})
63+
})
64+
65+
describe('CAIP-2 network mapping', () => {
66+
it('maps stellar:testnet to testnet', () => {
67+
expect(CAIP2_TO_NETWORK['stellar:testnet']).toBe('testnet')
68+
})
69+
70+
it('maps stellar:pubnet to public', () => {
71+
expect(CAIP2_TO_NETWORK['stellar:pubnet']).toBe('public')
72+
})
73+
74+
it('returns undefined for unknown CAIP-2 identifiers', () => {
75+
expect(CAIP2_TO_NETWORK['stellar:unknown']).toBeUndefined()
76+
})
77+
})
78+
79+
describe('DID-PKH format', () => {
80+
it('constructs correct DID-PKH from CAIP-2 network and public key', () => {
81+
const kp = Keypair.random()
82+
const caip2Network = 'stellar:testnet'
83+
const caip2Component = caip2Network.split(':')[1] ?? 'testnet'
84+
const source = `did:pkh:stellar:${caip2Component}:${kp.publicKey()}`
85+
86+
expect(source).toMatch(/^did:pkh:stellar:testnet:G[A-Z0-9]{55}$/)
87+
})
88+
89+
it('uses pubnet component for mainnet', () => {
90+
const kp = Keypair.random()
91+
const caip2Network = 'stellar:pubnet'
92+
const caip2Component = caip2Network.split(':')[1] ?? 'testnet'
93+
const source = `did:pkh:stellar:${caip2Component}:${kp.publicKey()}`
94+
95+
expect(source).toMatch(/^did:pkh:stellar:pubnet:G[A-Z0-9]{55}$/)
96+
})
5897
})

sdk/src/charge/client/Charge.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
BASE_FEE,
55
Contract,
66
Keypair,
7-
Memo,
87
TransactionBuilder,
98
authorizeEntry,
109
nativeToScVal,
@@ -15,7 +14,9 @@ import { Credential, Method } from 'mppx'
1514
import { z } from 'zod/mini'
1615
import {
1716
ALL_ZEROS,
17+
CAIP2_TO_NETWORK,
1818
DEFAULT_DECIMALS,
19+
DEFAULT_LEDGER_CLOSE_TIME,
1920
DEFAULT_TIMEOUT,
2021
NETWORK_PASSPHRASE,
2122
SOROBAN_RPC_URLS,
@@ -85,8 +86,10 @@ export function charge(parameters: charge.Parameters) {
8586
async createCredential({ challenge, context }) {
8687
const { request } = challenge
8788
const { amount, currency, recipient } = request
88-
const network: NetworkId = (request.methodDetails?.network as NetworkId) ?? 'testnet'
89-
const memo = request.methodDetails?.memo as string | undefined
89+
90+
const caip2Network = request.methodDetails?.network ?? 'stellar:testnet'
91+
const network: NetworkId = CAIP2_TO_NETWORK[caip2Network] ?? 'testnet'
92+
9093
onProgress?.({
9194
type: 'challenge',
9295
recipient,
@@ -112,6 +115,10 @@ export function charge(parameters: charge.Parameters) {
112115
)
113116
}
114117

118+
const expiresTimestamp: number | undefined = challenge.expires
119+
? Math.floor(new Date(challenge.expires).getTime() / 1000)
120+
: undefined
121+
115122
if (isServerSponsored) {
116123
// ── Spec-compliant sponsored path ──────────────────────────────────
117124
// Client uses an all-zeros source account so the server can swap in
@@ -128,20 +135,27 @@ export function charge(parameters: charge.Parameters) {
128135
const sponsoredBuilder = new TransactionBuilder(placeholderSource, {
129136
fee: BASE_FEE,
130137
networkPassphrase,
131-
})
132-
.addOperation(transferOp)
133-
.setTimeout(timeout)
138+
}).addOperation(transferOp)
134139

135-
if (memo) {
136-
sponsoredBuilder.addMemo(Memo.text(memo))
140+
if (expiresTimestamp) {
141+
sponsoredBuilder.setTimebounds(0, expiresTimestamp)
142+
} else {
143+
sponsoredBuilder.setTimeout(timeout)
137144
}
138145

139146
const unsignedTx = sponsoredBuilder.build()
140147
const prepared = await server.prepareTransaction(unsignedTx)
141148

142-
// Determine auth-entry expiry from the current ledger sequence
143149
const latestLedger = await server.getLatestLedger()
144-
const validUntilLedger = latestLedger.sequence + Math.ceil(timeout / 5) + 10
150+
let validUntilLedger: number
151+
if (expiresTimestamp) {
152+
const nowSecs = Math.floor(Date.now() / 1000)
153+
const secsUntilExpiry = Math.max(expiresTimestamp - nowSecs, 0)
154+
validUntilLedger =
155+
latestLedger.sequence + Math.ceil(secsUntilExpiry / DEFAULT_LEDGER_CLOSE_TIME)
156+
} else {
157+
validUntilLedger = latestLedger.sequence + Math.ceil(timeout / 5) + 10
158+
}
145159

146160
onProgress?.({ type: 'signing' })
147161

@@ -173,9 +187,13 @@ export function charge(parameters: charge.Parameters) {
173187
const signedXdr = prepared.toEnvelope().toXDR('base64')
174188
onProgress?.({ type: 'signed', transaction: signedXdr })
175189

190+
const didComponent = network === 'public' ? 'pubnet' : network
191+
const source = `did:pkh:stellar:${didComponent}:${keypair.publicKey()}`
192+
176193
return Credential.serialize({
177194
challenge,
178195
payload: { type: 'transaction' as const, transaction: signedXdr },
196+
source,
179197
})
180198
}
181199

@@ -194,12 +212,12 @@ export function charge(parameters: charge.Parameters) {
194212
const builder = new TransactionBuilder(sourceAccount, {
195213
fee: BASE_FEE,
196214
networkPassphrase,
197-
})
198-
.addOperation(transferOp)
199-
.setTimeout(timeout)
215+
}).addOperation(transferOp)
200216

201-
if (memo) {
202-
builder.addMemo(Memo.text(memo))
217+
if (expiresTimestamp) {
218+
builder.setTimebounds(0, expiresTimestamp)
219+
} else {
220+
builder.setTimeout(timeout)
203221
}
204222

205223
const transaction = builder.build()
@@ -213,6 +231,9 @@ export function charge(parameters: charge.Parameters) {
213231
const signedXdr = prepared.toXDR()
214232
onProgress?.({ type: 'signed', transaction: signedXdr })
215233

234+
const didComponent = network === 'public' ? 'pubnet' : network
235+
const source = `did:pkh:stellar:${didComponent}:${keypair.publicKey()}`
236+
216237
if (effectiveMode === 'push') {
217238
// Client broadcasts
218239
onProgress?.({ type: 'paying' })
@@ -231,13 +252,15 @@ export function charge(parameters: charge.Parameters) {
231252
return Credential.serialize({
232253
challenge,
233254
payload: { type: 'hash' as const, hash: result.hash },
255+
source,
234256
})
235257
}
236258

237259
// Pull mode: send signed XDR for server to broadcast
238260
return Credential.serialize({
239261
challenge,
240262
payload: { type: 'transaction' as const, transaction: signedXdr },
263+
source,
241264
})
242265
},
243266
})

sdk/src/charge/integration.test.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ function mockChallenge(overrides: Record<string, unknown> = {}) {
2121
currency: USDC_SAC_TESTNET,
2222
recipient: RECIPIENT.publicKey(),
2323
methodDetails: {
24-
reference: crypto.randomUUID(),
25-
network: 'testnet',
24+
network: 'stellar:testnet',
2625
},
2726
...overrides,
2827
},
@@ -121,3 +120,88 @@ describe('credential type validation', () => {
121120
expect(serialized).toContain('Payment')
122121
})
123122
})
123+
124+
describe('spec compliance: credential structure', () => {
125+
it('source is a top-level credential field, not inside payload', () => {
126+
const challenge = mockChallenge()
127+
const source = `did:pkh:stellar:testnet:${SENDER.publicKey()}`
128+
129+
const serialized = Credential.serialize({
130+
challenge,
131+
payload: { type: 'transaction' as const, transaction: 'AAAA' },
132+
source,
133+
})
134+
135+
const deserialized = Credential.deserialize(serialized)
136+
137+
expect(deserialized.source).toBe(source)
138+
139+
const payload = deserialized.payload as Record<string, unknown>
140+
expect(payload.source).toBeUndefined()
141+
})
142+
143+
it('source roundtrips through serialize/deserialize for hash type', () => {
144+
const challenge = mockChallenge()
145+
const source = `did:pkh:stellar:testnet:${SENDER.publicKey()}`
146+
147+
const serialized = Credential.serialize({
148+
challenge,
149+
payload: { type: 'hash' as const, hash: 'tx-hash-abc' },
150+
source,
151+
})
152+
153+
const deserialized = Credential.deserialize(serialized)
154+
expect(deserialized.source).toBe(source)
155+
156+
const payload = deserialized.payload as Record<string, unknown>
157+
expect(payload.source).toBeUndefined()
158+
expect(payload.type).toBe('hash')
159+
expect(payload.hash).toBe('tx-hash-abc')
160+
})
161+
162+
it('credential without source omits the field entirely', () => {
163+
const challenge = mockChallenge()
164+
165+
const serialized = Credential.serialize({
166+
challenge,
167+
payload: { type: 'transaction' as const, transaction: 'AAAA' },
168+
})
169+
170+
const deserialized = Credential.deserialize(serialized)
171+
expect(deserialized.source).toBeUndefined()
172+
})
173+
174+
it('source follows DID-PKH format for Stellar', () => {
175+
const kp = Keypair.random()
176+
const source = `did:pkh:stellar:testnet:${kp.publicKey()}`
177+
178+
expect(source).toMatch(/^did:pkh:stellar:(testnet|pubnet):G[A-Z2-7]{55}$/)
179+
})
180+
181+
it('payload.type discriminator is preserved without source contamination', () => {
182+
const challenge = mockChallenge()
183+
const source = `did:pkh:stellar:testnet:${SENDER.publicKey()}`
184+
185+
const txSerialized = Credential.serialize({
186+
challenge,
187+
payload: { type: 'transaction' as const, transaction: 'AAAA' },
188+
source,
189+
})
190+
const txDeserialized = Credential.deserialize(txSerialized)
191+
const txPayload = txDeserialized.payload as { type: string; transaction?: string }
192+
expect(txPayload.type).toBe('transaction')
193+
expect(txPayload.transaction).toBe('AAAA')
194+
expect(Object.keys(txPayload).sort()).toEqual(['transaction', 'type'])
195+
196+
const hashSerialized = Credential.serialize({
197+
challenge,
198+
payload: { type: 'hash' as const, hash: 'deadbeef' },
199+
source,
200+
})
201+
const hashDeserialized = Credential.deserialize(hashSerialized)
202+
const hashPayload = hashDeserialized.payload as { type: string; hash?: string }
203+
expect(hashPayload.type).toBe('hash')
204+
expect(hashPayload.hash).toBe('deadbeef')
205+
expect(Object.keys(hashPayload).sort()).toEqual(['hash', 'type'])
206+
})
207+
})

0 commit comments

Comments
 (0)