Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 21 additions & 2 deletions sdk/src/charge/Methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,16 +145,35 @@ describe('Methods.charge', () => {
})

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

it('credential payload rejects invalid hash format', () => {
expect(() =>
Methods.charge.schema.credential.payload.parse({
type: 'hash',
hash: 'abc123',
}),
).toThrow()
})

it('credential payload rejects hash that is not hex', () => {
expect(() =>
Methods.charge.schema.credential.payload.parse({
type: 'hash',
hash: 'zzzz' + '0'.repeat(60),
}),
).toThrow()
})

it('credential payload accepts transaction type (pull)', () => {
const result = Methods.charge.schema.credential.payload.parse({
type: 'transaction',
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/charge/Methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('hash') }),
z.object({ hash: z.string().check(z.regex(/^[0-9a-f]{64}$/i)), 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 Down
70 changes: 70 additions & 0 deletions sdk/src/charge/server/Charge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,76 @@ describe('charge hash format validation', () => {
})
})

describe('charge push-mode: single lookup (no polling)', () => {
it('rejects when transaction is NOT_FOUND on-chain', async () => {
mockGetTransaction.mockResolvedValueOnce({ status: 'NOT_FOUND' })

const method = charge({
recipient: RECIPIENT,
currency: USDC_SAC_TESTNET,
store: Store.memory(),
})
const cred = makeHashCredential({
hash: 'a'.repeat(64),
source: `did:pkh:stellar:testnet:${Keypair.random().publicKey()}`,
})

await expect(
method.verify({ credential: cred as any, request: cred.challenge.request }),
).rejects.toThrow('Transaction not found on-chain')
})

it('rejects when transaction FAILED on-chain', async () => {
mockGetTransaction.mockResolvedValueOnce({ status: 'FAILED' })

const method = charge({
recipient: RECIPIENT,
currency: USDC_SAC_TESTNET,
store: Store.memory(),
})
const cred = makeHashCredential({
hash: 'b'.repeat(64),
source: `did:pkh:stellar:testnet:${Keypair.random().publicKey()}`,
})

await expect(
method.verify({ credential: cred as any, request: cred.challenge.request }),
).rejects.toThrow('Transaction failed on-chain')
})

it('does not hold a semaphore slot for push-mode lookups', async () => {
// Fake hashes should be rejected instantly without consuming semaphore
// slots — this is the core fix for the DoS vector.
mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' })

const method = charge({
recipient: RECIPIENT,
currency: USDC_SAC_TESTNET,
store: Store.memory(),
pollMaxConcurrent: 1, // only 1 semaphore slot
})

// Fire 5 concurrent requests with different fake hashes — all must
// reject promptly instead of queueing behind a semaphore.
const start = Date.now()
const results = await Promise.allSettled(
Array.from({ length: 5 }, (_, i) => {
const cred = makeHashCredential({
hash: `${i}`.repeat(64).slice(0, 64),
source: `did:pkh:stellar:testnet:${Keypair.random().publicKey()}`,
})
return method.verify({ credential: cred as any, request: cred.challenge.request })
}),
)
const elapsed = Date.now() - start

// All should be rejected (NOT_FOUND)
expect(results.every((r) => r.status === 'rejected')).toBe(true)
// Should complete quickly — no 20s poll timeout per request
expect(elapsed).toBeLessThan(2000)
})
})

describe('charge DoS prevention: no global serial lock', () => {
it('processes concurrent verify calls in parallel, not serially', async () => {
// Regression test: verifyLock was removed to prevent head-of-line blocking.
Expand Down
24 changes: 18 additions & 6 deletions sdk/src/charge/server/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,24 @@ export function charge(parameters: charge.Parameters) {
}
releaseClaim(store, hashKey)

const txResult = await pollTransaction(rpcServer, hash, {
maxAttempts: pollMaxAttempts,
delayMs: pollDelayMs,
timeoutMs: pollTimeoutMs,
semaphore: pollSemaphore,
})
// Push mode requires the transaction to be confirmed on-chain
// before the client submits the hash. A single lookup replaces the
// unbounded poll loop, eliminating the semaphore-exhaustion DoS
// vector (an attacker can no longer hold slots with fake hashes).
Comment thread
marcelosalloum marked this conversation as resolved.
Outdated
const result = await rpcServer.getTransaction(hash)

if (result.status === 'FAILED') {
throw new PaymentVerificationError(`${LOG_PREFIX} Transaction failed on-chain.`, { hash })
Comment thread
marcelosalloum marked this conversation as resolved.
Outdated
}

if (result.status !== 'SUCCESS') {
throw new PaymentVerificationError(
`${LOG_PREFIX} Transaction not found on-chain. Push mode requires the transaction to be confirmed before submitting the hash.`,
{ hash, status: result.status },
)
}

const txResult = result as rpc.Api.GetSuccessfulTransactionResponse

// Extract the payer's public key from the credential DID to verify
// the on-chain transfer's `from` address matches the credential's
Expand Down
Loading