diff --git a/backend/src/services/invoice.service.ts b/backend/src/services/invoice.service.ts index cf6a86d..3c5c43e 100644 --- a/backend/src/services/invoice.service.ts +++ b/backend/src/services/invoice.service.ts @@ -454,6 +454,7 @@ export class InvoiceService { const query = ` INSERT INTO payment_events (invoice_id, event_type, event_data) VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING `; await this.db.query(query, [invoiceId, eventType, JSON.stringify(eventData)]); diff --git a/backend/src/services/payment-monitor.service.ts b/backend/src/services/payment-monitor.service.ts index f8d4cf2..85674a7 100644 --- a/backend/src/services/payment-monitor.service.ts +++ b/backend/src/services/payment-monitor.service.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import stellarService, { PaymentPageRecord, PaymentRecord } from './stellar.service'; +import stellarService, { ClaimableBalanceRecord, PaymentPageRecord, PaymentRecord } from './stellar.service'; import invoiceService, { InvoiceService, Queryable } from './invoice.service'; import { SELLER_PUBLIC_KEY, STELLAR_NETWORK } from '../config/stellar'; import { pool } from '../config/database'; @@ -8,7 +8,6 @@ import { canonicalAmount } from '../utils/safe-amount-compare'; import { PaymentClaimError } from '../domain/payment-attribution'; import { IllegalStateTransitionError } from '../domain/invoice-lifecycle'; import { - parseSettlementTime, SettlementTimeUnavailableError, } from '../domain/invoice-settlement'; import { monitorBackoffMs } from '../utils/monitor-retry-backoff'; @@ -29,6 +28,7 @@ import { export interface PaymentPageSource { getPaymentsPage(account: string, cursor: string, limit: number): Promise; getLatestPaymentCursor(account: string): Promise; + getTransaction: typeof stellarService.getTransaction; } export interface MonitorInvoiceService { @@ -445,6 +445,7 @@ export class PaymentMonitorService { let cursor = checkpoint.cursor; let processed = 0; + const handledTransactions = new Set(); for (let pageNumber = 0; pageNumber < this.maxPagesPerRun; pageNumber += 1) { // getPaymentsPage goes through horizonCall; a timeout/429/connection // failure throws and tick() applies the shared backoff before any @@ -459,10 +460,15 @@ export class PaymentMonitorService { continue; } - if (record.payment) { + if (record.payment && !handledTransactions.has(record.payment.txHash)) { + // A complete transaction is verified once even when several of its + // operations occur in this run. Each record still earns a checkpoint. await this.handlePayment(record.payment); + handledTransactions.add(record.payment.txHash); } + if (record.claimableBalance) await this.handleClaimableBalance(record.claimableBalance); + await this.checkpoints.save({ account: this.account, network: this.network, @@ -496,6 +502,22 @@ export class PaymentMonitorService { return { processed, cursor, bootstrapped: false }; } + private async handleClaimableBalance(balance: ClaimableBalanceRecord): Promise { + if (!balance.memo || balance.memoType?.toLowerCase().replace(/^memo_/, '') !== 'text') return; + const invoice = await this.invoices.getInvoiceByMemo(balance.memo); + if (!invoice || invoice.status !== 'PENDING' || invoice.sellerPublicKey !== balance.claimant) return; + // Storage enforces uniqueness by invoice + balance id across cursor replay. + // This is a claim opportunity, never proof that funds reached the seller. + await this.invoices.logPaymentEvent(invoice.id, 'CLAIMABLE_BALANCE_RECEIVED', { + balanceId: balance.balanceId, + amount: balance.amount, + asset: balance.asset, + predicate: balance.predicate, + txHash: balance.txHash, + source: 'payment-monitor', + }); + } + private async handlePayment(payment: PaymentRecord): Promise { if (!payment.memo) return; @@ -534,7 +556,7 @@ export class PaymentMonitorService { // Payment records only reach here after Horizon returned them through // horizonCall. tick() classifies timeout/429/connection failures and // backs off — this compare never runs on an outage (issue #556). - const isNative = payment.assetCode === 'XLM' && !payment.assetIssuer; + const txDetails = await this.source.getTransaction(payment.txHash, payment.transaction); const startedAt = Date.now(); const context = this.buildLogContext(); emitEvent('info', 'payment.verify.started', context, { @@ -545,17 +567,9 @@ export class PaymentMonitorService { const verification = verifyHorizonPayment({ txHash: payment.txHash, - network: this.network, - transaction: { memo: payment.memo, memo_type: payment.memoType }, - operations: [{ - type: 'payment', - from: payment.from, - to: payment.to, - amount: payment.amount, - asset_type: isNative ? 'native' : 'credit_alphanum12', - asset_code: isNative ? undefined : payment.assetCode, - asset_issuer: payment.assetIssuer, - }], + network: txDetails.network ?? this.network, + transaction: txDetails.transaction, + operations: txDetails.operations, expected: { memo: invoice.memo, amount: invoice.amount, @@ -585,22 +599,24 @@ export class PaymentMonitorService { expectedAmount: canonicalAmount(invoice.amount) ?? String(invoice.amount), receivedAmount: payment.amount, payerPublicKey: payment.from, + operationType: payment.operationType, } ); return; } - const settledAt = parseSettlementTime(payment.createdAt) ?? verification.value.settledAt; + const settledAt = verification.value.settledAt; if (!settledAt) { throw new SettlementTimeUnavailableError(); } try { - await this.saveTransaction(payment, invoice.id); + const selectedPayment = { ...payment, ...verification.value }; + await this.saveTransaction(selectedPayment, invoice.id); await this.invoices.markAsPaid( invoice.id, payment.txHash, - payment.from, + verification.value.from, undefined, { settledAt, destinationMuxedId: verification.value.toMuxedId } ); diff --git a/backend/src/services/payment-verification.ts b/backend/src/services/payment-verification.ts index 3827ef0..049103a 100644 --- a/backend/src/services/payment-verification.ts +++ b/backend/src/services/payment-verification.ts @@ -146,6 +146,8 @@ export interface HorizonTransactionLike { export interface HorizonOperationLike { type: string; + account?: string; + into?: string; from?: string; to?: string; amount?: string; @@ -416,7 +418,12 @@ export function verifyHorizonPayment(input: VerifyPaymentInput): VerificationRes const selection = selectInvoicePaymentOperation(operations, expected.destination); if (selection.kind === 'none') { - return failure('NO_PAYMENT_OPERATION'); + const unsupported = operations.some((operation) => { + const to = operation.type === 'create_account' ? operation.account + : operation.type === 'account_merge' ? operation.into ?? operation.to : undefined; + return to && destinationMatches(to, expected.destination); + }); + return failure(unsupported ? 'UNSUPPORTED_PAYMENT_OPERATION' : 'NO_PAYMENT_OPERATION'); } if (selection.kind === 'ambiguous') { return failure('AMBIGUOUS_PAYMENT_OPERATION'); diff --git a/backend/src/services/stellar.service.ts b/backend/src/services/stellar.service.ts index 1b64418..7113f42 100644 --- a/backend/src/services/stellar.service.ts +++ b/backend/src/services/stellar.service.ts @@ -1,16 +1,20 @@ import { emitOperationalFailure } from '../observability/log-events'; import * as StellarSdk from '@stellar/stellar-sdk'; -import { server, NETWORK_PASSPHRASE, getSellerKeypair } from '../config/stellar'; +import { server, NETWORK_PASSPHRASE, STELLAR_NETWORK, getSellerKeypair } from '../config/stellar'; import { checkTxHash, failure, verifyHorizonPayment, + normalizePaymentOperation, + destinationMatches, } from './payment-verification'; import { fitsStellarTextMemo } from '../../../shared/memo'; import type { ExpectedPayment, VerificationResult, VerifiedPayment, + HorizonOperationLike, + HorizonTransactionLike, } from './payment-verification'; import { classifyHorizonFailure, @@ -18,6 +22,13 @@ import { isHorizonUnavailable, } from '../utils/horizon-client'; +export interface HorizonTransactionDetails { + transaction: HorizonTransactionLike; + operations: HorizonOperationLike[]; + /** Network of the Horizon source, independent of a monitor's configuration. */ + network?: string; +} + export interface PaymentRecord { id: string; txHash: string; @@ -30,12 +41,28 @@ export interface PaymentRecord { memoType?: string; ledger: number; createdAt: string; + operationType?: string; + /** The observed transaction envelope; operations are fetched for memo candidates. */ + transaction?: HorizonTransactionLike; } +export interface ClaimableBalanceRecord { + balanceId: string; + txHash: string; + claimant: string; + memo?: string; + memoType?: string; + amount: string; + asset: string; + predicate: unknown; +} + + export interface PaymentPageRecord { pagingToken: string; ledger?: number; payment?: PaymentRecord; + claimableBalance?: ClaimableBalanceRecord; } class StellarService { @@ -111,20 +138,24 @@ class StellarService { /** * Get transaction details */ - async getTransaction(txHash: string): Promise { + async getTransaction( + txHash: string, + observedTransaction?: HorizonTransactionLike + ): Promise { try { - const transaction = await horizonCall( + const transaction = observedTransaction ?? await horizonCall( () => server.transactions().transaction(txHash).call(), { label: 'transactions().transaction' } ); const operations = await horizonCall( - () => server.operations().forTransaction(txHash).call(), + () => server.operations().forTransaction(txHash).order('asc').limit(200).call(), { label: 'operations().forTransaction' } ); return { transaction, operations: operations.records, + network: STELLAR_NETWORK, }; } catch (error: any) { emitOperationalFailure('stellar.transaction'); @@ -151,7 +182,8 @@ class StellarService { .stream({ onmessage: async (record: any) => { try { - if (record.type === 'payment' && record.to === publicKey) { + const normalized = normalizePaymentOperation(record); + if (normalized && destinationMatches(normalized.to, publicKey)) { // Get transaction to retrieve memo const transaction = await horizonCall( () => server.transactions().transaction(record.transaction_hash).call(), @@ -161,11 +193,12 @@ class StellarService { const payment: PaymentRecord = { id: record.id, txHash: record.transaction_hash, - from: record.from, - to: record.to, - amount: record.amount, - assetCode: record.asset_type === 'native' ? 'XLM' : (record.asset_code ?? 'UNKNOWN'), - assetIssuer: record.asset_type === 'native' ? undefined : record.asset_issuer, + from: normalized.from, + to: normalized.to, + amount: normalized.amount, + assetCode: normalized.assetType === 'native' ? 'XLM' : (normalized.assetCode ?? 'UNKNOWN'), + assetIssuer: normalized.assetType === 'native' ? undefined : normalized.assetIssuer, + operationType: normalized.type, memo: transaction.memo || undefined, memoType: transaction.memo_type || undefined, ledger: transaction.ledger_attr, @@ -198,50 +231,81 @@ class StellarService { cursor: string, limit: number = 100 ): Promise { + // The operations feed includes claimable balances; its paging tokens share + // the payment feed's operation ordering, so existing checkpoints remain valid. const page = await horizonCall( - () => - server - .payments() - .forAccount(publicKey) - .cursor(cursor) - .order('asc') - .limit(limit) - .call(), + () => server.operations().forAccount(publicKey).cursor(cursor).order('asc').limit(limit).call(), { label: 'getPaymentsPage' } ); const records: PaymentPageRecord[] = []; + const transactions = new Map(); for (const record of page.records as any[]) { const pagingToken = String(record.paging_token ?? record.id); const base: PaymentPageRecord = { pagingToken }; - - if (record.type !== 'payment' || record.to !== publicKey) { + const normalized = normalizePaymentOperation(record); + const unsupportedTo = record.type === 'create_account' ? record.account + : record.type === 'account_merge' ? record.into ?? record.to : undefined; + const claimant = record.type === 'create_claimable_balance' + ? record.claimants?.find((entry: any) => entry.destination === publicKey) : undefined; + if (!(normalized && destinationMatches(normalized.to, publicKey)) + && !(unsupportedTo && destinationMatches(unsupportedTo, publicKey)) && !claimant) { records.push(base); continue; } - const transaction = await horizonCall( - () => server.transactions().transaction(record.transaction_hash).call(), - { label: 'getPaymentsPage tx lookup' } - ); - const ledger = Number((transaction as any).ledger_attr ?? (transaction as any).ledger); - records.push({ - pagingToken, - ledger: Number.isFinite(ledger) ? ledger : undefined, - payment: { + let transaction = transactions.get(record.transaction_hash); + if (!transaction) { + transaction = await horizonCall( + () => server.transactions().transaction(record.transaction_hash).call(), + { label: 'getPaymentsPage tx lookup' } + ); + transactions.set(record.transaction_hash, transaction); + } + const ledger = Number(transaction.ledger_attr ?? transaction.ledger); + base.ledger = Number.isFinite(ledger) ? ledger : undefined; + + if (claimant) { + // Horizon's create operation omits balance_id. Its immutable creation + // effect supplies the actual id, including after a balance is claimed. + if (transaction.memo) { + const effects = await horizonCall( + () => server.effects().forOperation(String(record.id)).limit(200).call(), + { label: 'claimable balance creation effects' } + ); + const effect = (effects.records as any[]).find((entry) => entry.type === 'claimable_balance_created'); + if (!effect?.balance_id) throw new Error('Claimable balance creation effect is unavailable'); + base.claimableBalance = { + balanceId: effect.balance_id, + txHash: record.transaction_hash, + claimant: publicKey, + memo: transaction.memo, + memoType: transaction.memo_type, + amount: record.amount, + asset: record.asset, + predicate: claimant.predicate, + }; + } + } else { + base.payment = { id: String(record.id), txHash: record.transaction_hash, - from: record.from, - to: record.to, - amount: record.amount, - assetCode: record.asset_type === 'native' ? 'XLM' : (record.asset_code ?? 'UNKNOWN'), - assetIssuer: record.asset_type === 'native' ? undefined : record.asset_issuer, - memo: (transaction as any).memo || undefined, - memoType: (transaction as any).memo_type || undefined, + from: normalized?.from ?? record.from ?? record.funder ?? record.source_account, + to: normalized?.to ?? unsupportedTo, + amount: normalized?.amount ?? record.starting_balance ?? '', + assetCode: normalized + ? normalized.assetType === 'native' ? 'XLM' : (normalized.assetCode ?? 'UNKNOWN') + : 'XLM', + assetIssuer: normalized?.assetType === 'native' ? undefined : normalized?.assetIssuer, + memo: transaction.memo || undefined, + memoType: transaction.memo_type || undefined, ledger: Number.isFinite(ledger) ? ledger : 0, - createdAt: record.created_at, - }, - }); + createdAt: transaction.created_at ?? record.created_at, + operationType: record.type, + transaction, + }; + } + records.push(base); } return records; } @@ -249,7 +313,7 @@ class StellarService { /** Anchor a brand-new monitor at the latest known operation. */ async getLatestPaymentCursor(publicKey: string): Promise { const page = await horizonCall( - () => server.payments().forAccount(publicKey).order('desc').limit(1).call(), + () => server.operations().forAccount(publicKey).order('desc').limit(1).call(), { label: 'getLatestPaymentCursor' } ); const latest = (page.records as any[])[0]; @@ -275,7 +339,8 @@ class StellarService { const paymentRecords: PaymentRecord[] = []; for (const record of payments.records) { - if (record.type === 'payment') { + const normalized = normalizePaymentOperation(record); + if (normalized) { const transaction = await horizonCall( () => server.transactions().transaction(record.transaction_hash).call(), { label: 'getRecentPayments tx lookup' } @@ -284,11 +349,12 @@ class StellarService { paymentRecords.push({ id: record.id, txHash: record.transaction_hash, - from: record.from, - to: record.to, - amount: record.amount, - assetCode: record.asset_type === 'native' ? 'XLM' : (record.asset_code ?? 'UNKNOWN'), - assetIssuer: record.asset_type === 'native' ? undefined : record.asset_issuer, + from: normalized.from, + to: normalized.to, + amount: normalized.amount, + assetCode: normalized.assetType === 'native' ? 'XLM' : (normalized.assetCode ?? 'UNKNOWN'), + assetIssuer: normalized.assetType === 'native' ? undefined : normalized.assetIssuer, + operationType: normalized.type, memo: transaction.memo || undefined, memoType: transaction.memo_type || undefined, ledger: transaction.ledger_attr, diff --git a/backend/src/storage/memory-storage.ts b/backend/src/storage/memory-storage.ts index 1293088..9bc48f0 100644 --- a/backend/src/storage/memory-storage.ts +++ b/backend/src/storage/memory-storage.ts @@ -234,6 +234,9 @@ class MemoryStorage { * Records a payment lifecycle audit event in memory. */ logPaymentEvent(invoiceId: string, eventType: string, eventData: any): void { + if (eventType === 'CLAIMABLE_BALANCE_RECEIVED' && eventData?.balanceId + && this.paymentEvents.some((event) => event.invoiceId === invoiceId + && event.eventType === eventType && event.eventData?.balanceId === eventData.balanceId)) return; this.paymentEvents.push({ id: uuidv4(), invoiceId, diff --git a/backend/tests/claimable-event-postgres.integration.test.ts b/backend/tests/claimable-event-postgres.integration.test.ts new file mode 100644 index 0000000..9175e7b --- /dev/null +++ b/backend/tests/claimable-event-postgres.integration.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { it } from 'node:test'; +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { Pool } from 'pg'; +import { InvoiceService } from '../src/services/invoice.service'; + +it('PostgreSQL preserves one claimable event across concurrent writers and a new connection', { + skip: process.env.DATABASE_URL ? false : 'DATABASE_URL is not set', +}, async () => { + // An isolated schema keeps this check independent of other integration jobs. + const schema = `q585_claimable_${randomUUID().replaceAll('-', '')}`; + const admin = new Pool({ connectionString: process.env.DATABASE_URL }); + let writer: Pool | undefined; + let reader: Pool | undefined; + try { + await admin.query(`CREATE SCHEMA "${schema}"`); + const options = { connectionString: process.env.DATABASE_URL, options: `-c search_path=${schema},public` }; + writer = new Pool(options); + const sql = readFileSync(path.join(__dirname, '../../db/schema.sql'), 'utf8'); + await writer.query(sql); + await writer.query(sql); // deployed schema replay must retain the partial index + const service = new InvoiceService(writer); + const invoice = await service.createInvoice({ + sellerPublicKey: 'GAYF33NNNMI2Z6VNRFXQ64D4E4SF77PM46NW3ZUZEEU5X7FCHAZCMHKU', + amount: 12.33, assetCode: 'XLM', + }); + const event = { balanceId: 'test-balance-585', amount: '12.3300000', asset: 'native', predicate: { unconditional: true } }; + await Promise.all(Array.from({ length: 4 }, () => service.logPaymentEvent(invoice.id, 'CLAIMABLE_BALANCE_RECEIVED', event))); + await writer.end(); + writer = undefined; + + reader = new Pool(options); + const restarted = new InvoiceService(reader); + await restarted.logPaymentEvent(invoice.id, 'CLAIMABLE_BALANCE_RECEIVED', event); + let events = await restarted.getPaymentEvents(invoice.id); + assert.equal(events.length, 1); + assert.deepEqual(events[0].eventData, event); + assert.equal((await restarted.getInvoiceById(invoice.id))?.status, 'PENDING'); + + await restarted.logPaymentEvent(invoice.id, 'CLAIMABLE_BALANCE_RECEIVED', { ...event, balanceId: 'second-balance-585' }); + await restarted.logPaymentEvent(invoice.id, 'PAYMENT_REJECTED', { balanceId: event.balanceId }); + await restarted.logPaymentEvent(invoice.id, 'PAYMENT_REJECTED', { balanceId: event.balanceId }); + events = await restarted.getPaymentEvents(invoice.id); + assert.equal(events.filter((entry) => entry.eventType === 'CLAIMABLE_BALANCE_RECEIVED').length, 2); + assert.equal(events.filter((entry) => entry.eventType === 'PAYMENT_REJECTED').length, 2, 'the index applies only to claim hints'); + } finally { + await writer?.end(); + await reader?.end(); + await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await admin.end(); + } +}); diff --git a/backend/tests/fixtures/monitor-testnet-receipts.json b/backend/tests/fixtures/monitor-testnet-receipts.json new file mode 100644 index 0000000..1708d59 --- /dev/null +++ b/backend/tests/fixtures/monitor-testnet-receipts.json @@ -0,0 +1,347 @@ +{ + "schema_version": 1, + "network": "TESTNET", + "network_passphrase": "Test SDF Network ; September 2015", + "purpose": "Public read-only Horizon receipts for Quittance0 issue 585 fixtures. These records are not asserted to be payments of real Quittance invoices. No transactions were created or submitted.", + "network_resource": { + "url": "https://horizon-testnet.stellar.org/", + "fetched_at": "2026-10-04T11:55:16.551193+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:55:20 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "6cc063bc32a6359bc7c1909081ac5a8e24eb336a3b940c96c579cdd9eae75674", + "bytes": 4600, + "raw_utf8": "{\n \"_links\": {\n \"account\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/{account_id}\",\n \"templated\": true\n },\n \"accounts\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts{?signer,sponsor,asset,liquidity_pool,cursor,limit,order}\",\n \"templated\": true\n },\n \"account_transactions\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/{account_id}/transactions{?cursor,limit,order}\",\n \"templated\": true\n },\n \"claimable_balances\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances{?asset,sponsor,claimant,cursor,limit,order}\",\n \"templated\": true\n },\n \"assets\": {\n \"href\": \"https://horizon-testnet.stellar.org/assets{?asset_code,asset_issuer,cursor,limit,order}\",\n \"templated\": true\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects{?cursor,limit,order}\",\n \"templated\": true\n },\n \"fee_stats\": {\n \"href\": \"https://horizon-testnet.stellar.org/fee_stats\"\n },\n \"friendbot\": {\n \"href\": \"https://friendbot.stellar.org/{?addr}\",\n \"templated\": true\n },\n \"ledger\": {\n \"href\": \"https://horizon-testnet.stellar.org/ledgers/{sequence}\",\n \"templated\": true\n },\n \"ledgers\": {\n \"href\": \"https://horizon-testnet.stellar.org/ledgers{?cursor,limit,order}\",\n \"templated\": true\n },\n \"liquidity_pools\": {\n \"href\": \"https://horizon-testnet.stellar.org/liquidity_pools{?reserves,account,cursor,limit,order}\",\n \"templated\": true\n },\n \"offer\": {\n \"href\": \"https://horizon-testnet.stellar.org/offers/{offer_id}\",\n \"templated\": true\n },\n \"offers\": {\n \"href\": \"https://horizon-testnet.stellar.org/offers{?selling,buying,seller,sponsor,cursor,limit,order}\",\n \"templated\": true\n },\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/{id}\",\n \"templated\": true\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations{?cursor,limit,order,include_failed}\",\n \"templated\": true\n },\n \"order_book\": {\n \"href\": \"https://horizon-testnet.stellar.org/order_book{?selling_asset_type,selling_asset_code,selling_asset_issuer,buying_asset_type,buying_asset_code,buying_asset_issuer,limit}\",\n \"templated\": true\n },\n \"payments\": {\n \"href\": \"https://horizon-testnet.stellar.org/payments{?cursor,limit,order,include_failed}\",\n \"templated\": true\n },\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/\"\n },\n \"strict_receive_paths\": {\n \"href\": \"https://horizon-testnet.stellar.org/paths/strict-receive{?source_assets,source_account,destination_account,destination_asset_type,destination_asset_issuer,destination_asset_code,destination_amount}\",\n \"templated\": true\n },\n \"strict_send_paths\": {\n \"href\": \"https://horizon-testnet.stellar.org/paths/strict-send{?destination_account,destination_assets,source_asset_type,source_asset_issuer,source_asset_code,source_amount}\",\n \"templated\": true\n },\n \"trade_aggregations\": {\n \"href\": \"https://horizon-testnet.stellar.org/trade_aggregations?base_asset_type={base_asset_type}\\u0026base_asset_code={base_asset_code}\\u0026base_asset_issuer={base_asset_issuer}\\u0026counter_asset_type={counter_asset_type}\\u0026counter_asset_code={counter_asset_code}\\u0026counter_asset_issuer={counter_asset_issuer}\",\n \"templated\": true\n },\n \"trades\": {\n \"href\": \"https://horizon-testnet.stellar.org/trades?base_asset_type={base_asset_type}\\u0026base_asset_code={base_asset_code}\\u0026base_asset_issuer={base_asset_issuer}\\u0026counter_asset_type={counter_asset_type}\\u0026counter_asset_code={counter_asset_code}\\u0026counter_asset_issuer={counter_asset_issuer}\",\n \"templated\": true\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/{hash}\",\n \"templated\": true\n },\n \"transactions\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions{?cursor,limit,order}\",\n \"templated\": true\n }\n },\n \"horizon_version\": \"29.0.0-c9e48b645f0777f19135109d3de5c46160cea737\",\n \"core_version\": \"stellar-core 29.0.0 (4eb83337380a29ad0907f4e32196ce97b8dc7649)\",\n \"ingest_latest_ledger\": 5018266,\n \"history_latest_ledger\": 5018266,\n \"history_latest_ledger_closed_at\": \"2026-10-04T11:55:17Z\",\n \"history_elder_ledger\": 128,\n \"core_latest_ledger\": 5018266,\n \"network_passphrase\": \"Test SDF Network ; September 2015\",\n \"current_protocol_version\": 29,\n \"supported_protocol_version\": 29,\n \"core_supported_protocol_version\": 29\n}" + }, + "cases": [ + { + "name": "strict_send_xlm_to_usdc", + "transaction_hash": "2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196", + "ledger": 5017697, + "created_at": "2026-10-04T11:07:52Z", + "memo_type": "none", + "memo": null, + "operation_ids": [ + "21550844516245505" + ], + "operation_paging_tokens": [ + "21550844516245505" + ], + "resources": { + "transaction": { + "url": "https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196", + "fetched_at": "2026-10-04T11:53:22.984366+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:53:27 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "1245e660662678e741ca147255ed5772d7fa890e6458fbc53aa096580a8bb52d", + "bytes": 3171, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196\"\n },\n \"account\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GDD7GQOZRA2NSIWV7W6SG6EN6SLHSYM3AE46YIRYLTV5E6QYTG4WNBGA\"\n },\n \"ledger\": {\n \"href\": \"https://horizon-testnet.stellar.org/ledgers/5017697\"\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196/operations{?cursor,limit,order}\",\n \"templated\": true\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196/effects{?cursor,limit,order}\",\n \"templated\": true\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=asc\\u0026cursor=21550844516245504\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=desc\\u0026cursor=21550844516245504\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196\"\n }\n },\n \"id\": \"2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196\",\n \"paging_token\": \"21550844516245504\",\n \"successful\": true,\n \"hash\": \"2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196\",\n \"ledger\": 5017697,\n \"created_at\": \"2026-10-04T11:07:52Z\",\n \"source_account\": \"GDD7GQOZRA2NSIWV7W6SG6EN6SLHSYM3AE46YIRYLTV5E6QYTG4WNBGA\",\n \"source_account_sequence\": \"21548078557298692\",\n \"fee_account\": \"GDD7GQOZRA2NSIWV7W6SG6EN6SLHSYM3AE46YIRYLTV5E6QYTG4WNBGA\",\n \"fee_charged\": \"100\",\n \"max_fee\": \"100\",\n \"operation_count\": 1,\n \"envelope_xdr\": \"AAAAAgAAAADH80HZiDTZItX9vSN4jfSWeWGbATnsIjhc69J6GJm5ZgAAAGQATI3dAAAABAAAAAEAAAAAAAAAAAAAAABqwjQ3AAAAAAAAAAEAAAAAAAAADQAAAAAAAAACVAvkAAAAAADH80HZiDTZItX9vSN4jfSWeWGbATnsIjhc69J6GJm5ZgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAigbXhwAAAAAAAAAAAAAAAEYmblmAAAAQL60opDOgVF5MzWRzvPmDDDxyTfe3C6rcBKmgHHb1R8wIySbXkRc+VwrcifdDBN4JPxWBds6EiIQFqo6CZVX8AI=\",\n \"result_xdr\": \"AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAANAAAAAAAAAAEAAAACTNH23vuiN+7Lxf7+JZ+J68S17dSRFr61U2xANPxI1j8AAAABVVNEQwAAAABCPn0F8uyvv+wZKyFaPxvpau242OcCVKvjQT4CB95WsgAAAAIzX9gxAAAAAAAAAAJUC+QAAAAAAMfzQdmINNki1f29I3iN9JZ5YZsBOewiOFzr0noYmblmAAAAAVVTREMAAAAAQj59BfLsr7/sGSshWj8b6WrtuNjnAlSr40E+AgfeVrIAAAACM1/YMQAAAAA=\",\n \"fee_meta_xdr\": \"AAAAAgAAAAMATJBJAAAAAAAAAADH80HZiDTZItX9vSN4jfSWeWGbATnsIjhc69J6GJm5ZgAAABdIdZt1AEyN3QAAAAMAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAABMkEkAAAAAasIzEAAAAAAAAAABAEyQYQAAAAAAAAAAx/NB2Yg02SLV/b0jeI30lnlhmwE57CI4XOvSehiZuWYAAAAXSHWbEQBMjd0AAAADAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAATJBJAAAAAGrCMxAAAAAA\",\n \"memo_type\": \"none\",\n \"signatures\": [\n \"vrSikM6BUXkzNZHO8+YMMPHJN97cLqtwEqaAcdvVHzAjJJteRFz5XCtyJ90ME3gk/FYF2zoSIhAWqjoJlVfwAg==\"\n ],\n \"preconditions\": {\n \"timebounds\": {\n \"min_time\": \"0\",\n \"max_time\": \"1791112247\"\n }\n }\n}" + }, + "operations": { + "url": "https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196/operations?order=asc&limit=200", + "fetched_at": "2026-10-04T11:53:22.986428+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:53:27 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "540c50ef506f20837b7a1775e99f514f51052dfc52d52cf69bdea4bc307b8dc1", + "bytes": 2354, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196/operations?cursor=\\u0026limit=200\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196/operations?cursor=21550844516245505\\u0026limit=200\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196/operations?cursor=21550844516245505\\u0026limit=200\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21550844516245505\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196\"\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21550844516245505/effects\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21550844516245505\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21550844516245505\"\n }\n },\n \"id\": \"21550844516245505\",\n \"paging_token\": \"21550844516245505\",\n \"transaction_successful\": true,\n \"source_account\": \"GDD7GQOZRA2NSIWV7W6SG6EN6SLHSYM3AE46YIRYLTV5E6QYTG4WNBGA\",\n \"type\": \"path_payment_strict_send\",\n \"type_i\": 13,\n \"created_at\": \"2026-10-04T11:07:52Z\",\n \"transaction_hash\": \"2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196\",\n \"asset_type\": \"credit_alphanum4\",\n \"asset_code\": \"USDC\",\n \"asset_issuer\": \"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"from\": \"GDD7GQOZRA2NSIWV7W6SG6EN6SLHSYM3AE46YIRYLTV5E6QYTG4WNBGA\",\n \"to\": \"GDD7GQOZRA2NSIWV7W6SG6EN6SLHSYM3AE46YIRYLTV5E6QYTG4WNBGA\",\n \"amount\": \"945.1853873\",\n \"path\": [],\n \"source_amount\": \"1000.0000000\",\n \"destination_min\": \"926.2816796\",\n \"source_asset_type\": \"native\"\n }\n ]\n }\n}" + } + } + }, + { + "name": "strict_receive_xlm_to_usdc", + "transaction_hash": "fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76", + "ledger": 3469102, + "created_at": "2026-07-06T18:02:33Z", + "memo_type": "text", + "memo": "off_60cil8zxp", + "operation_ids": [ + "14899679636492289" + ], + "operation_paging_tokens": [ + "14899679636492289" + ], + "resources": { + "transaction": { + "url": "https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76", + "fetched_at": "2026-10-04T11:50:53.314595+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:50:57 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "ab29d6895044b21161a89eb6db7c8300ea95d96466051ffcf6630c5516d89463", + "bytes": 3266, + "raw_utf8": "{\n \"memo\": \"off_60cil8zxp\",\n \"memo_bytes\": \"b2ZmXzYwY2lsOHp4cA==\",\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76\"\n },\n \"account\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBZP4OZOU6FLO2DR4PP6VTM6UUQUVTIVXOT6RESZRAVZJ4EPWM5VSHJY\"\n },\n \"ledger\": {\n \"href\": \"https://horizon-testnet.stellar.org/ledgers/3469102\"\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76/operations{?cursor,limit,order}\",\n \"templated\": true\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76/effects{?cursor,limit,order}\",\n \"templated\": true\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=asc\\u0026cursor=14899679636492288\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=desc\\u0026cursor=14899679636492288\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76\"\n }\n },\n \"id\": \"fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76\",\n \"paging_token\": \"14899679636492288\",\n \"successful\": true,\n \"hash\": \"fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76\",\n \"ledger\": 3469102,\n \"created_at\": \"2026-07-06T18:02:33Z\",\n \"source_account\": \"GBZP4OZOU6FLO2DR4PP6VTM6UUQUVTIVXOT6RESZRAVZJ4EPWM5VSHJY\",\n \"source_account_sequence\": \"14592039719010341\",\n \"fee_account\": \"GBZP4OZOU6FLO2DR4PP6VTM6UUQUVTIVXOT6RESZRAVZJ4EPWM5VSHJY\",\n \"fee_charged\": \"100\",\n \"max_fee\": \"100\",\n \"operation_count\": 1,\n \"envelope_xdr\": \"AAAAAgAAAABy/jsup4q3aHHj3+rNnqUhSs0Vu6foklmIK5Twj7M7WQAAAGQAM9diAAAAJQAAAAEAAAAAAAAAAAAAAABqS+5pAAAAAQAAAA1vZmZfNjBjaWw4enhwAAAAAAAAAQAAAAAAAAACAAAAAAAAAAEqBfIAAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAVVTREMAAAAAQj59BfLsr7/sGSshWj8b6WrtuNjnAlSr40E+AgfeVrIAAAAAAGW4HgAAAAAAAAAAAAAAAY+zO1kAAABA5rkbdLUoNkLTkNcMtaH2ayxPH6bNj0JstWcP9UhWjLfpWwIWRVD9hYSu0xrsp+7Gzd2pM7iOZ5l1SG1pu9VCAw==\",\n \"result_xdr\": \"AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAACAAAAAAAAAAEAAAACTNH23vuiN+7Lxf7+JZ+J68S17dSRFr61U2xANPxI1j8AAAABVVNEQwAAAABCPn0F8uyvv+wZKyFaPxvpau242OcCVKvjQT4CB95WsgAAAAAAZbgeAAAAAAAAAAAALSXZAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAVVTREMAAAAAQj59BfLsr7/sGSshWj8b6WrtuNjnAlSr40E+AgfeVrIAAAAAAGW4HgAAAAA=\",\n \"fee_meta_xdr\": \"AAAAAgAAAAMANO8rAAAAAAAAAABy/jsup4q3aHHj3+rNnqUhSs0Vu6foklmIK5Twj7M7WQAAABdOlBDFADPXYgAAACQAAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAADAAAAAAA07ysAAAAAakvtqgAAAAAAAAABADTvLgAAAAAAAAAAcv47LqeKt2hx49/qzZ6lIUrNFbun6JJZiCuU8I+zO1kAAAAXTpQQYQAz12IAAAAkAAAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAwAAAAAANO8rAAAAAGpL7aoAAAAA\",\n \"memo_type\": \"text\",\n \"signatures\": [\n \"5rkbdLUoNkLTkNcMtaH2ayxPH6bNj0JstWcP9UhWjLfpWwIWRVD9hYSu0xrsp+7Gzd2pM7iOZ5l1SG1pu9VCAw==\"\n ],\n \"preconditions\": {\n \"timebounds\": {\n \"min_time\": \"0\",\n \"max_time\": \"1783361129\"\n }\n }\n}" + }, + "operations": { + "url": "https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76/operations?order=asc&limit=200", + "fetched_at": "2026-10-04T11:50:53.316683+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:50:57 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "82f32fcdfbd1092936b65d8e3fb767ca5870c1f11d4fa7f088f57bf9d8896b64", + "bytes": 2346, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76/operations?cursor=\\u0026limit=200\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76/operations?cursor=14899679636492289\\u0026limit=200\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76/operations?cursor=14899679636492289\\u0026limit=200\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/14899679636492289\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76\"\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/14899679636492289/effects\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=14899679636492289\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=14899679636492289\"\n }\n },\n \"id\": \"14899679636492289\",\n \"paging_token\": \"14899679636492289\",\n \"transaction_successful\": true,\n \"source_account\": \"GBZP4OZOU6FLO2DR4PP6VTM6UUQUVTIVXOT6RESZRAVZJ4EPWM5VSHJY\",\n \"type\": \"path_payment_strict_receive\",\n \"type_i\": 2,\n \"created_at\": \"2026-07-06T18:02:33Z\",\n \"transaction_hash\": \"fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76\",\n \"asset_type\": \"credit_alphanum4\",\n \"asset_code\": \"USDC\",\n \"asset_issuer\": \"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"from\": \"GBZP4OZOU6FLO2DR4PP6VTM6UUQUVTIVXOT6RESZRAVZJ4EPWM5VSHJY\",\n \"to\": \"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"amount\": \"0.6666270\",\n \"path\": [],\n \"source_amount\": \"0.2958809\",\n \"source_max\": \"500.0000000\",\n \"source_asset_type\": \"native\"\n }\n ]\n }\n}" + } + } + }, + { + "name": "claimable_native_text_memo", + "transaction_hash": "fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12", + "ledger": 5007352, + "created_at": "2026-10-03T20:45:47Z", + "memo_type": "text", + "memo": "createclaimablebalance", + "operation_ids": [ + "21506413079564289" + ], + "operation_paging_tokens": [ + "21506413079564289" + ], + "resources": { + "transaction": { + "url": "https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12", + "fetched_at": "2026-10-04T11:55:11.885571+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:55:16 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "ac0fc3373e8417cb68e8d53bf07775812ac5e4ab4d78b7bcb62a5571166d5785", + "bytes": 2901, + "raw_utf8": "{\n \"memo\": \"createclaimablebalance\",\n \"memo_bytes\": \"Y3JlYXRlY2xhaW1hYmxlYmFsYW5jZQ==\",\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\"\n },\n \"account\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\"\n },\n \"ledger\": {\n \"href\": \"https://horizon-testnet.stellar.org/ledgers/5007352\"\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12/operations{?cursor,limit,order}\",\n \"templated\": true\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12/effects{?cursor,limit,order}\",\n \"templated\": true\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=asc\\u0026cursor=21506413079564288\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=desc\\u0026cursor=21506413079564288\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\"\n }\n },\n \"id\": \"fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\",\n \"paging_token\": \"21506413079564288\",\n \"successful\": true,\n \"hash\": \"fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\",\n \"ledger\": 5007352,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"source_account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"source_account_sequence\": \"21506404489625601\",\n \"fee_account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"fee_charged\": \"100\",\n \"max_fee\": \"100\",\n \"operation_count\": 1,\n \"envelope_xdr\": \"AAAAAgAAAACCNbLK0SEqhNKQotumZPOV73To6RZzwmoM971et7AkKAAAAGQATGf2AAAAAQAAAAAAAAABAAAAFmNyZWF0ZWNsYWltYWJsZWJhbGFuY2UAAAAAAAEAAAAAAAAADgAAAAAAAAAAB1looAAAAAIAAAAAAAAAAGynUoSb6oBX6u2VSyTCclAzaEqvxZcVpNlBil1oNFkFAAAAAAAAAAAAAAAAsMRih9RzNk50LOf+y1+bENueHugqY5nv7JieMwfvstIAAAACAAAAAgAAAAEAAAACAAAAAwAAAAEAAAAFAAAAAAAAAGQAAAAEAAAAAGFk3hAAAAAEAAAAAF93kLEAAAAAAAAAAbewJCgAAABAIqN//Sf3jXV9DYvr3tLFR3ubvHTv6lgRCNsjQFSGrzvcEoR+66Kt1swUSdUBfBa1AbzAy1YRSMP9RJJqt424Bg==\",\n \"result_xdr\": \"AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAOAAAAAAAAAAA4yIC+J2jAmFFifv7DXRVeXqR3YOA7Bvv3CTqIFL7TMgAAAAA=\",\n \"fee_meta_xdr\": \"AAAAAgAAAAMATGf2AAAAAAAAAACCNbLK0SEqhNKQotumZPOV73To6RZzwmoM971et7AkKAAAABdIdugAAExn9gAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAEATGf4AAAAAAAAAACCNbLK0SEqhNKQotumZPOV73To6RZzwmoM971et7AkKAAAABdIduecAExn9gAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAA==\",\n \"memo_type\": \"text\",\n \"signatures\": [\n \"IqN//Sf3jXV9DYvr3tLFR3ubvHTv6lgRCNsjQFSGrzvcEoR+66Kt1swUSdUBfBa1AbzAy1YRSMP9RJJqt424Bg==\"\n ]\n}" + }, + "operations": { + "url": "https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12/operations?order=asc&limit=200", + "fetched_at": "2026-10-04T11:55:11.886480+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:55:16 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "e7dd1d53a8cbb665221659be06327055e713dc2c8c2bf7a78ab0f07990668161", + "bytes": 2954, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12/operations?cursor=\\u0026limit=200\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12/operations?cursor=21506413079564289\\u0026limit=200\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12/operations?cursor=21506413079564289\\u0026limit=200\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\"\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289/effects\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289\"\n }\n },\n \"id\": \"21506413079564289\",\n \"paging_token\": \"21506413079564289\",\n \"transaction_successful\": true,\n \"source_account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"type\": \"create_claimable_balance\",\n \"type_i\": 14,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"transaction_hash\": \"fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\",\n \"sponsor\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"asset\": \"native\",\n \"amount\": \"12.3300000\",\n \"claimants\": [\n {\n \"destination\": \"GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"destination\": \"GCYMIYUH2RZTMTTUFTT75S27TMINXHQ65AVGHGPP5SMJ4MYH56ZNEZSM\",\n \"predicate\": {\n \"or\": [\n {\n \"and\": [\n {\n \"not\": {\n \"rel_before\": \"100\"\n }\n },\n {\n \"abs_before\": \"2021-10-12T01:00:00Z\",\n \"abs_before_epoch\": \"1634000400\"\n }\n ]\n },\n {\n \"abs_before\": \"2020-10-02T20:42:25Z\",\n \"abs_before_epoch\": \"1601671345\"\n }\n ]\n }\n }\n ]\n }\n ]\n }\n}" + }, + "effects": { + "url": "https://horizon-testnet.stellar.org/operations/21506413079564289/effects?order=asc&limit=200", + "fetched_at": "2026-10-04T11:55:11.887645+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:55:16 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "90ae2134ca3ed8789c993a128e622ae2756bc78a5405cb5785dcb0325c5f6dae", + "bytes": 5615, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289/effects?cursor=\\u0026limit=200\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289/effects?cursor=21506413079564289-5\\u0026limit=200\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289/effects?cursor=21506413079564289-1\\u0026limit=200\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289-1\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289-1\"\n }\n },\n \"id\": \"0021506413079564289-0000000001\",\n \"paging_token\": \"21506413079564289-1\",\n \"account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"type\": \"claimable_balance_created\",\n \"type_i\": 50,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"asset\": \"native\",\n \"balance_id\": \"0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\",\n \"amount\": \"12.3300000\"\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289-2\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289-2\"\n }\n },\n \"id\": \"0021506413079564289-0000000002\",\n \"paging_token\": \"21506413079564289-2\",\n \"account\": \"GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7\",\n \"type\": \"claimable_balance_claimant_created\",\n \"type_i\": 51,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"asset\": \"native\",\n \"balance_id\": \"0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\",\n \"amount\": \"12.3300000\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289-3\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289-3\"\n }\n },\n \"id\": \"0021506413079564289-0000000003\",\n \"paging_token\": \"21506413079564289-3\",\n \"account\": \"GCYMIYUH2RZTMTTUFTT75S27TMINXHQ65AVGHGPP5SMJ4MYH56ZNEZSM\",\n \"type\": \"claimable_balance_claimant_created\",\n \"type_i\": 51,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"asset\": \"native\",\n \"balance_id\": \"0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\",\n \"amount\": \"12.3300000\",\n \"predicate\": {\n \"or\": [\n {\n \"and\": [\n {\n \"not\": {\n \"rel_before\": \"100\"\n }\n },\n {\n \"abs_before\": \"2021-10-12T01:00:00Z\",\n \"abs_before_epoch\": \"1634000400\"\n }\n ]\n },\n {\n \"abs_before\": \"2020-10-02T20:42:25Z\",\n \"abs_before_epoch\": \"1601671345\"\n }\n ]\n }\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289-4\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289-4\"\n }\n },\n \"id\": \"0021506413079564289-0000000004\",\n \"paging_token\": \"21506413079564289-4\",\n \"account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"type\": \"account_debited\",\n \"type_i\": 3,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"asset_type\": \"native\",\n \"amount\": \"12.3300000\"\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289-5\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289-5\"\n }\n },\n \"id\": \"0021506413079564289-0000000005\",\n \"paging_token\": \"21506413079564289-5\",\n \"account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"type\": \"claimable_balance_sponsorship_created\",\n \"type_i\": 69,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"balance_id\": \"0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\",\n \"sponsor\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\"\n }\n ]\n }\n}" + }, + "balance": { + "url": "https://horizon-testnet.stellar.org/claimable_balances/0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332", + "fetched_at": "2026-10-04T11:55:11.888464+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:55:16 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Content-Length": "1902", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Latest-Ledger": "5018265", + "Vary": "Origin" + }, + "sha256": "d9387dfa97cbabed75e7a0ca6c418bb9d06dbd881c7cc8e7a3bc47a5b27d7e76", + "bytes": 1902, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances/0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\"\n },\n \"transactions\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances/0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332/transactions{?cursor,limit,order}\",\n \"templated\": true\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances/0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332/operations{?cursor,limit,order}\",\n \"templated\": true\n }\n },\n \"id\": \"0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\",\n \"asset\": \"native\",\n \"amount\": \"12.3300000\",\n \"sponsor\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"last_modified_ledger\": 5007352,\n \"last_modified_time\": \"2026-10-03T20:45:47Z\",\n \"claimants\": [\n {\n \"destination\": \"GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"destination\": \"GCYMIYUH2RZTMTTUFTT75S27TMINXHQ65AVGHGPP5SMJ4MYH56ZNEZSM\",\n \"predicate\": {\n \"or\": [\n {\n \"and\": [\n {\n \"not\": {\n \"abs_before\": \"2026-10-03T20:47:27Z\",\n \"abs_before_epoch\": \"1791060447\"\n }\n },\n {\n \"abs_before\": \"2021-10-12T01:00:00Z\",\n \"abs_before_epoch\": \"1634000400\"\n }\n ]\n },\n {\n \"abs_before\": \"2020-10-02T20:42:25Z\",\n \"abs_before_epoch\": \"1601671345\"\n }\n ]\n }\n }\n ],\n \"flags\": {\n \"clawback_enabled\": false\n },\n \"paging_token\": \"5007352-0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332\"\n}" + }, + "claimant-operations": { + "url": "https://horizon-testnet.stellar.org/accounts/GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7/operations?order=asc&limit=1&cursor=21506413079564288", + "fetched_at": "2026-10-04T11:55:16.493950+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:55:20 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "f0b923407b44f592c8de83948ba9cc30211d62db7ec399048ebe64e4708c9b46", + "bytes": 2929, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7/operations?cursor=21506413079564288\\u0026limit=1\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7/operations?cursor=21506413079564289\\u0026limit=1\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7/operations?cursor=21506413079564289\\u0026limit=1\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\"\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21506413079564289/effects\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21506413079564289\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21506413079564289\"\n }\n },\n \"id\": \"21506413079564289\",\n \"paging_token\": \"21506413079564289\",\n \"transaction_successful\": true,\n \"source_account\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"type\": \"create_claimable_balance\",\n \"type_i\": 14,\n \"created_at\": \"2026-10-03T20:45:47Z\",\n \"transaction_hash\": \"fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12\",\n \"sponsor\": \"GCBDLMWK2EQSVBGSSCRNXJTE6OK665HI5ELHHQTKBT332XVXWASCRUYS\",\n \"asset\": \"native\",\n \"amount\": \"12.3300000\",\n \"claimants\": [\n {\n \"destination\": \"GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"destination\": \"GCYMIYUH2RZTMTTUFTT75S27TMINXHQ65AVGHGPP5SMJ4MYH56ZNEZSM\",\n \"predicate\": {\n \"or\": [\n {\n \"and\": [\n {\n \"not\": {\n \"rel_before\": \"100\"\n }\n },\n {\n \"abs_before\": \"2021-10-12T01:00:00Z\",\n \"abs_before_epoch\": \"1634000400\"\n }\n ]\n },\n {\n \"abs_before\": \"2020-10-02T20:42:25Z\",\n \"abs_before_epoch\": \"1601671345\"\n }\n ]\n }\n }\n ]\n }\n ]\n }\n}" + } + }, + "balance_id": "0000000038c880be2768c09851627efec35d155e5ea47760e03b06fbf7093a8814bed332", + "test_claimant": "GBWKOUUETPVIAV7K5WKUWJGCOJIDG2CKV7CZOFNE3FAYUXLIGRMQK7X7", + "test_claimant_predicate": { + "unconditional": true + } + }, + { + "name": "claimable_usdc_no_memo", + "transaction_hash": "50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c", + "ledger": 5017738, + "created_at": "2026-10-04T11:11:17Z", + "memo_type": "none", + "memo": null, + "operation_ids": [ + "21551020609900545" + ], + "operation_paging_tokens": [ + "21551020609900545" + ], + "resources": { + "transaction": { + "url": "https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c", + "fetched_at": "2026-10-04T11:51:37.842434+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:51:47 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "9e99d6e9ce0b3c8d990fe6c7452686bac7d020f18767c1215a92c18da6860899", + "bytes": 3039, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\"\n },\n \"account\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\"\n },\n \"ledger\": {\n \"href\": \"https://horizon-testnet.stellar.org/ledgers/5017738\"\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c/operations{?cursor,limit,order}\",\n \"templated\": true\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c/effects{?cursor,limit,order}\",\n \"templated\": true\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=asc\\u0026cursor=21551020609900544\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions?order=desc\\u0026cursor=21551020609900544\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\"\n }\n },\n \"id\": \"50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\",\n \"paging_token\": \"21551020609900544\",\n \"successful\": true,\n \"hash\": \"50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\",\n \"ledger\": 5017738,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"source_account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"source_account_sequence\": \"15279835781792210\",\n \"fee_account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"fee_charged\": \"100\",\n \"max_fee\": \"100\",\n \"operation_count\": 1,\n \"envelope_xdr\": \"AAAAAgAAAADJLvLTR6gKy8eBogLCadqML2Gu+fDv5yTA/AaF6nqB7wAAAGQANkjuAAAB0gAAAAEAAAAAAAAAAAAAAABqwjUEAAAAAAAAAAEAAAAAAAAADgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAABMS0AAAAACAAAAAAAAAABaIWqht8ZV/4aOgjAxDmFU8zwnMHzoJcEH0XMlnBXf0gAAAAAAAAAAAAAAAMku8tNHqArLx4GiAsJp2owvYa758O/nJMD8BoXqeoHvAAAAAwAAAAEAAAAFAAAAAAAADhAAAAAAAAAAAep6ge8AAABA1gbv1FHhUo+FRnyNkVwgwKSaH5qnM2qvCc1yeJqmla5heaej651pOveHxjkrGuqR5pDTHYF5irBEirgly5UyAg==\",\n \"result_xdr\": \"AAAAAAAAAGQAAAAAAAAAAQAAAAAAAAAOAAAAAAAAAAAJ9QHoC/pbej6DZqzvH488cVPyp5C72VuLSS8KTObs9AAAAAA=\",\n \"fee_meta_xdr\": \"AAAAAgAAAAMATJCJAAAAAAAAAADJLvLTR6gKy8eBogLCadqML2Gu+fDv5yTA/AaF6nqB7wAAABLdUIkuADZI7gAAAdEAAAACAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAABbAAAAAAAAAADAAAAAABMkIkAAAAAasI0UAAAAAAAAAABAEyQigAAAAAAAAAAyS7y00eoCsvHgaICwmnajC9hrvnw7+ckwPwGhep6ge8AAAAS3VCIygA2SO4AAAHRAAAAAgAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAWwAAAAAAAAAAwAAAAAATJCJAAAAAGrCNFAAAAAA\",\n \"memo_type\": \"none\",\n \"signatures\": [\n \"1gbv1FHhUo+FRnyNkVwgwKSaH5qnM2qvCc1yeJqmla5heaej651pOveHxjkrGuqR5pDTHYF5irBEirgly5UyAg==\"\n ],\n \"preconditions\": {\n \"timebounds\": {\n \"min_time\": \"0\",\n \"max_time\": \"1791112452\"\n }\n }\n}" + }, + "transaction-operations": { + "url": "https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c/operations?order=asc&limit=200", + "fetched_at": "2026-10-04T11:51:37.867219+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:51:47 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "479812d94568d1cad0fcd8d2344462a2e722f5028c4a74acedd3722552c3dfb8", + "bytes": 2516, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c/operations?cursor=\\u0026limit=200\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c/operations?cursor=21551020609900545\\u0026limit=200\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c/operations?cursor=21551020609900545\\u0026limit=200\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\"\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545/effects\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545\"\n }\n },\n \"id\": \"21551020609900545\",\n \"paging_token\": \"21551020609900545\",\n \"transaction_successful\": true,\n \"source_account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"type\": \"create_claimable_balance\",\n \"type_i\": 14,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"transaction_hash\": \"50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\",\n \"sponsor\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"asset\": \"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"amount\": \"0.5000000\",\n \"claimants\": [\n {\n \"destination\": \"GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"destination\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"predicate\": {\n \"not\": {\n \"rel_before\": \"3600\"\n }\n }\n }\n ]\n }\n ]\n }\n}" + }, + "effects": { + "url": "https://horizon-testnet.stellar.org/operations/21551020609900545/effects?order=asc&limit=200", + "fetched_at": "2026-10-04T11:51:37.868916+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:51:47 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "0cf05eb022963fbc993a690e9b03cd859024f0bda4bc7608188e961137ec83df", + "bytes": 5472, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545/effects?cursor=\\u0026limit=200\\u0026order=asc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545/effects?cursor=21551020609900545-5\\u0026limit=200\\u0026order=asc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545/effects?cursor=21551020609900545-1\\u0026limit=200\\u0026order=desc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545-1\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545-1\"\n }\n },\n \"id\": \"0021551020609900545-0000000001\",\n \"paging_token\": \"21551020609900545-1\",\n \"account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"type\": \"claimable_balance_created\",\n \"type_i\": 50,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"asset\": \"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"balance_id\": \"0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\",\n \"amount\": \"0.5000000\"\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545-2\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545-2\"\n }\n },\n \"id\": \"0021551020609900545-0000000002\",\n \"paging_token\": \"21551020609900545-2\",\n \"account\": \"GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP\",\n \"type\": \"claimable_balance_claimant_created\",\n \"type_i\": 51,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"asset\": \"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"balance_id\": \"0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\",\n \"amount\": \"0.5000000\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545-3\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545-3\"\n }\n },\n \"id\": \"0021551020609900545-0000000003\",\n \"paging_token\": \"21551020609900545-3\",\n \"account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"type\": \"claimable_balance_claimant_created\",\n \"type_i\": 51,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"asset\": \"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"balance_id\": \"0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\",\n \"amount\": \"0.5000000\",\n \"predicate\": {\n \"not\": {\n \"rel_before\": \"3600\"\n }\n }\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545-4\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545-4\"\n }\n },\n \"id\": \"0021551020609900545-0000000004\",\n \"paging_token\": \"21551020609900545-4\",\n \"account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"type\": \"account_debited\",\n \"type_i\": 3,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"asset_type\": \"credit_alphanum4\",\n \"asset_code\": \"USDC\",\n \"asset_issuer\": \"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"amount\": \"0.5000000\"\n },\n {\n \"_links\": {\n \"operation\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545-5\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545-5\"\n }\n },\n \"id\": \"0021551020609900545-0000000005\",\n \"paging_token\": \"21551020609900545-5\",\n \"account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"type\": \"claimable_balance_sponsorship_created\",\n \"type_i\": 69,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"balance_id\": \"0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\",\n \"sponsor\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\"\n }\n ]\n }\n}" + }, + "balance": { + "url": "https://horizon-testnet.stellar.org/claimable_balances/0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4", + "fetched_at": "2026-10-04T11:50:53.317375+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:50:57 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Content-Length": "1551", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Latest-Ledger": "5018214", + "Vary": "Origin" + }, + "sha256": "980971fba37d73c6e91c064a0093e588a26a5c3f83a923a6c0cb6bd03a08e6dc", + "bytes": 1551, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances/0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\"\n },\n \"transactions\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances/0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4/transactions{?cursor,limit,order}\",\n \"templated\": true\n },\n \"operations\": {\n \"href\": \"https://horizon-testnet.stellar.org/claimable_balances/0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4/operations{?cursor,limit,order}\",\n \"templated\": true\n }\n },\n \"id\": \"0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\",\n \"asset\": \"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"amount\": \"0.5000000\",\n \"sponsor\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"last_modified_ledger\": 5017738,\n \"last_modified_time\": \"2026-10-04T11:11:17Z\",\n \"claimants\": [\n {\n \"destination\": \"GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"destination\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"predicate\": {\n \"not\": {\n \"abs_before\": \"2026-10-04T12:11:17Z\",\n \"abs_before_epoch\": \"1791115877\"\n }\n }\n }\n ],\n \"flags\": {\n \"clawback_enabled\": false\n },\n \"paging_token\": \"5017738-0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4\"\n}" + }, + "claimant-operations": { + "url": "https://horizon-testnet.stellar.org/accounts/GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP/operations?order=desc&limit=200", + "fetched_at": "2026-10-04T11:51:49.240680+00:00", + "status": 200, + "headers": { + "Date": "Sun, 04 Oct 2026 11:51:53 GMT", + "Content-Type": "application/hal+json; charset=utf-8", + "Transfer-Encoding": "chunked", + "Connection": "close", + "Cache-Control": "no-cache, no-store, max-age=0", + "Content-Disposition": "inline", + "Vary": "Origin" + }, + "sha256": "9c439043deafd5d935249088068217a03deaef8ac670ebf42216b5152199190a", + "bytes": 2481, + "raw_utf8": "{\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP/operations?cursor=\\u0026limit=200\\u0026order=desc\"\n },\n \"next\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP/operations?cursor=21551020609900545\\u0026limit=200\\u0026order=desc\"\n },\n \"prev\": {\n \"href\": \"https://horizon-testnet.stellar.org/accounts/GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP/operations?cursor=21551020609900545\\u0026limit=200\\u0026order=asc\"\n }\n },\n \"_embedded\": {\n \"records\": [\n {\n \"_links\": {\n \"self\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545\"\n },\n \"transaction\": {\n \"href\": \"https://horizon-testnet.stellar.org/transactions/50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\"\n },\n \"effects\": {\n \"href\": \"https://horizon-testnet.stellar.org/operations/21551020609900545/effects\"\n },\n \"succeeds\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=desc\\u0026cursor=21551020609900545\"\n },\n \"precedes\": {\n \"href\": \"https://horizon-testnet.stellar.org/effects?order=asc\\u0026cursor=21551020609900545\"\n }\n },\n \"id\": \"21551020609900545\",\n \"paging_token\": \"21551020609900545\",\n \"transaction_successful\": true,\n \"source_account\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"type\": \"create_claimable_balance\",\n \"type_i\": 14,\n \"created_at\": \"2026-10-04T11:11:17Z\",\n \"transaction_hash\": \"50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c\",\n \"sponsor\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"asset\": \"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5\",\n \"amount\": \"0.5000000\",\n \"claimants\": [\n {\n \"destination\": \"GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP\",\n \"predicate\": {\n \"unconditional\": true\n }\n },\n {\n \"destination\": \"GDES54WTI6UAVS6HQGRAFQTJ3KGC6YNO7HYO7ZZEYD6ANBPKPKA67H4L\",\n \"predicate\": {\n \"not\": {\n \"rel_before\": \"3600\"\n }\n }\n }\n ]\n }\n ]\n }\n}" + } + }, + "balance_id": "0000000009f501e80bfa5b7a3e8366acef1f8f3c7153f2a790bbd95b8b492f0a4ce6ecf4", + "test_claimant": "GBNCC2VBW7DFL74GR2BDAMIOMFKPGPBHGB6OQJOBA7IXGJM4CXP5ESMP", + "test_claimant_predicate": { + "unconditional": true + } + } + ], + "limitations": [ + "The strict-send receipt has memo_type none. A positive memo-bearing invoice test derived by replacing its memo must be labelled synthetic and is not the original transaction.", + "The strict-receive receipt has text memo off_60cil8zxp. A test invoice may be constructed to expect its existing fields; this is a test expectation, not evidence of a real invoice.", + "The native claimable receipt has text memo createclaimablebalance and can support a test invoice expecting that unchanged memo. Claimable balance creation is not settlement.", + "Claimable creation operation contains no balance_id. The retained claimable_balance_created effect supplies it.", + "Claimable balance resource predicates may normalize relative deadlines to absolute values; operation and effect raw predicates are retained separately.", + "Testnet history and unclaimed balance resources may change or reset after capture; frozen raw bytes and metadata preserve this observation." + ] +} diff --git a/backend/tests/fixtures/payment-page-source.ts b/backend/tests/fixtures/payment-page-source.ts new file mode 100644 index 0000000..eb8f38d --- /dev/null +++ b/backend/tests/fixtures/payment-page-source.ts @@ -0,0 +1,47 @@ +import type { PaymentPageSource } from '../../src/services/payment-monitor.service'; +import type { HorizonTransactionDetails } from '../../src/services/stellar.service'; + +/** Supplies transaction envelopes for the existing single-payment page fixtures. */ +export function withPaymentTransactions( + source: Omit +): PaymentPageSource { + const transactions = new Map(); + + return { + ...source, + async getPaymentsPage(...args) { + const page = await source.getPaymentsPage(...args); + for (const { payment } of page) { + // Repeated hashes in cursor tests represent replays, not additional ops. + if (!payment || transactions.has(payment.txHash)) continue; + const native = payment.assetCode === 'XLM'; + transactions.set(payment.txHash, { + transaction: { + memo: payment.memo ?? null, + memo_type: payment.memoType ?? (payment.memo ? 'text' : 'none'), + created_at: payment.createdAt, + }, + operations: [{ + type: 'payment', + from: payment.from, + to: payment.to, + amount: payment.amount, + asset_type: native + ? 'native' + : payment.assetCode.length <= 4 ? 'credit_alphanum4' : 'credit_alphanum12', + ...(native ? {} : { + asset_code: payment.assetCode, + asset_issuer: payment.assetIssuer, + }), + }], + }); + } + return page; + }, + async getTransaction(txHash) { + const details = transactions.get(txHash); + if (!details) throw new Error(`No transaction fixture for ${txHash}`); + return details; + }, + }; +} diff --git a/backend/tests/fixtures/usdc-verify-edge-cases.fixture.ts b/backend/tests/fixtures/usdc-verify-edge-cases.fixture.ts index 10d4219..b8d6eed 100644 --- a/backend/tests/fixtures/usdc-verify-edge-cases.fixture.ts +++ b/backend/tests/fixtures/usdc-verify-edge-cases.fixture.ts @@ -1,3 +1,5 @@ +// Path-payment rows below are synthetic verifier examples using legacy dest_* aliases. +// Exact, unchanged Horizon path-payment receipts are in monitor-testnet-receipts.json. import { VerifyPaymentInput, ExpectedPayment, diff --git a/backend/tests/invoice-cancel-payment-race.test.ts b/backend/tests/invoice-cancel-payment-race.test.ts index 376b21f..491a19c 100644 --- a/backend/tests/invoice-cancel-payment-race.test.ts +++ b/backend/tests/invoice-cancel-payment-race.test.ts @@ -1,3 +1,4 @@ +import { withPaymentTransactions } from './fixtures/payment-page-source'; import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import type { Request, Response } from 'express'; @@ -504,7 +505,7 @@ class MemoryCheckpointStore implements PaymentMonitorCheckpointStore { } function monitorSource(createdAt: string, overrides: { amount?: string } = {}): PaymentPageSource { - return { + return withPaymentTransactions({ async getLatestPaymentCursor() { return 'cursor-0'; }, @@ -528,7 +529,7 @@ function monitorSource(createdAt: string, overrides: { amount?: string } = {}): }, ]; }, - }; + }); } describe('cancel versus payment monitor attribution on memory storage', () => { diff --git a/backend/tests/invoice-payment-loop.test.ts b/backend/tests/invoice-payment-loop.test.ts index ca4345c..ade41c6 100644 --- a/backend/tests/invoice-payment-loop.test.ts +++ b/backend/tests/invoice-payment-loop.test.ts @@ -109,9 +109,10 @@ function paymentOn(overrides: { assetCode?: string; }) { return (path: string) => { - // Match the path instead of comparing it with one constant, so each test - // can use its own hash. - if (/^\/transactions\/[0-9a-f]{64}\/operations$/.test(path)) { + // Horizon SDK pagination adds query parameters to the operations endpoint. + // Match only the URL pathname so the fixture follows the production request. + const pathname = new URL(path, 'http://horizon.test').pathname; + if (/^\/transactions\/[0-9a-f]{64}\/operations$/.test(pathname)) { return { status: 200, body: { @@ -132,11 +133,11 @@ function paymentOn(overrides: { }; } - if (/^\/transactions\/[0-9a-f]{64}$/.test(path)) { + if (/^\/transactions\/[0-9a-f]{64}$/.test(pathname)) { return { status: 200, body: { - hash: path.split('/')[2], + hash: pathname.split('/')[2], successful: true, ledger: 1_000_000, memo: overrides.memo, diff --git a/backend/tests/monitor-verify-parity.test.ts b/backend/tests/monitor-verify-parity.test.ts new file mode 100644 index 0000000..0ee69a2 --- /dev/null +++ b/backend/tests/monitor-verify-parity.test.ts @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import { describe, it, type TestContext } from 'node:test'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import express from 'express'; +import { createHash } from 'node:crypto'; +import { server, STELLAR_NETWORK } from '../src/config/stellar'; +import stellarService, { type HorizonTransactionDetails } from '../src/services/stellar.service'; +import { PaymentMonitorService } from '../src/services/payment-monitor.service'; +import type { PaymentMonitorCheckpoint, PaymentMonitorCheckpointStore } from '../src/services/payment-monitor-checkpoint'; +import { MemoryStorage } from '../src/storage/memory-storage'; +import { InvoiceMemoryService } from '../src/services/invoice-memory.service'; +import { MemoryInvoiceStorage } from '../src/storage/memory-invoice-storage'; +import { createInvoiceRouter } from '../src/routes/invoice.routes'; +import type { VerificationCache } from '../src/middleware/verify-cache'; +import { normalizePaymentOperation, type ExpectedPayment } from '../src/services/payment-verification'; +import { USDC_VERIFY_CASES } from './fixtures/usdc-verify-edge-cases.fixture'; +import receipts from './fixtures/monitor-testnet-receipts.json'; + +const SELLER = 'GAYF33NNNMI2Z6VNRFXQ64D4E4SF77PM46NW3ZUZEEU5X7FCHAZCMHKU'; +const PAYER = 'GCT7D6S5VTFGEURS6ZYIO33YZRPQMA3LNWB4GEOHDFDXZGWTA4EPIM5E'; +const HASH = 'a'.repeat(64); +const CLOSE_TIME = '2026-10-01T12:00:00Z'; + +class Checkpoints implements PaymentMonitorCheckpointStore { + saved: Array> = []; + constructor(public cursor = '0') {} + async load(account: string, network: string) { + return { account, network, cursor: this.cursor, updatedAt: new Date() }; + } + async save(value: Omit) { + this.saved.push(value); + this.cursor = value.cursor; + } +} + +/** Only Horizon I/O is doubled: the service, monitor, storage and HTTP route are real. */ +function horizon(t: TestContext, details: HorizonTransactionDetails, hash: string, effects: any[] = []) { + const counts = { pages: 0, transactions: 0, operations: 0, effects: 0 }; + const rows = details.operations.map((operation, index) => ({ + id: String(index + 1), paging_token: String(index + 1), + transaction_hash: hash, created_at: details.transaction.created_at, + ...operation, + })); + t.mock.method(server, 'operations', () => { + let account = false; + let cursor = '0'; + const builder = { + forAccount() { account = true; return this; }, + forTransaction(requested: string) { assert.equal(requested, hash); return this; }, + cursor(value: string) { cursor = value; return this; }, + order(value: string) { assert.equal(value, 'asc'); return this; }, + limit(value: number) { if (!account) assert.equal(value, 200, 'read every transaction operation, not Horizon default 10'); return this; }, + async call() { + if (account) { + counts.pages += 1; + return { records: rows.filter((row: any) => BigInt(row.paging_token) > BigInt(cursor)) }; + } + counts.operations += 1; + return { records: rows }; + }, + }; + return builder as any; + }); + t.mock.method(server, 'transactions', () => ({ + transaction(requested: string) { + assert.equal(requested, hash); + return { async call() { counts.transactions += 1; return { ledger_attr: 123, ...details.transaction }; } }; + }, + } as any)); + t.mock.method(server, 'effects', () => ({ + forOperation() { return this; }, limit() { return this; }, + async call() { counts.effects += 1; return { records: effects }; }, + } as any)); + const source = { + getPaymentsPage: stellarService.getPaymentsPage.bind(stellarService), + getLatestPaymentCursor: stellarService.getLatestPaymentCursor.bind(stellarService), + async getTransaction(txHash: string, observed?: HorizonTransactionDetails['transaction']) { + return { ...await stellarService.getTransaction(txHash, observed), network: details.network ?? STELLAR_NETWORK }; + }, + }; + return { counts, rows, source }; +} + +function invoices(expected: ExpectedPayment) { + const memory = new MemoryStorage(); + const invoice = memory.createInvoice({ + sellerPublicKey: expected.destination, + amount: Number(expected.amount), assetCode: expected.assetCode, + assetIssuer: expected.assetIssuer, memo: expected.memo, + expiresAt: new Date('2099-01-01T00:00:00Z'), + }); + const service = new InvoiceMemoryService(memory); + return { memory, invoice, service, storage: new MemoryInvoiceStorage(service) }; +} + +async function postVerify(storage: MemoryInvoiceStorage, invoiceId: string, hash: string, network?: string) { + const app = express(); + app.use(express.json()); + app.use('/api', createInvoiceRouter({ + storage, stellar: stellarService, enableRateLimiting: false, + enableConcurrencyLock: false, enableCeilingCheck: false, enableVerifyCache: false, + verifyCache: { set: async () => undefined } as unknown as VerificationCache, + })); + const listener = await new Promise((resolve) => { + const value = app.listen(0, '127.0.0.1', () => resolve(value)); + }); + try { + const payload = JSON.stringify({ txHash: hash, network }); + return await new Promise<{ status: number; body: any }>((resolve, reject) => { + const request = http.request({ + host: '127.0.0.1', port: (listener.address() as AddressInfo).port, + method: 'POST', path: `/api/invoices/${invoiceId}/verify`, agent: false, + headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }, + }, (response) => { + let body = ''; + response.on('data', (chunk) => { body += chunk; }); + response.on('end', () => resolve({ status: response.statusCode!, body: JSON.parse(body) })); + }); + request.on('error', reject); + request.end(payload); + }); + } finally { + listener.closeAllConnections(); + await new Promise((resolve) => listener.close(() => resolve())); + } +} + +async function parity(t: TestContext, expected: ExpectedPayment, details: HorizonTransactionDetails, hash: string) { + const io = horizon(t, details, hash); + const manual = invoices(expected); + const response = await postVerify(manual.storage, manual.invoice.id, hash, details.network ?? STELLAR_NETWORK); + const watched = invoices(expected); + const checkpoint = new Checkpoints(); + const beforeOperations = io.counts.operations; + const beforeTransactions = io.counts.transactions; + const monitor = new PaymentMonitorService({ + account: expected.destination, network: STELLAR_NETWORK, source: io.source, + invoices: watched.service, checkpoints: checkpoint, database: undefined, + }); + await monitor.runOnce(); + const monitorPaid = watched.memory.getInvoiceById(watched.invoice.id)?.status === 'PAID'; + const manualPaid = manual.memory.getInvoiceById(manual.invoice.id)?.status === 'PAID'; + assert.equal(monitorPaid, manualPaid, 'monitor and HTTP route must agree on settlement'); + assert.equal(response.body.success, manualPaid); + assert.equal(response.status, manualPaid ? 200 : 400); + const events = await watched.service.getPaymentEvents(watched.invoice.id); + const verificationEvents = events.filter((event) => ['PARTIAL_PAYMENT', 'PAYMENT_REJECTED'].includes(event.eventType)); + if (verificationEvents.length) assert.equal(verificationEvents[0].eventData?.code, response.body.code); + assert.ok(io.counts.operations - beforeOperations <= 1, 'one full operations lookup per candidate transaction'); + assert.ok(io.counts.transactions - beforeTransactions <= 1, 'reuse the observed transaction envelope'); + assert.equal(checkpoint.saved.length, io.rows.length, 'checkpoint each record, including irrelevant operations'); + return { manualPaid, monitorPaid, response, events, checkpoint, io, watched }; +} + +describe('monitor and HTTP verify: every existing USDC fixture', () => { + for (const [index, fixture] of USDC_VERIFY_CASES.entries()) { + it(fixture.name, async (t) => { + const input = structuredClone(fixture.input); + // Legacy no-memo captures are verifier examples, not invoice payments. + // Replay them with an explicit synthetic invoice memo and close time. + // No captured JSON is rewritten. Preserve all asset/amount/destination cases. + if (!input.expected.memo) { + input.expected.memo = `INV-585-REPLAY-${index}`; + input.transaction.memo = input.expected.memo; + input.transaction.memo_type = 'text'; + } + input.transaction.created_at = CLOSE_TIME; + // HTTP's invoice network is server-configured. Retain the fixture's + // mismatch relation when this test process is configured for TESTNET. + const mismatch = input.network !== input.expected.network; + input.expected.network = STELLAR_NETWORK; + input.network = mismatch ? (STELLAR_NETWORK === 'TESTNET' ? 'PUBLIC' : 'TESTNET') : STELLAR_NETWORK; + const result = await parity(t, input.expected, { + transaction: input.transaction, operations: input.operations, network: input.network, + }, input.txHash); + assert.equal(result.monitorPaid, fixture.expectedResult); + if (!fixture.expectedResult) assert.equal(result.response.body.code, fixture.expectedCode); + }); + } +}); + +const payment = (overrides: Record = {}) => ({ + type: 'payment', from: PAYER, to: SELLER, amount: '10.0000000', asset_type: 'native', ...overrides, +}); +const expected = { memo: 'INV-585-MULTI', amount: '10.0000000', destination: SELLER, assetCode: 'XLM' }; +const transaction = { memo: expected.memo, memo_type: 'text', created_at: CLOSE_TIME }; + +describe('complete transaction selection and cursor progress', () => { + it('finds the unique seller payment after more than ten unrelated operations', async (t) => { + const operations = [...Array.from({ length: 12 }, () => ({ type: 'manage_data' })), payment()]; + const result = await parity(t, expected, { transaction, operations }, HASH); + assert.equal(result.monitorPaid, true); + assert.equal(result.checkpoint.cursor, '13'); + }); + it('rejects two seller payments once, instead of settling the first page record', async (t) => { + const result = await parity(t, expected, { transaction, operations: [payment(), payment()] }, HASH); + assert.equal(result.monitorPaid, false); + assert.equal(result.response.body.code, 'AMBIGUOUS_PAYMENT_OPERATION'); + assert.equal(result.events.length, 1); + assert.equal(result.checkpoint.cursor, '2'); + }); + for (const type of ['account_merge', 'create_account']) { + it(`${type} with an invoice memo produces a typed rejection`, async (t) => { + const operation = type === 'account_merge' + ? { type, account: PAYER, into: SELLER } + : { type, funder: PAYER, account: SELLER, starting_balance: '10.0000000' }; + const result = await parity(t, expected, { transaction, operations: [operation] }, HASH); + assert.equal(result.monitorPaid, false); + assert.equal(result.response.body.code, 'UNSUPPORTED_PAYMENT_OPERATION'); + assert.equal(result.events[0].eventType, 'PAYMENT_REJECTED'); + assert.equal(result.events[0].eventData?.operationType, type); + }); + } +}); + +function receipt(name: string) { + const item = receipts.cases.find((entry) => entry.name === name)!; + const resources = item.resources as unknown as Record; + const read = (key: string) => JSON.parse(resources[key].raw_utf8); + const transaction = read('transaction'); + const operations = read(resources.operations ? 'operations' : 'transaction-operations')._embedded.records; + return { item, resources, read, transaction, operations }; +} + +describe('unchanged public testnet receipts', () => { + it('retains the exact captured response bytes and testnet network provenance', () => { + const resources = [receipts.network_resource, ...receipts.cases.flatMap((entry) => Object.values(entry.resources))]; + for (const resource of resources) { + assert.equal(resource.status, 200); + assert.equal(Buffer.byteLength(resource.raw_utf8), resource.bytes); + assert.equal(createHash('sha256').update(resource.raw_utf8).digest('hex'), resource.sha256); + } + assert.equal(JSON.parse(receipts.network_resource.raw_utf8).network_passphrase, receipts.network_passphrase); + }); + it('settles the real strict-receive envelope against a constructed invoice with its unchanged text memo', async (t) => { + const raw = receipt('strict_receive_xlm_to_usdc'); + const operation = normalizePaymentOperation(raw.operations[0])!; + const result = await parity(t, { + memo: raw.transaction.memo, destination: operation.to, amount: operation.amount, + assetCode: operation.assetCode!, assetIssuer: operation.assetIssuer, + }, { transaction: raw.transaction, operations: raw.operations }, raw.item.transaction_hash); + assert.equal(result.monitorPaid, true); + assert.equal(result.watched.memory.getInvoiceById(result.watched.invoice.id)?.settledAt?.toISOString(), new Date(raw.transaction.created_at).toISOString()); + }); + it('preserves real strict-send destination USDC without inventing an invoice memo', async (t) => { + const raw = receipt('strict_send_xlm_to_usdc'); + const io = horizon(t, { transaction: raw.transaction, operations: raw.operations }, raw.item.transaction_hash); + const page = await io.source.getPaymentsPage(raw.operations[0].to, '0', 100); + const received = page.find((row) => row.payment)?.payment!; + assert.equal(received.operationType, 'path_payment_strict_send'); + assert.equal(received.amount, '945.1853873'); + assert.equal(received.assetCode, 'USDC'); + assert.equal(received.assetIssuer, raw.operations[0].asset_issuer); + assert.equal(received.memo, undefined); + assert.notEqual(received.amount, raw.operations[0].source_amount); + assert.notEqual(received.amount, raw.operations[0].destination_min); + }); + it('settles a clearly synthetic memo-bearing derivative of the real strict-send shape', async (t) => { + const raw = receipt('strict_send_xlm_to_usdc'); + const operation = normalizePaymentOperation(raw.operations[0])!; + const memo = 'INV-585-SYNTHETIC-SEND'; + const result = await parity(t, { + memo, destination: operation.to, amount: operation.amount, + assetCode: operation.assetCode!, assetIssuer: operation.assetIssuer, + }, { transaction: { ...raw.transaction, memo_type: 'text', memo }, operations: raw.operations }, raw.item.transaction_hash); + assert.equal(result.monitorPaid, true); + }); + it('logs a real claimable balance once across cursor replay and monitor restart, leaving the invoice pending', async (t) => { + const raw = receipt('claimable_native_text_memo'); + const effects = raw.read('effects')._embedded.records; + const io = horizon(t, { transaction: raw.transaction, operations: raw.operations }, raw.item.transaction_hash, effects); + const claimant = raw.item.test_claimant!; + const target = invoices({ memo: raw.transaction.memo, destination: claimant, amount: '12.3300000', assetCode: 'XLM' }); + const checkpoint = new Checkpoints(); + const options = { account: claimant, network: STELLAR_NETWORK, source: io.source, invoices: target.service, checkpoints: checkpoint, database: undefined, database: undefined }; + await new PaymentMonitorService(options).runOnce(); + checkpoint.cursor = '0'; // crash after audit write but before durable cursor save + await new PaymentMonitorService(options).runOnce(); + const events = await target.service.getPaymentEvents(target.invoice.id); + assert.equal(events.length, 1); + assert.equal(events[0].eventType, 'CLAIMABLE_BALANCE_RECEIVED'); + assert.deepEqual(events[0].eventData, { + balanceId: raw.item.balance_id, amount: '12.3300000', asset: 'native', + predicate: { unconditional: true }, txHash: raw.item.transaction_hash, source: 'payment-monitor', + }); + assert.equal(target.memory.getInvoiceById(target.invoice.id)?.status, 'PENDING'); + assert.equal(io.counts.operations, 0, 'claim hints do not enter invoice settlement'); + const feed = raw.read('claimant-operations')._embedded.records; + assert.ok(feed.some((operation: any) => operation.id === raw.operations[0].id)); + }); + it('does not advance past a claimable operation when its creation effect is unavailable', async (t) => { + const raw = receipt('claimable_native_text_memo'); + const io = horizon(t, { transaction: raw.transaction, operations: raw.operations }, raw.item.transaction_hash); + const target = invoices({ memo: raw.transaction.memo, destination: raw.item.test_claimant!, amount: '12.3300000', assetCode: 'XLM' }); + const checkpoint = new Checkpoints(); + const monitor = new PaymentMonitorService({ + account: raw.item.test_claimant!, source: io.source, invoices: target.service, checkpoints: checkpoint, database: undefined, + }); + await assert.rejects(monitor.runOnce(), /creation effect is unavailable/); + assert.equal(checkpoint.cursor, '0'); + assert.equal((await target.service.getPaymentEvents(target.invoice.id)).length, 0); + }); +}); diff --git a/backend/tests/payment-monitor-durable-cursor.test.ts b/backend/tests/payment-monitor-durable-cursor.test.ts index 2035cce..8eca42b 100644 --- a/backend/tests/payment-monitor-durable-cursor.test.ts +++ b/backend/tests/payment-monitor-durable-cursor.test.ts @@ -6,6 +6,7 @@ import type { PaymentMonitorCheckpointStore, } from '../src/services/payment-monitor-checkpoint'; import type { StoredInvoice } from '../src/storage/invoice-storage'; +import { withPaymentTransactions } from './fixtures/payment-page-source'; const ACCOUNT = 'GSELLER'; const PAYER = 'GPAYER'; @@ -92,7 +93,7 @@ describe('PaymentMonitorService durable cursor', () => { const checkpoints = new MemoryCheckpoint('10'); const invoices = invoiceService(); let requestedCursor = ''; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '10'; }, @@ -100,7 +101,7 @@ describe('PaymentMonitorService durable cursor', () => { requestedCursor = cursor; return cursor === '10' ? [paymentRecord('11')] : []; }, - }; + }); // A new service instance represents the restarted process. It receives no // cursor through memory; only the durable checkpoint store is shared. @@ -128,14 +129,14 @@ describe('PaymentMonitorService durable cursor', () => { invoices.markAsPaid = async () => { throw new Error('database unavailable'); }; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '20'; }, async getPaymentsPage() { return [paymentRecord('21'), paymentRecord('22')]; }, - }; + }); const monitor = new PaymentMonitorService({ account: ACCOUNT, source, @@ -151,14 +152,14 @@ describe('PaymentMonitorService durable cursor', () => { it('records a partial payment and advances past the handled rejection', async () => { const checkpoints = new MemoryCheckpoint('30'); const invoices = invoiceService(); - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '30'; }, async getPaymentsPage(_account, cursor) { return cursor === '30' ? [paymentRecord('31', '24.0000000')] : []; }, - }; + }); const monitor = new PaymentMonitorService({ account: ACCOUNT, source, @@ -177,7 +178,7 @@ describe('PaymentMonitorService durable cursor', () => { it('anchors a first run at the latest token without an unbounded history scan', async () => { const checkpoints = new MemoryCheckpoint(); let pageCalls = 0; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '999'; }, @@ -185,7 +186,7 @@ describe('PaymentMonitorService durable cursor', () => { pageCalls += 1; return []; }, - }; + }); const monitor = new PaymentMonitorService({ account: ACCOUNT, source, diff --git a/backend/tests/payment-monitor-hydrate.test.ts b/backend/tests/payment-monitor-hydrate.test.ts index 5e5cc85..51fede6 100644 --- a/backend/tests/payment-monitor-hydrate.test.ts +++ b/backend/tests/payment-monitor-hydrate.test.ts @@ -9,6 +9,7 @@ import type { PaymentMonitorCheckpointStore, } from '../src/services/payment-monitor-checkpoint'; import type { StoredInvoice } from '../src/storage/invoice-storage'; +import { withPaymentTransactions } from './fixtures/payment-page-source'; const SELLER_KEY = 'GAA5INZB2GO3FJN4VXJYJSSIQXN4EKQTZWTR6R566TR3IMTXHSUDORLI'; const OTHER_SELLER = 'GBZXN7PIRZGNMHGA72UDEL52OQK6ICNJPF37ACNO42P7J7W4TX225656'; @@ -83,14 +84,14 @@ function createInvoiceStore(initial: StoredInvoice[]) { }; } -const EMPTY_SOURCE: PaymentPageSource = { +const EMPTY_SOURCE: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '100'; }, async getPaymentsPage() { return []; }, -}; +}); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -153,7 +154,7 @@ describe('Payment monitor restart hydration (issue #502)', () => { const invoice = createMockInvoice('inv-restart', 'MEMO-RESTART', 42); const store = createInvoiceStore([invoice]); - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '100'; }, @@ -177,7 +178,7 @@ describe('Payment monitor restart hydration (issue #502)', () => { }, ]; }, - }; + }); const monitor = new PaymentMonitorService({ account: SELLER_KEY, diff --git a/backend/tests/payment-monitor-runtime.test.ts b/backend/tests/payment-monitor-runtime.test.ts index 56e4d79..5c16fef 100644 --- a/backend/tests/payment-monitor-runtime.test.ts +++ b/backend/tests/payment-monitor-runtime.test.ts @@ -10,6 +10,7 @@ import type { } from '../src/services/payment-monitor-checkpoint'; import type { StoredInvoice } from '../src/storage/invoice-storage'; import { PaymentClaimError } from '../src/domain/payment-attribution'; +import { withPaymentTransactions } from './fixtures/payment-page-source'; const SELLER_KEY = 'GAA5INZB2GO3FJN4VXJYJSSIQXN4EKQTZWTR6R566TR3IMTXHSUDORLI'; const PAYER_KEY = 'GBZXN7PIRZGNMHGA72UDEL52OQK6ICNJPF37ACNO42P7J7W4TX225656'; @@ -118,7 +119,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, }; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '100'; }, @@ -142,7 +143,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, ]; }, - }; + }); const monitor = new PaymentMonitorService({ account: SELLER_KEY, @@ -191,7 +192,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, }; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '200'; }, @@ -215,7 +216,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, ]; }, - }; + }); const monitor = new PaymentMonitorService({ account: SELLER_KEY, @@ -261,7 +262,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, }; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '300'; }, @@ -285,7 +286,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, ]; }, - }; + }); const monitor = new PaymentMonitorService({ account: SELLER_KEY, @@ -324,7 +325,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, }; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '400'; }, @@ -348,7 +349,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }, ]; }, - }; + }); const monitor = new PaymentMonitorService({ account: SELLER_KEY, @@ -369,14 +370,14 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { it('exposes status metrics including watchedCount, processedTotal, and lagSeconds', async () => { const checkpoints = new TestCheckpointStore('500'); - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '500'; }, async getPaymentsPage() { return []; }, - }; + }); const monitor = new PaymentMonitorService({ account: SELLER_KEY, source, @@ -420,7 +421,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { }; let pageDelivered = false; - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return '600'; }, @@ -448,7 +449,7 @@ describe('PaymentMonitorService Runtime & Multi-Invoice Watch', () => { } return []; }, - }; + }); // First instance runs and settles const instance1 = new PaymentMonitorService({ diff --git a/backend/tests/runtime-log-privacy.test.ts b/backend/tests/runtime-log-privacy.test.ts index 2e620ca..9e51993 100644 --- a/backend/tests/runtime-log-privacy.test.ts +++ b/backend/tests/runtime-log-privacy.test.ts @@ -113,7 +113,7 @@ describe('runtime log privacy at real HTTP and service boundaries', () => { const invoice = created.body.data.invoice; horizonResponder = url => { if (scenario === 'outage') return { status: 503, body: { status: 503, title: PRIVATE_MARKER, detail: `wallet=${SELLER}` } }; - if (url.endsWith('/operations')) return { status: 200, body: { _embedded: { records: [{ + if (new URL(url, 'http://horizon.test').pathname.endsWith('/operations')) return { status: 200, body: { _embedded: { records: [{ id: '1', type: 'payment', from: PAYER, to: SELLER, amount: '25.0000000', asset_type: 'native', }] } } }; return { status: 200, body: { diff --git a/backend/tests/server-mvp-payment-monitor.test.ts b/backend/tests/server-mvp-payment-monitor.test.ts index dd4ecef..fe5bb8b 100644 --- a/backend/tests/server-mvp-payment-monitor.test.ts +++ b/backend/tests/server-mvp-payment-monitor.test.ts @@ -13,6 +13,7 @@ import paymentMonitorService, { PaymentPageSource, } from '../src/services/payment-monitor.service'; import { FilePaymentMonitorCheckpointStore } from '../src/services/payment-monitor-checkpoint'; +import { withPaymentTransactions } from './fixtures/payment-page-source'; const SELLER = 'GB3Q3VRHH3OQDYITTLONDLEHWQGKB27T2BEDSFHIUMOERULVXPDXRKG4'; const PAYER = 'GB6IHEZ4QNOHJZRYRFLOC45P4SK3KKL6KNPI5WEG6FNVSZ2K5FS2MNY7'; @@ -70,14 +71,14 @@ describe('server-mvp payment monitor integration', () => { paymentMonitorService.configure({ account: SELLER, network: 'TESTNET', - source: { + source: withPaymentTransactions({ async getLatestPaymentCursor() { return 'cursor-sync-0'; }, async getPaymentsPage() { return []; }, - }, + }), checkpoints: new FilePaymentMonitorCheckpointStore(checkpointFile), }); server = await new Promise((resolve, reject) => { @@ -118,7 +119,7 @@ describe('server-mvp payment monitor integration', () => { }); assert.equal(invoice.status, 'PENDING'); - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return 'cursor-0'; }, @@ -142,7 +143,7 @@ describe('server-mvp payment monitor integration', () => { }, ]; }, - }; + }); const checkpoints = new FilePaymentMonitorCheckpointStore(checkpointFile); await checkpoints.save({ @@ -184,7 +185,7 @@ describe('server-mvp payment monitor integration', () => { memo: 'MEMO-MVP-PARTIAL', }); - const source: PaymentPageSource = { + const source: PaymentPageSource = withPaymentTransactions({ async getLatestPaymentCursor() { return 'cursor-1'; }, @@ -208,7 +209,7 @@ describe('server-mvp payment monitor integration', () => { }, ]; }, - }; + }); const checkpoints = new FilePaymentMonitorCheckpointStore(checkpointFile); await checkpoints.save({ diff --git a/backend/tests/stellar-service.test.ts b/backend/tests/stellar-service.test.ts index 772a74e..dcb6379 100644 --- a/backend/tests/stellar-service.test.ts +++ b/backend/tests/stellar-service.test.ts @@ -27,18 +27,27 @@ describe('StellarService - Horizon Read Consolidation', () => { } }); - it('verifies payment with a non-existent 64-character hash returns TRANSACTION_NOT_FOUND', async () => { - const nonExistentHash = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; - const result = await stellarService.verifyPayment(nonExistentHash, { - memo: 'INV-123', - amount: 10, - destination: 'GABC', - assetCode: 'XLM', + it('returns TRANSACTION_NOT_FOUND for a confirmed Horizon 404', async (t) => { + t.mock.method(stellarService, 'getTransaction', async () => { + throw Object.assign(new Error('Horizon response'), { response: { status: 404 } }); }); + const result = await stellarService.verifyPayment( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + { memo: 'INV-123', amount: 10, destination: 'GABC', assetCode: 'XLM' }, + ); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.code, 'TRANSACTION_NOT_FOUND'); + }); + it('returns VERIFY_UNAVAILABLE for a confirmed Horizon 503', async (t) => { + t.mock.method(stellarService, 'getTransaction', async () => { + throw Object.assign(new Error('Horizon response'), { response: { status: 503 } }); + }); + const result = await stellarService.verifyPayment( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + { memo: 'INV-123', amount: 10, destination: 'GABC', assetCode: 'XLM' }, + ); assert.equal(result.ok, false); - if (!result.ok) { - assert.equal(result.code, 'TRANSACTION_NOT_FOUND'); - } + if (!result.ok) assert.equal(result.code, 'VERIFY_UNAVAILABLE'); }); }); diff --git a/db/schema.sql b/db/schema.sql index bf44e52..02380ab 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -152,7 +152,7 @@ BEGIN 'Resolve conflicts before re-running the migration.'; END IF; - CREATE UNIQUE INDEX invoices_payment_tx_hash_unique + CREATE UNIQUE INDEX IF NOT EXISTS invoices_payment_tx_hash_unique ON invoices(payment_tx_hash) WHERE payment_tx_hash IS NOT NULL; END IF; @@ -192,3 +192,8 @@ ALTER TABLE invoices ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(255); CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_seller_idempotency ON invoices (seller_public_key, idempotency_key) WHERE idempotency_key IS NOT NULL; + +-- Replaying a monitor cursor must not duplicate the same pending claim hint. +CREATE UNIQUE INDEX IF NOT EXISTS payment_events_claimable_balance_unique +ON payment_events (invoice_id, (event_data->>'balanceId')) +WHERE event_type = 'CLAIMABLE_BALANCE_RECEIVED'; diff --git a/docs/PAYMENT_MONITOR.md b/docs/PAYMENT_MONITOR.md index aac2474..31a2c4a 100644 --- a/docs/PAYMENT_MONITOR.md +++ b/docs/PAYMENT_MONITOR.md @@ -2,7 +2,7 @@ ## Decision -The backend uses bounded polling of the account payments collection in +The backend uses bounded polling of the account operations collection in ascending order. Each Horizon operation's paging token is committed only after that operation has been handled. This is preferable to the previous cursor-now stream because a reconnect resumes from a durable position and does @@ -26,7 +26,7 @@ payment_monitor_checkpoints. The cursor advances after each handled record, not at the end of a page: 1. Read the next ascending page after the stored cursor. -2. Ignore or verify one record. +2. Ignore a record, audit a claimable balance, or verify its complete transaction. 3. Persist a matching transaction and move the invoice to PAID. 4. Commit that record's paging token and ledger. @@ -69,6 +69,101 @@ An incoming operation settles exactly one invoice selected by its unique memo. I A partial amount is recorded as PARTIAL_PAYMENT and leaves the invoice pending. Other mismatches are recorded as PAYMENT_REJECTED. Both are handled records, so they advance the cursor and cannot block later valid payments. +## Path payments and claimable balances (#585) + +The account operations feed includes ordinary payments, strict-send and +strict-receive path payments, and claimable-balance creation. Its operation +paging tokens preserve existing payment-feed checkpoints. Non-payment records +still advance the cursor; the first-run bootstrap policy remains unchanged. + +For an incoming payment with an invoice memo, the monitor reuses the transaction +envelope observed during paging and loads its complete operation list once per +run. It calls the same `verifyHorizonPayment` used by `POST /api/invoices/:id/verify`. +The operation query explicitly requests 200 entries, covering the transaction's +full operation list instead of Horizon's default first page. A unique payment +to the seller is selected from the entire transaction; two matching payments +produce `AMBIGUOUS_PAYMENT_OPERATION`. Every page record is checkpointed even +when its transaction was already verified in the same run. + +Path payments use the amount and asset received by the destination. Source XLM +spent, maximum source amount, and minimum destination amount do not determine +invoice settlement. Credit assets retain their actual code and issuer. An +account creation or merge to the invoice seller is audited as +`PAYMENT_REJECTED` with `UNSUPPORTED_PAYMENT_OPERATION` when the transaction has +no supported payment operation. + +For a claimable-balance creation naming the monitored seller as claimant, a text +memo can identify a pending invoice. The creation effect provides the immutable +balance ID; the operation provides amount, asset and the seller's predicate. +The monitor records `CLAIMABLE_BALANCE_RECEIVED` and leaves the invoice pending. +A missing creation effect is retryable and prevents cursor advancement. The +seller feed shows the balance ID and a hint to claim it when its conditions +allow. The balance event itself never confirms invoice payment. + +The `(invoice_id, balanceId)` partial unique index in `db/schema.sql`, together +with `ON CONFLICT DO NOTHING`, makes the event idempotent across PostgreSQL +cursor replay. Memory storage applies the same key within that storage instance. +Apply the schema before enabling the updated monitor. + +### Reproducible evidence + +`backend/tests/monitor-verify-parity.test.ts` runs all ten existing USDC cases +through the real Horizon service mapping, monitor, memory storage and HTTP +verify route. Legacy examples without memos receive a clearly synthetic invoice +context; network mismatch direction is normalized to the process's configured +network. The amount, asset and destination cases remain intact. The suite also +covers more than ten operations, ambiguous payment selection, unsupported +operations, claim-event replay and a missing creation effect. + +`backend/tests/fixtures/monitor-testnet-receipts.json` retains fifteen exact HTTP +response bodies captured from public Testnet on 2026-10-04, with request URLs, +UTC capture times, byte counts and SHA-256 hashes. The four recorded transactions +are: + +| Shape | Transaction hash | Memo and use | +| --- | --- | --- | +| XLM → USDC strict send | `2858162875b8bd6daf282b295961e7c80c2141865fae3029899ef2d2302a5196` | No memo; unchanged mapping test plus an explicitly synthetic memo-bearing derivative | +| XLM → USDC strict receive | `fd6fedc2c62153606a76e51ea237d2c234753b8f261d0184543b5b4bf75fdf76` | Original text memo `off_60cil8zxp`; unchanged envelope, constructed test invoice | +| Native claimable balance | `fa0af3ca5f2ba206daa5d35c99ba6356fac8eca9ff172e5a846efce67be61d12` | Original text memo `createclaimablebalance`; unchanged envelope, constructed pending invoice | +| USDC claimable balance | `50b1f59a29591a8937e22b929a81fe4eeeac6ccfc071e024cc4b8aae7d08fa4c` | No memo; additional raw asset and predicate receipt | + +These are public operation-shape receipts, not evidence of a live Quittance +invoice. No transactions were submitted for this capture. Re-capture with GET +requests to the URLs stored in each resource. Testnet resets and later balance +claims can invalidate live lookups; retain the original bodies rather than +rewriting them. The tests check the stored byte hashes and claimant-feed +membership. The wallet test in `frontend/tests/payment-monitor.test.js` covers +all three payment types, destination values, concurrent replay, one toast per +operation, and the claim hint. + +Run the focused checks from the corresponding package directory: + +```sh +# backend +node --import tsx --test tests/monitor-verify-parity.test.ts tests/payment-monitor-*.test.ts tests/server-mvp-payment-monitor.test.ts tests/invoice-cancel-payment-race.test.ts tests/usdc-verify-edge-cases.test.ts tests/payment-verification.test.ts +# frontend +node --test tests/payment-monitor.test.js tests/verify-rejection-label.test.js +``` + +The optional `backend/tests/claimable-event-postgres.integration.test.ts` uses +`DATABASE_URL` and an isolated schema to check concurrent duplicate writers, +schema replay, a fresh database connection, and preservation of other audit +events. It is skipped when no PostgreSQL test database is configured; the +memory replay check does not establish PostgreSQL durability. + +### Recorded PostgreSQL acceptance + +On 2026-10-04, `claimable-event-postgres.integration.test.ts` passed on source +`66f18e99a62c8373b5e487100d99f49368e2576f`: **1 passed, 0 failed, 0 skipped**. +The native runner used Node 24.21.0, npm 11.19.0 and PostgreSQL 16.15. Test time +was 93.757396 ms; total process time was 413.141959 ms. The case exercised +concurrent writes, schema replay, a new connection, pending invoice state, +a distinct balance ID, and preservation of unrelated audit events. + +[Native run 37204455112, job 111442631510](https://github.com/woahwhattheheck/bounty-concierge/actions/runs/37204455112/job/111442631510) +retains the source checkout and output. Its Q585 PostgreSQL step succeeded; +the combined workflow later failed in a separate Q584 Horizon 404 fixture. + ## Failure matrix and restart safety | Failure | Cursor effect | Retry or operator signal | Invoice effect | diff --git a/docs/Q585_SUBMISSION.md b/docs/Q585_SUBMISSION.md new file mode 100644 index 0000000..6ccf3a1 --- /dev/null +++ b/docs/Q585_SUBMISSION.md @@ -0,0 +1,50 @@ +# Quittance #585 submission + +Target repository: `Quittance-Labs/Quittance0` +Base branch: `main` +Head: `woahwhattheheck:feat/q585-monitor-payment-parity-ledger0938` +Title: **Align monitor and wallet with path payments and pending claimable balances** +Implementation commit: `2704b3ed3878cafcee38a526b7111d7904eed7a6` + +## Pull request body + +Closes #585. + +The backend monitor previously filtered out path payments and rebuilt each observed payment as one synthetic operation. That could disagree with manual verification, lose the delivered credit asset, and incorrectly accept the first payment in an ambiguous transaction. Claimable balances also never reached the seller's event feed. + +## Changes + +- Poll the account operations feed while retaining existing operation paging tokens. Normalize ordinary, strict-send and strict-receive payments using destination amount/code/issuer. +- Reuse the observed transaction envelope and fetch its complete operation list once per candidate transaction. Run the same `verifyHorizonPayment` as POST verify; preserve multi-operation selection, ledger settlement time and per-record checkpoints. +- Observe claimable-balance creation for the seller, resolve its immutable balance ID from creation effects, and log `CLAIMABLE_BALANCE_RECEIVED` with amount, asset and predicate. The invoice stays pending. Memory storage and a PostgreSQL partial unique index deduplicate cursor replay. +- Give account creation/merge an explicit shared rejection code. Show claim information in the seller feed; accept path payments in the wallet stream with one received notification per operation. +- Preserve existing monitor fixtures through a test-only full-transaction source helper and document the actual Horizon field mapping. + +## Validation + +Both packages installed from their existing lockfiles with `npm ci`; neither lockfile changed. + +- Backend: 103 focused verifier/monitor/server tests passed, plus 22 existing cancellation/attribution tests passed (125 total; no skips). +- Frontend: 16 payment-stream, event-rendering and rejection-label tests passed with locked dependencies. +- Backend and frontend TypeScript checks passed. +- The new acceptance suite drives the actual Horizon service mapping, monitor, memory storage and HTTP POST verify route. It covers every existing USDC fixture, more than ten operations, ambiguous seller payments, both unsupported operation types, claimable replay/restart and missing creation effects. + +The focused commands and fixture interpretation are in [PAYMENT_MONITOR.md](docs/PAYMENT_MONITOR.md). + +## Public Testnet evidence + +The committed [receipt bundle](backend/tests/fixtures/monitor-testnet-receipts.json) retains 15 exact public HTTP response bodies with URLs, capture timestamps, byte counts and SHA-256 hashes. It includes real strict-send, strict-receive, native claimable and USDC claimable transactions. The tests consume unchanged strict-receive and memo-bearing claimable envelopes against constructed test invoices. + +The strict-send capture has **no memo**. Its unchanged destination mapping is tested separately from an explicitly synthetic memo-bearing derivative. The legacy path rows also remain labeled synthetic. No live Quittance invoice or newly submitted transaction is asserted. + +## PostgreSQL rollout and acceptance + +Apply `db/schema.sql` before enabling this monitor so `payment_events_claimable_balance_unique` exists. The `claimable-event-postgres.integration.test.ts` acceptance checks concurrent writers, schema replay and a fresh connection in its own schema when `DATABASE_URL` is provided. + +This check passed on implementation head `66f18e99a62c8373b5e487100d99f49368e2576f` in [native run 37204455112, job 111442631510](https://github.com/woahwhattheheck/bounty-concierge/actions/runs/37204455112/job/111442631510), using Node 24.21.0, npm 11.19.0 and PostgreSQL 16.15. Result: **1 test passed, 0 failed, 0 skipped**; 93.757396 ms test time, 413.141959 ms total. Command, from `backend` with the isolated runner database configured: + +```sh +node --import tsx --test tests/claimable-event-postgres.integration.test.ts +``` + +The combined workflow subsequently failed in a separate Q584 Horizon 404 fixture. The Q585 PostgreSQL acceptance step itself succeeded. diff --git a/docs/USDC-VERIFY-EDGE-CASES.md b/docs/USDC-VERIFY-EDGE-CASES.md index b797c10..e980fc5 100644 --- a/docs/USDC-VERIFY-EDGE-CASES.md +++ b/docs/USDC-VERIFY-EDGE-CASES.md @@ -75,29 +75,29 @@ Verification mapping: ### 2.3 Path Payment Strict Send (`path_payment_strict_send`) -The payer specifies the exact source amount sent. The destination receives `dest_amount` of the destination asset. +The payer specifies the exact source amount sent. Horizon reports what the destination receives in `amount` and `asset_*`; `source_amount` and `source_asset_*` describe the sender. The verifier also accepts the legacy synthetic `dest_*` aliases retained by the original fixture matrix. ```json { "type": "path_payment_strict_send", "from": "GCUXM6OT4H6PD7R6YUS632SDK36BYKDESGS4BSHTPTPDXBCYTE6JUEJE", "to": "GAYF33NNNMI2Z6VNRFXQ64D4E4SF77PM46NW3ZUZEEU5X7FCHAZCMHKU", - "amount": "200.0000000", + "source_amount": "200.0000000", "source_asset_type": "native", - "dest_amount": "25.0000000", - "dest_min": "24.5000000", - "dest_asset_type": "credit_alphanum4", - "dest_asset_code": "USDC", - "dest_asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "amount": "25.0000000", + "destination_min": "24.5000000", + "asset_type": "credit_alphanum4", + "asset_code": "USDC", + "asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", "path": [] } ``` Verification mapping: - Destination: `operation.to` -- Amount delivered: `operation.dest_amount` -- Asset code: `operation.dest_asset_code` -- Asset issuer: `operation.dest_asset_issuer` +- Amount delivered: `operation.amount` +- Asset code: `operation.asset_code` +- Asset issuer: `operation.asset_issuer` --- @@ -222,3 +222,14 @@ The following items are outside the scope of Issue #378: 1. Direct fiat on/off-ramp anchor integrations (SEP-24 / SEP-6). 2. Mainnet Circle issuer deployment and automated multi-issuer swapping. 3. Multi-currency portfolio analytics on the seller dashboard. + +## Monitor parity and exact public receipts + +The original path-payment examples above and in `usdc-verify-edge-cases.fixture.ts` +are synthetic. Their reused ordinary-payment hash does not prove those path +operations occurred on-chain. Exact public strict-send, strict-receive and +claimable-balance responses, with their real transaction hashes and provenance, +now live in `backend/tests/fixtures/monitor-testnet-receipts.json`. See +[Payment monitor evidence](PAYMENT_MONITOR.md#reproducible-evidence) for the +unchanged receipts, explicitly synthetic invoice replays, and the monitor/HTTP +acceptance command. diff --git a/frontend/components/PaymentEventsFeed.tsx b/frontend/components/PaymentEventsFeed.tsx index dc5df3d..579a890 100644 --- a/frontend/components/PaymentEventsFeed.tsx +++ b/frontend/components/PaymentEventsFeed.tsx @@ -7,7 +7,15 @@ import { formatDate } from '@/lib/utils'; interface PaymentEvent { id: string; eventType: string; - eventData?: { code?: string; txHash?: string; source?: string } | null; + eventData?: { + code?: string; + txHash?: string; + source?: string; + balanceId?: string; + amount?: string; + asset?: string; + predicate?: unknown; + } | null; createdAt: string; } @@ -15,6 +23,7 @@ const EVENT_LABELS: Record = { PAYMENT_CONFIRMED: 'Payment confirmed', PAYMENT_REJECTED: 'Payment rejected', PARTIAL_PAYMENT: 'Partial payment received', + CLAIMABLE_BALANCE_RECEIVED: 'Claimable balance received', }; /** @@ -61,6 +70,22 @@ export default function PaymentEventsFeed({

{EVENT_LABELS[event.eventType] ?? event.eventType}

+ {event.eventType === 'CLAIMABLE_BALANCE_RECEIVED' && ( +
+ {event.eventData?.amount && event.eventData?.asset && ( +

+ {event.eventData.amount} {event.eventData.asset === 'native' ? 'XLM' : event.eventData.asset} +

+ )} + {event.eventData?.balanceId && ( +

Balance {event.eventData.balanceId}

+ )} +

+ Claim this balance in a compatible Stellar wallet when its claim conditions allow. + The invoice remains pending until a qualifying payment is verified. +

+
+ )} {event.eventData?.code && (

{event.eventData.code}

)} diff --git a/frontend/lib/payment-monitor.ts b/frontend/lib/payment-monitor.ts index 56001b1..6e60604 100644 --- a/frontend/lib/payment-monitor.ts +++ b/frontend/lib/payment-monitor.ts @@ -3,7 +3,7 @@ * Monitors Stellar blockchain for incoming payments to user's wallet */ -import { server } from './stellar'; +import { isPaymentOperation, server } from './stellar'; import { canonicalAmount } from './stroop-amount.js'; import { explorerSegmentFor, resolveStellarNetwork } from '@shared/network'; import { toast } from 'sonner'; @@ -56,46 +56,73 @@ class PaymentMonitor { this.callbacks.set(publicKey, onPayment ? [onPayment] : []); try { + let streamActive = true; + const pendingPaymentIds = new Set(); + const deliveredPaymentIds = new Set(); const closeHandler = server .payments() .forAccount(publicKey) .cursor('now') // Only new payments .stream({ onmessage: async (record: any) => { + // Horizon can replay an operation while reconnecting. Reserve its + // ID before the memo lookup so concurrent deliveries toast once. + if (!streamActive || !isPaymentOperation(record) || record.to !== publicKey) return; + const paymentId = record.id || record.paging_token; + if ( + !paymentId || + pendingPaymentIds.has(paymentId) || + deliveredPaymentIds.has(paymentId) + ) return; + pendingPaymentIds.add(paymentId); + try { - // Only process incoming payments - if (record.type === 'payment' && record.to === publicKey) { - // Fetch transaction for memo - const transaction = await server - .transactions() - .transaction(record.transaction_hash) - .call(); - - const payment: PaymentNotification = { - id: record.id, - hash: record.transaction_hash, - from: record.from, - to: record.to, - amount: record.amount, - assetCode: record.asset_type === 'native' ? 'XLM' : record.asset_code, - memo: transaction.memo || undefined, - timestamp: new Date(record.created_at), - }; - - console.log('Payment received:', payment); - - // Show toast notification - this.showNotification(payment); - - // Call all registered callbacks - const callbacks = this.callbacks.get(publicKey) || []; - callbacks.forEach((callback) => callback(payment)); + // Fetch transaction for memo + const transaction = await server + .transactions() + .transaction(record.transaction_hash) + .call(); + + if (!streamActive) return; + + const payment: PaymentNotification = { + id: paymentId, + hash: record.transaction_hash, + from: record.from, + to: record.to, + amount: record.amount, + assetCode: record.asset_type === 'native' ? 'XLM' : record.asset_code, + memo: transaction.memo || undefined, + timestamp: new Date(record.created_at), + }; + + console.log('Payment received:', payment); + + // Show toast notification + this.showNotification(payment); + deliveredPaymentIds.add(paymentId); + // Bound replay bookkeeping for wallets left open for days. + if (deliveredPaymentIds.size > 1000) { + deliveredPaymentIds.delete(deliveredPaymentIds.values().next().value!); } + + // Call all registered callbacks + const callbacks = this.callbacks.get(publicKey) || []; + callbacks.forEach((callback) => { + try { + callback(payment); + } catch (error) { + console.error('Payment callback failed:', error); + } + }); } catch (error) { console.error('Error processing payment:', error); + } finally { + pendingPaymentIds.delete(paymentId); } }, onerror: (error: any) => { + if (!streamActive) return; console.error('Payment stream error:', error); // Horizon/stream failures are surfaced as a stable string so the // monitor banner reads consistently with API/verification errors. @@ -105,6 +132,7 @@ class PaymentMonitor { // Try to reconnect after 5 seconds setTimeout(() => { + if (!streamActive) return; console.log('Reconnecting payment stream...'); this.stopMonitoring(publicKey); this.startMonitoring(publicKey, onPayment); @@ -112,7 +140,10 @@ class PaymentMonitor { }, }); - this.activeStreams.set(publicKey, closeHandler); + this.activeStreams.set(publicKey, () => { + streamActive = false; + closeHandler(); + }); toast.success('Payment monitoring active', { description: 'You will be notified of incoming payments', diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index b7bce15..582e0f7 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -557,8 +557,19 @@ export const checkTransactionStatus = async ( } }; +/** Horizon uses destination-side amount/asset fields for all three shapes. */ +export const isPaymentOperation = ( + record: { type?: string; transaction_successful?: boolean } | null | undefined +): boolean => + record?.transaction_successful !== false && + (record?.type === 'payment' || + record?.type === 'path_payment_strict_receive' || + record?.type === 'path_payment_strict_send'); + /** - * Stream payments for an account + * Stream payment operations for an account. Keep the Horizon record intact: + * amount/asset_* describe what the destination receives, not source_amount + * or source_asset_* spent by a path-payment sender. */ export const streamPayments = ( publicKey: string, @@ -570,7 +581,7 @@ export const streamPayments = ( .cursor('now') .stream({ onmessage: (payment: any) => { - if (payment.type === 'payment') { + if (isPaymentOperation(payment)) { onPayment(payment); } }, diff --git a/frontend/lib/verification.d.ts b/frontend/lib/verification.d.ts index f6c230f..845cc89 100644 --- a/frontend/lib/verification.d.ts +++ b/frontend/lib/verification.d.ts @@ -12,6 +12,7 @@ export type VerificationCode = | 'TRANSACTION_CLOSE_TIME_UNAVAILABLE' | 'VERIFY_UNAVAILABLE' | 'NO_PAYMENT_OPERATION' + | 'UNSUPPORTED_PAYMENT_OPERATION' | 'AMBIGUOUS_PAYMENT_OPERATION' | 'MEMO_TYPE_MISMATCH' | 'MEMO_MISMATCH' diff --git a/frontend/lib/verify-rejection-label.ts b/frontend/lib/verify-rejection-label.ts index ff81df7..af86539 100644 --- a/frontend/lib/verify-rejection-label.ts +++ b/frontend/lib/verify-rejection-label.ts @@ -22,7 +22,8 @@ // 'VERIFY_RATE_LIMIT_EXCEEDED' | 'INVOICE_EXPIRED' | 'INVOICE_NOT_PENDING' | // 'TRANSACTION_NOT_FOUND' | 'TRANSACTION_CLOSE_TIME_UNAVAILABLE' | // 'VERIFY_UNAVAILABLE' | -// 'NO_PAYMENT_OPERATION' | 'AMBIGUOUS_PAYMENT_OPERATION' | 'MEMO_TYPE_MISMATCH' | 'MEMO_MISMATCH' | +// 'NO_PAYMENT_OPERATION' | 'UNSUPPORTED_PAYMENT_OPERATION' | 'AMBIGUOUS_PAYMENT_OPERATION' | +// 'MEMO_TYPE_MISMATCH' | 'MEMO_MISMATCH' | // 'DESTINATION_MISMATCH' | // 'AMOUNT_MISMATCH' | 'AMOUNT_TOO_LOW' | 'AMOUNT_TOO_HIGH' | // 'ASSET_MISMATCH' | 'NETWORK_MISMATCH' | @@ -54,6 +55,7 @@ const REJECTION_LABELS: Record = { TRANSACTION_CLOSE_TIME_UNAVAILABLE: 'Close time unavailable', VERIFY_UNAVAILABLE: 'Verification unavailable', NO_PAYMENT_OPERATION: 'No payment operation', + UNSUPPORTED_PAYMENT_OPERATION: 'Unsupported payment operation', AMBIGUOUS_PAYMENT_OPERATION: 'Multiple payments to seller', MEMO_TYPE_MISMATCH: 'Not a text memo', MEMO_MISMATCH: 'Memo mismatch', diff --git a/frontend/tests/fixtures/verify-rejection-label.fixture.js b/frontend/tests/fixtures/verify-rejection-label.fixture.js index 62388e9..3160e0a 100644 --- a/frontend/tests/fixtures/verify-rejection-label.fixture.js +++ b/frontend/tests/fixtures/verify-rejection-label.fixture.js @@ -19,6 +19,7 @@ const KNOWN_CODE_FIXTURES = [ { code: 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', label: 'Close time unavailable' }, { code: 'VERIFY_UNAVAILABLE', label: 'Verification unavailable' }, { code: 'NO_PAYMENT_OPERATION', label: 'No payment operation' }, + { code: 'UNSUPPORTED_PAYMENT_OPERATION', label: 'Unsupported payment operation' }, { code: 'AMBIGUOUS_PAYMENT_OPERATION', label: 'Multiple payments to seller' }, { code: 'MEMO_TYPE_MISMATCH', label: 'Not a text memo' }, { code: 'MEMO_MISMATCH', label: 'Memo mismatch' }, diff --git a/frontend/tests/payment-monitor.test.js b/frontend/tests/payment-monitor.test.js new file mode 100644 index 0000000..a973f62 --- /dev/null +++ b/frontend/tests/payment-monitor.test.js @@ -0,0 +1,245 @@ +/** + * Wallet stream parity for issue #585. The real TS modules are compiled with + * the existing esbuild dependency; only Horizon, Freighter and toast I/O are + * replaced. No transaction is submitted and no wallet is contacted. + */ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const Module = require('node:module'); +const esbuild = require('esbuild'); + +const ROOT = path.resolve(__dirname, '..'); +const SELLER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; +const BUYER = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; +const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; + +function sourceLoader(aliases) { + const cache = new Map(); + function load(file) { + if (!fs.existsSync(file)) file += '.ts'; + if (cache.has(file)) return cache.get(file).exports; + const compiled = new Module(file, module); + compiled.filename = file; + compiled.paths = Module._nodeModulePaths(path.dirname(file)); + cache.set(file, compiled); + compiled.require = (specifier) => { + if (Object.hasOwn(aliases, specifier)) return aliases[specifier]; + if (specifier === '@shared/network') return load(path.join(ROOT, '../shared/network.ts')); + if (specifier.startsWith('.')) return load(path.resolve(path.dirname(file), specifier)); + return require(specifier); + }; + const result = esbuild.transformSync(fs.readFileSync(file, 'utf8'), { + loader: file.endsWith('.tsx') ? 'tsx' : 'ts', + format: 'cjs', + jsx: 'automatic', + target: 'es2020', + sourcefile: file, + }); + compiled._compile(result.code, file); + return compiled.exports; + } + return load; +} + +function harness() { + const state = { streams: [], toasts: [], lookups: [], lookup: async () => ({ memo: 'INV-585' }) }; + const horizon = { + payments() { + const stream = { account: null, cursor: null, closed: false }; + return { + forAccount(account) { stream.account = account; return this; }, + cursor(cursor) { stream.cursor = cursor; return this; }, + stream(handlers) { + Object.assign(stream, handlers); + state.streams.push(stream); + return () => { stream.closed = true; }; + }, + }; + }, + transactions() { + return { + transaction(hash) { + return { + call() { state.lookups.push(hash); return state.lookup(hash); }, + }; + }, + }; + }, + }; + const load = sourceLoader({ + '@stellar/stellar-sdk': { Horizon: { Server: class { constructor() { return horizon; } } } }, + '@stellar/freighter-api': {}, + './freighter-availability': {}, + './network-display-name': { networkDisplayName: () => 'Testnet' }, + './trustline-preflight': {}, + '@shared/memo': {}, + sonner: { + toast: { + success: (title, options) => state.toasts.push({ title, options }), + error: (title, options) => state.toasts.push({ title, options }), + }, + }, + }); + return { + state, + stellar: load(path.join(ROOT, 'lib/stellar.ts')), + monitor: load(path.join(ROOT, 'lib/payment-monitor.ts')).paymentMonitor, + receivedToasts: () => state.toasts.filter((entry) => entry.title === 'Payment Received!'), + }; +} + +function payment(type, overrides = {}) { + return { + id: 'operation-585', + type, + transaction_hash: 'a'.repeat(64), + transaction_successful: true, + from: BUYER, + to: SELLER, + amount: '12.5000000', + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: USDC_ISSUER, + source_amount: '99.0000000', + source_asset_type: 'native', + created_at: '2026-10-01T12:00:00Z', + ...overrides, + }; +} + +test('account stream recognizes all accepted payment shapes and preserves destination fields', () => { + const h = harness(); + const seen = []; + const close = h.stellar.streamPayments(SELLER, (record) => seen.push(record)); + const records = ['payment', 'path_payment_strict_receive', 'path_payment_strict_send'].map((type) => payment(type)); + for (const record of records) h.state.streams[0].onmessage(record); + for (const type of ['create_claimable_balance', 'account_merge', 'create_account']) { + h.state.streams[0].onmessage(payment(type)); + } + h.state.streams[0].onmessage(payment('path_payment_strict_send', { transaction_successful: false })); + assert.deepEqual(seen, records); + assert.equal(seen[2], records[2], 'forward the original destination-side record'); + assert.equal(seen[2].amount, '12.5000000'); + assert.equal(seen[2].asset_code, 'USDC'); + assert.equal(h.state.streams[0].cursor, 'now'); + close(); + assert.equal(h.state.streams[0].closed, true); +}); + +for (const type of ['payment', 'path_payment_strict_receive', 'path_payment_strict_send']) { + test(`${type}: an incoming operation emits one received toast and destination-side notification`, async () => { + const h = harness(); + const received = []; + h.monitor.startMonitoring(SELLER, (record) => received.push(record)); + await h.state.streams[0].onmessage(payment(type)); + assert.equal(received.length, 1); + assert.equal(received[0].amount, '12.5000000'); + assert.equal(received[0].assetCode, 'USDC'); + assert.equal(received[0].to, SELLER); + assert.equal(received[0].memo, 'INV-585'); + assert.equal(h.receivedToasts().length, 1); + assert.match(h.receivedToasts()[0].options.description, /12\.5000000 USDC/); + assert.doesNotMatch(h.receivedToasts()[0].options.description, /99|XLM/); + h.monitor.stopAll(); + }); +} + +test('concurrent and later replay of the same operation do not duplicate memo fetches or toasts', async () => { + const h = harness(); + let finish; + h.state.lookup = () => new Promise((resolve) => { finish = resolve; }); + const received = []; + h.monitor.startMonitoring(SELLER, (record) => received.push(record)); + const record = payment('path_payment_strict_send'); + const first = h.state.streams[0].onmessage(record); + const replay = h.state.streams[0].onmessage(record); + assert.equal(h.state.lookups.length, 1); + assert.equal(h.receivedToasts().length, 0); + finish({ memo: 'INV-585' }); + await Promise.all([first, replay]); + await h.state.streams[0].onmessage(record); + assert.equal(h.state.lookups.length, 1); + assert.equal(received.length, 1); + assert.equal(h.receivedToasts().length, 1); + h.monitor.stopAll(); +}); + +test('outgoing, failed and non-payment operations never trigger received notifications', async () => { + const h = harness(); + const received = []; + h.monitor.startMonitoring(SELLER, (record) => received.push(record)); + const records = [ + payment('path_payment_strict_send', { to: BUYER, from: SELLER }), + payment('path_payment_strict_receive', { transaction_successful: false }), + payment('create_claimable_balance'), + payment('account_merge'), + payment('create_account'), + ]; + for (const record of records) await h.state.streams[0].onmessage(record); + assert.equal(h.state.lookups.length, 0); + assert.deepEqual(received, []); + assert.deepEqual(h.receivedToasts(), []); + h.monitor.stopAll(); +}); + +test('stopping a wallet during the memo lookup suppresses the stale received notification', async () => { + const h = harness(); + let finish; + h.state.lookup = () => new Promise((resolve) => { finish = resolve; }); + const received = []; + h.monitor.startMonitoring(SELLER, (record) => received.push(record)); + const processing = h.state.streams[0].onmessage(payment('path_payment_strict_send')); + h.monitor.stopMonitoring(SELLER); + finish({ memo: 'INV-585' }); + await processing; + assert.deepEqual(received, []); + assert.deepEqual(h.receivedToasts(), []); + assert.equal(h.monitor.isMonitoring(SELLER), false); +}); + +test('claimable balance feed shows its identity and claim hint without confirming payment', async () => { + const React = require('react'); + const { JSDOM } = require('jsdom'); + const dom = new JSDOM('
'); + const previous = new Map(); + for (const name of ['window', 'document', 'navigator']) { + previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true }); + } + previous.set('IS_REACT_ACT_ENVIRONMENT', Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT')); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + const calls = []; + const event = { + id: 'event-585', + eventType: 'CLAIMABLE_BALANCE_RECEIVED', + eventData: { balanceId: 'balance-585', amount: '12.5000000', asset: `USDC:${USDC_ISSUER}`, predicate: { unconditional: true } }, + createdAt: '2026-10-01T12:00:00Z', + }; + const load = sourceLoader({ + '@/lib/api': { invoiceApi: { getPaymentEvents: async (...args) => { calls.push(args); return { data: [event] }; } } }, + '@/lib/utils': { formatDate: (value) => value }, + }); + const Feed = load(path.join(ROOT, 'components/PaymentEventsFeed.tsx')).default; + const { createRoot } = require('react-dom/client'); + const container = dom.window.document.getElementById('root'); + const root = createRoot(container); + try { + await React.act(async () => { root.render(React.createElement(Feed, { invoiceId: 'inv-585', sellerPublicKey: SELLER })); }); + assert.deepEqual(calls, [['inv-585', SELLER]]); + assert.match(container.textContent, /Claimable balance received/); + assert.match(container.textContent, /12\.5000000 USDC:/); + assert.match(container.textContent, /Balance balance-585/); + assert.match(container.textContent, /Claim this balance/); + assert.match(container.textContent, /invoice remains pending/); + assert.doesNotMatch(container.textContent, /Payment confirmed/); + } finally { + await React.act(async () => root.unmount()); + dom.window.close(); + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; + } + } +}); diff --git a/frontend/tests/verification.test.js b/frontend/tests/verification.test.js index e600350..de25024 100644 --- a/frontend/tests/verification.test.js +++ b/frontend/tests/verification.test.js @@ -97,6 +97,7 @@ test('covers every rejection code with a message', () => { 'TRANSACTION_CLOSE_TIME_UNAVAILABLE', 'VERIFY_UNAVAILABLE', 'NO_PAYMENT_OPERATION', + 'UNSUPPORTED_PAYMENT_OPERATION', 'AMBIGUOUS_PAYMENT_OPERATION', 'MEMO_TYPE_MISMATCH', 'MEMO_MISMATCH', diff --git a/shared/verification.ts b/shared/verification.ts index 4812185..f347122 100644 --- a/shared/verification.ts +++ b/shared/verification.ts @@ -48,6 +48,7 @@ export type VerificationCode = | 'TRANSACTION_CLOSE_TIME_UNAVAILABLE' | 'VERIFY_UNAVAILABLE' | 'NO_PAYMENT_OPERATION' + | 'UNSUPPORTED_PAYMENT_OPERATION' | 'AMBIGUOUS_PAYMENT_OPERATION' | 'MEMO_TYPE_MISMATCH' | 'MEMO_MISMATCH' @@ -98,6 +99,7 @@ export const VERIFICATION_MESSAGES: Record = { TRANSACTION_CLOSE_TIME_UNAVAILABLE: 'Transaction close time is unavailable; try verification again later', VERIFY_UNAVAILABLE: 'Verification is temporarily unavailable; try again within 30 seconds', NO_PAYMENT_OPERATION: 'No payment operation found in transaction', + UNSUPPORTED_PAYMENT_OPERATION: 'Account creation or merge cannot settle an invoice', AMBIGUOUS_PAYMENT_OPERATION: 'Transaction contains more than one payment to the invoice destination', MEMO_TYPE_MISMATCH: 'Payment memo type is not a text memo', MEMO_MISMATCH: 'Memo mismatch',