Date: May 29, 2026
Scope: Intent types, SEP-38, quotes, test patterns, deadline validation, floor validation
- types/intent.ts — DOES NOT EXIST (planned for v1.2)
- Intent types are documented in
docs/ARCHITECTURE.mdbut not yet in code
// docs/ARCHITECTURE.md — lines 126-140
interface Intent {
version: 1
nonce: string // 128-bit random, replay protection
account: string // user's Stellar public key
corridor: `${string}-${string}` // e.g. 'usdc-ngn'
sellAsset: { code: string; issuer: string }
sellAmount: string // decimal string
buyAsset: { code: string } // fiat, e.g. 'NGN'
minReceive: string // floor on delivered amount
deliveryHint: DeliveryHint // bank / mobile-money / cash pickup
deadline: string // RFC3339
preferences?: {
allowSplit: boolean // default: true
maxAnchors: number // default: 2
preferAnchorIds?: string[] // user whitelist
}
}
interface SignedIntent {
intent: Intent
intentHash: string // sha-256 over canonical JSON
signature: string // ed25519 over intentHash, by account
}
// Also planned: Plan and Outcome types (for router output and reputation writes)
interface Plan {
// Single or split-anchor quote with scores
anchors: Array<{
anchorId: string
amount: string
quoteId: string
score: number
expiresAt: Date
}>
}
interface Outcome {
// Reputation write tuple
intentHash: string
anchorId: string
corridorId: string
quotedRate: number
deliveredRate: number
quotedAmount: string
deliveredAmount: string
settleSeconds: number
outcome: 'completed' | 'refunded' | 'error'
stellarTxId: string
timestamp: Date
disputed: boolean
}- lib/intent/canonical.ts — Deterministic JSON canonicalization for hashing (PLANNED v1.2)
- lib/router/ — Intent router & solver (PLANNED v1.2)
- lib/publisher/ — Outcome publisher to Soroban (PLANNED v2)
- lib/stellar/sep38.ts — DOES NOT EXIST (planned for v1.1)
- Current implementation uses SEP-24
/fee+ cached rates
From ARCHITECTURE.md § 4.5:
// lib/stellar/sep38.ts (planned)
// POST /sep38/quote
interface Sep38QuoteRequest {
sell_asset: string // e.g. 'USDC-...'
sell_amount: string
buy_asset: string // e.g. 'NGN' (fiat)
context: 'sep24' // operation context
}
interface Sep38QuoteResponse {
id: string // quote ID, passed to SEP-24 interactive
price: number // local currency units per 1 USDC
expires_at: string // RFC3339
total_price: number // price with fees
fee: {
details?: Array<{ name: string; amount: string }>
total: string // total fee in sell asset
}
}- SEP-38 RFQ is parallel across all candidates on a corridor
- Returned quote
idis then passed toPOST /transactions/withdraw/interactiveto bind the anchor contractually to the price - This is the evolution from "useful rate page" to "firm execution layer"
// lines 1-20
interface Anchor {
id: string
name: string
homeDomain: string
corridors: string[] // corridor IDs this anchor serves
assetCode: string
assetIssuer: string
}
interface Corridor {
id: string // e.g. 'usdc-ngn'
from: string // asset code, e.g. 'USDC'
to: string // fiat currency code, e.g. 'NGN'
countryCode: string // ISO 3166-1 alpha-2
countryName: string
}// lines 22-46
interface AnchorRate {
anchorId: string
anchorName: string
corridorId: string
fee: number | null // flat fee in USDC
feeType: 'flat' | 'percent' | 'combined'
exchangeRate: number | null // local currency units per 1 USDC
totalReceived: number | null // computed: (amount - fee) * exchangeRate
updatedAt: Date
source: 'sep38' | 'sep24-fee' | 'unavailable'
}
interface RateComparison {
corridorId: string
rates: AnchorRate[]
bestRateId: string // anchorId of highest totalReceived
}// lines 48-82
interface AnchorCapabilities {
sep10: boolean
sep24: boolean
sep38: boolean // indicates ANCHOR_QUOTE_SERVER exists
sep12: boolean
}
interface Sep1TomlData {
domain: string
TRANSFER_SERVER_SEP0024: string | null
ANCHOR_QUOTE_SERVER: string | null // indicates SEP-38 support
WEB_AUTH_ENDPOINT: string | null
SIGNING_KEY: string | null
NETWORK_PASSPHRASE: string | null
CURRENCIES: Array<{ code: string; issuer?: string }>
capabilities: AnchorCapabilities
}
interface Sep10Auth {
jwt: string
anchorDomain: string
publicKey: string
expiresAt: Date // Expires field for JWT validation
}// lines 100-152
type WithdrawStatusValue =
| 'incomplete'
| 'pending_user_transfer_start'
| 'pending_user_transfer_complete'
| 'pending_external'
| 'pending_anchor'
| 'pending_stellar'
| 'pending_trust'
| 'pending_user'
| 'completed'
| 'refunded'
| 'error'
| 'no_market' // ← Floor violations detected via status
| 'too_small' // ← Floor validation feedback
| 'too_large'
| 'expired' // ← Deadline violation feedback
interface Sep24Transaction {
id: string
status: WithdrawStatusValue
amountIn?: string
amountInAsset?: string
amountOut?: string
amountOutAsset?: string
amountFee?: string
updatedAt: Date
stellarTransactionId?: string
externalTransactionId?: string
refunds?: Sep24Refunds
}- Location: Anchor onboarding templates mention "Floor: ₦1,500" for NGN corridors
- Mechanism: Anchors return status
too_smallwhen delivered amount falls below minimum - SEP-24 Status Handler:
tests/sep24-status-map.spec.tsmaps anchor statuses to app canonical statuses - Method: Floor is part of negotiated quote terms; anchor enforces server-side
Example from .github/ISSUE_TEMPLATE/anchor-onboard.yml (line 135):
Flat $0.50 USDC + 0.5% spread over Binance mid-market. Floor: ₦1,500
- Location: Types in
Sep10Auth.expiresAtand plannedIntent.deadline - Current Use: JWT expires tracked locally; no server-side deadline enforcement yet
- Planned (v1.2): Intent contains RFC3339
deadlinefield; router will reject quotes after deadline
Location: lib/stellar/sep24.ts lines 10-13
export const TERMINAL_STATES: ReadonlySet<WithdrawStatusValue> = new Set([
'completed',
'error',
'refunded',
'expired', // ← Deadline expired
'no_market', // ← Floor/market validation failed
'too_small', // ← Floor violation
'too_large',
])Framework: Vitest with vi.mock(), vi.spyOn(), vi.stubGlobal()
Setup File: tests/setup.ts
// Established testing library dependencies
import '@testing-library/jest-dom'Example Pattern from tests/lib/sep24.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest'
beforeEach(() => {
vi.restoreAllMocks() // Clean state between tests
})
describe('fetchAnchorFee', () => {
it('constructs the correct fee URL with all query parameters', async () => {
let capturedUrl = ''
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
capturedUrl = url
return { ok: true, json: async () => ({ fee: '2.00' }) }
}))
await fetchAnchorFee(params)
expect(capturedUrl).toContain('operation=withdraw')
})
})SEP-1 TOML Fixtures: tests/fixtures/sep1/
bitso.tomlcowrie.tomlflutterwave.tomlmoneygram.tomlmychoice.tomltempo.toml
Mock Anchor Factory Pattern (from tests/rate-ranking.spec.ts):
function createMockRate(
anchorId: string,
totalReceived: number,
overrides?: Partial<AnchorRate>
): AnchorRate {
return {
anchorId,
anchorName: `Anchor ${anchorId}`,
corridorId: 'usdc-ngn',
fee: 2.5,
feeType: 'flat',
exchangeRate: 1580,
totalReceived,
source: 'sep24-fee',
updatedAt: new Date(),
...overrides,
}
}Framework: fast-check
Example from tests/compute-total.spec.ts:
import fc from 'fast-check'
describe('computeTotalReceived - Property-Based Tests', () => {
const amountArb = fc.double({ min: 0, max: 1_000_000, noNaN: true })
const feeArb = fc.double({ min: 0, max: 100_000, noNaN: true })
it('total received is always non-negative', () => {
fc.assert(
fc.property(amountArb, feeArb, (amount, fee) => {
const result = computeTotalReceived(amount, fee, 0, 1580)
expect(result).toBeGreaterThanOrEqual(0)
}),
{ numRuns: 1000 }
)
})
})File: tests/intent-e2e.spec.ts
// End-to-end: builds payment, signs with Freighter mock, verifies on horizon
const INTENT_FIXTURE = {
sourcePublicKey: sourceKeypair.publicKey(),
anchorAccount: Keypair.random().publicKey(),
amount: '42.5',
memo: 'intent-memo',
memoType: 'text',
assetCode: 'USDC',
assetIssuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
}
describe('Intent end-to-end round trip', () => {
it('builds a withdrawal intent, signs it with Freighter, verifies it on server', async () => {
vi.spyOn(horizonServer, 'loadAccount').mockResolvedValue(mockAccount)
vi.spyOn(horizonServer, 'fetchBaseFee').mockResolvedValue(100)
// ... mock Freighter, verify signatures
})
})From: tests/rate-ranking.spec.ts
describe('computeRateComparison — sort stability', () => {
it('returns the same bestRateId given identical inputs', () => {
const comparison1 = computeRateComparison(results, 'usdc-ngn')
const comparison2 = computeRateComparison(results, 'usdc-ngn')
expect(comparison1.bestRateId).toBe(comparison2.bestRateId)
})
})
describe('computeRateComparison — monotonicity', () => {
it('ensures bestRateId has the highest totalReceived value', () => {
// Verify that the selected anchor has max rate across all results
})
})Location: Types use expiresAt: Date but no global deadline constant yet
Planned (v1.2): Will be added per-corridor or globally in constants/
Current: Floor is part of anchor's SEP-38 quote response
Planned: Global validation thresholds in constants/index.ts
Location: lib/utils.ts
export function computeTotalReceived(
amount: number,
fee: number,
feePercent: number,
exchangeRate: number
): number {
const afterFlat = Math.max(0, amount - fee)
const afterPercent = afterFlat * (1 - feePercent / 100)
return afterPercent * exchangeRate
}
export function formatCurrency(amount: number, currencyCode: string): string {
// Intl.NumberFormat for localized display
}
export function formatRate(rate: number, from: string, to: string): string {
// e.g. "1 USDC = 1,580 NGN"
}Location: lib/stellar/errors.ts
export class SepError extends Error {
code: string
httpStatus: number
raw: unknown
// Parses anchor error responses
}
export function parseSepErrorBody(body: unknown, status: number): SepError {
// Normalizes { error: string, code: string } | { error: { message } } | string
}score(anchor, amount) =
+ grossRate(anchor, amount) // SEP-38 firm rate
- feeFrac(anchor, amount) // anchor fee as % of sell
- slippagePenalty(anchor, amount) // historical quote → delivered delta
- failurePenalty(anchor) // (1 − fillRate) from oracle
× settlementDiscount(anchor) // 1 / (1 + latency_hours × λ)
Split is proposed only when:
- At least 2 anchors quoted in last 30 seconds
- Split score > best single score − fixed fee of second leg
- Both anchors have
fillRate > 0.9(floor default)
Planned Location: lib/router/solver.ts
Inputs: Draft Intent + live SEP-38 quotes (parallel RFQ)
Output: Plan (single or split)
Oracle Dependency: Reads fillRate, slippagePenalty, latency from Soroban
| Path | Purpose |
|---|---|
types/index.ts |
Anchor, Corridor, AnchorRate, WithdrawStatus, SEP auth types |
lib/stellar/sep1.ts |
stellar.toml resolver, ANCHOR_QUOTE_SERVER extraction |
lib/stellar/sep10.ts |
Challenge → sign → JWT flow, mainnet pinning |
lib/stellar/sep24.ts |
/fee, /interactive, /transaction clients |
lib/stellar/horizon.ts |
Build + sign + submit user payment |
lib/stellar/anchors.ts |
Registry + SEP-1 resolution helpers |
lib/utils.ts |
computeTotalReceived(), formatting helpers |
constants/anchors.ts |
ANCHORS[], CORRIDORS[] registry |
tests/compute-total.spec.ts |
Property-based tests for fee math |
tests/rate-ranking.spec.ts |
Sort stability, monotonicity, determinism |
tests/intent-e2e.spec.ts |
Freighter mock, payment signing, Horizon submit |
tests/fixtures/sep1/ |
Anchor TOML fixtures |
| Path | Status | Purpose |
|---|---|---|
types/intent.ts |
v1.2 | Intent, SignedIntent, Plan, Outcome schemas |
lib/intent/canonical.ts |
v1.2 | Deterministic JSON → hash → sign |
lib/stellar/sep38.ts |
v1.1 | SEP-38 firm-quote RFQ (parallel) |
lib/router/ |
v1.2 | Intent router: scoring, splitting, solving |
lib/publisher/ |
v2 | Outcome publisher → Soroban contract |
contracts/oracle/ |
v2 | Soroban oracle: store + read reputation |
packages/mcp/ |
v4 | MCP server: tools for agents |
packages/sdk/ |
v4 | TypeScript client + types |
- No user keys held — Signing in Freighter (web) or caller's wallet (agent); none transmitted
- No user funds held — Payment flows directly user → anchor; no intermediary
- Network pinned — SEP-10 challenge must be mainnet or rejected
- Outcomes user-witnessed — Every reputation write references user-signed intent_hash
- Publisher cannot invent — Signs transport, not content; contract verifies on-ledger existence
- Router is deterministic given same inputs + oracle snapshot
- Replay audit trail: input intent → signature → quote ID → outcome
- Create types/intent.ts with Intent, SignedIntent, Plan, Outcome
- Create lib/intent/canonical.ts for deterministic JSON hashing
- Create lib/stellar/sep38.ts with parallel RFQ logic
- Create lib/router/ with scoring, determinism, and split logic
- Write comprehensive tests matching patterns established in sep24, rate-ranking, intent-e2e
- Define deadline & floor constants in constants/ once router requires them
Generated: 2026-05-29
Codebase: stellar-intel v1.0 (main)