diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb47c7..d7738f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- `usePayments` now extracts real transfers from Soroban `invoke_host_function` operations instead of returning blank 0 XLM rows. +- `usePayments` reports the actual merged amount for `account_merge` operations by reading operation effects. +- Unhandled payment operation types are filtered out instead of being returned as fabricated zero-amount rows. + ### Added - Typed wallet adapter, payment, asset, trustline, and Soroban simulation error codes. diff --git a/packages/core/src/__tests__/fixtures/horizon-payments.ts b/packages/core/src/__tests__/fixtures/horizon-payments.ts new file mode 100644 index 0000000..7c12c63 --- /dev/null +++ b/packages/core/src/__tests__/fixtures/horizon-payments.ts @@ -0,0 +1,100 @@ +// Realistic testnet Horizon fixtures for the six PaymentRecord union members. +// Addresses are placeholder testnet addresses, not mainnet. + +export const TARGET = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +export const SENDER = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" +export const RECEIVER = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" +export const ISSUER = "GDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD" + +export const nativePayment = { + id: "1001", + type: "payment", + transaction_hash: "tx1001", + created_at: "2026-01-01T00:00:00Z", + from: SENDER, + to: TARGET, + amount: "10.5", + asset_type: "native", +} + +export const createAccount = { + id: "1002", + type: "create_account", + transaction_hash: "tx1002", + created_at: "2026-01-01T00:00:01Z", + funder: SENDER, + account: TARGET, + starting_balance: "1.5", +} + +export const accountMerge = { + id: "1003", + type: "account_merge", + transaction_hash: "tx1003", + created_at: "2026-01-01T00:00:02Z", + account: TARGET, + into: RECEIVER, +} + +export const accountMergeEffects = [ + { + id: "1003-1", + type: "account_debited", + account: TARGET, + amount: "25.5", + asset_type: "native", + }, + { + id: "1003-2", + type: "account_credited", + account: RECEIVER, + amount: "25.5", + asset_type: "native", + }, +] + +export const pathPaymentStrictReceive = { + id: "1004", + type: "path_payment_strict_receive", + transaction_hash: "tx1004", + created_at: "2026-01-01T00:00:03Z", + from: SENDER, + to: TARGET, + amount: "7.25", + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: ISSUER, + source_amount: "7.25", + source_asset_type: "native", +} + +export const pathPaymentStrictSend = { + id: "1005", + type: "path_payment_strict_send", + transaction_hash: "tx1005", + created_at: "2026-01-01T00:00:04Z", + from: TARGET, + to: RECEIVER, + amount: "3.5", + asset_type: "native", + source_amount: "3.5", + source_asset_type: "native", +} + +export const invokeHostFunction = { + id: "1006", + type: "invoke_host_function", + transaction_hash: "tx1006", + created_at: "2026-01-01T00:00:05Z", + asset_balance_changes: [ + { + type: "asset_balance_change", + from: SENDER, + to: TARGET, + amount: "12.0", + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: ISSUER, + }, + ], +} diff --git a/packages/core/src/hooks/usePayments.test.tsx b/packages/core/src/hooks/usePayments.test.tsx index 0c2a45a..06c69bc 100644 --- a/packages/core/src/hooks/usePayments.test.tsx +++ b/packages/core/src/hooks/usePayments.test.tsx @@ -1,3 +1,25 @@ +import React from "react" +import { renderHook, act, waitFor } from "@testing-library/react" +import { StellarProvider } from "../context/StellarProvider" +import { usePayments } from "./usePayments" +import { + nativePayment, + createAccount, + accountMerge, + pathPaymentStrictReceive, + pathPaymentStrictSend, + invokeHostFunction, + accountMergeEffects, + TARGET, + SENDER, + RECEIVER, + ISSUER, +} from "../__tests__/fixtures/horizon-payments" + +jest.mock("../utils", () => ({ + ...jest.requireActual("../utils"), + getHorizonServer: jest.fn(), +})) // packages/core/src/hooks/useTransactionHistory.ts import { useCallback, useReducer } from "react" @@ -33,6 +55,17 @@ function normalizeTransaction(record: TransactionRecord): NormalizedTransaction const mockGetHorizonServer = getHorizonServer as jest.Mock const mockCall = jest.fn() +const mockNext = jest.fn() +const mockPrev = jest.fn() +const mockEffectsCall = jest.fn() +const mockForOperation = jest.fn() + +const mockQuery = { + forAccount: jest.fn(), + limit: jest.fn(), + order: jest.fn(), + cursor: jest.fn(), + call: mockCall, interface PageData { transactions: NormalizedTransaction[] hasNext: boolean @@ -133,11 +166,17 @@ describe("usePayments", () => { mockQuery.limit.mockReturnValue(mockQuery) mockQuery.order.mockReturnValue(mockQuery) mockQuery.cursor.mockReturnValue(mockQuery) - mockGetHorizonServer.mockReturnValue({ payments: () => mockQuery }) + mockEffectsCall.mockResolvedValue({ records: accountMergeEffects }) + mockForOperation.mockReturnValue({ call: mockEffectsCall }) + mockGetHorizonServer.mockReturnValue({ + payments: () => mockQuery, + effects: () => ({ forOperation: mockForOperation }), + }) }) // ── Basic behaviour ──────────────────────────────────────────────────── + const { result } = renderHook(() => usePayments({ address: TARGET }), { wrapper }) it("handles empty state and returns empty array", async () => { mockCall.mockResolvedValueOnce(pageOf([])) @@ -225,6 +264,130 @@ export function useTransactionHistory({ enabled: Boolean(resolvedAddress), }) + it.each([ + { + name: "native payment", + record: nativePayment, + expected: { + id: nativePayment.id, + txHash: nativePayment.transaction_hash, + type: "payment", + from: SENDER, + to: TARGET, + amount: "10.5", + asset: "XLM", + direction: "incoming", + createdAt: nativePayment.created_at, + }, + }, + { + name: "create account", + record: createAccount, + expected: { + id: createAccount.id, + txHash: createAccount.transaction_hash, + type: "create_account", + from: SENDER, + to: TARGET, + amount: "1.5", + asset: "XLM", + direction: "incoming", + createdAt: createAccount.created_at, + }, + }, + { + name: "account merge", + record: accountMerge, + expected: { + id: accountMerge.id, + txHash: accountMerge.transaction_hash, + type: "account_merge", + from: TARGET, + to: RECEIVER, + amount: "25.5", + asset: "XLM", + direction: "outgoing", + createdAt: accountMerge.created_at, + }, + }, + { + name: "path payment strict receive", + record: pathPaymentStrictReceive, + expected: { + id: pathPaymentStrictReceive.id, + txHash: pathPaymentStrictReceive.transaction_hash, + type: "path_payment_strict_receive", + from: SENDER, + to: TARGET, + amount: "7.25", + asset: { code: "USDC", issuer: ISSUER }, + direction: "incoming", + createdAt: pathPaymentStrictReceive.created_at, + }, + }, + { + name: "path payment strict send", + record: pathPaymentStrictSend, + expected: { + id: pathPaymentStrictSend.id, + txHash: pathPaymentStrictSend.transaction_hash, + type: "path_payment_strict_send", + from: TARGET, + to: RECEIVER, + amount: "3.5", + asset: "XLM", + direction: "outgoing", + createdAt: pathPaymentStrictSend.created_at, + }, + }, + { + name: "invoke host function", + record: invokeHostFunction, + expected: { + id: invokeHostFunction.id, + txHash: invokeHostFunction.transaction_hash, + type: "invoke_host_function", + from: SENDER, + to: TARGET, + amount: "12.0", + asset: { code: "USDC", issuer: ISSUER }, + direction: "incoming", + createdAt: invokeHostFunction.created_at, + }, + }, + ])("normalizes $name", async ({ record, expected }) => { + mockCall.mockResolvedValueOnce({ records: [record] }) + + const { result } = renderHook(() => usePayments({ address: TARGET }), { wrapper }) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.payments).toEqual([expected]) + }) + + it("handles pagination via fetchNext and fetchPrev", async () => { + const page1 = { + records: [{ ...nativePayment, id: "200" }], + next: mockNext, + prev: mockPrev, + } + + const page2 = { + records: [{ ...nativePayment, id: "201" }], + next: mockNext, + prev: mockPrev, + } + + mockCall.mockResolvedValueOnce(page1) + mockNext.mockResolvedValueOnce(page2) + + const { result } = renderHook(() => usePayments({ address: TARGET, limit: 1 }), { wrapper }) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.payments[0].id).toBe("200") + expect(result.current.hasNext).toBe(true) + const fetchNext = useCallback(async () => { if (pageState.queryKey !== currentQueryKey || !pageState.next) return @@ -339,6 +502,7 @@ export function useTransactionHistory({ it("handles errors gracefully", async () => { mockCall.mockRejectedValueOnce(new Error("Network Error")) + const { result } = renderHook(() => usePayments({ address: TARGET }), { wrapper }) const { result } = renderHook(() => usePayments({ address: ADDRESS }), { wrapper }) await waitFor(() => expect(result.current.loading).toBe(false)) diff --git a/packages/core/src/hooks/usePayments.ts b/packages/core/src/hooks/usePayments.ts index 254d1a2..0680257 100644 --- a/packages/core/src/hooks/usePayments.ts +++ b/packages/core/src/hooks/usePayments.ts @@ -139,6 +139,14 @@ export function usePayments({ error: null, }) + // Store page navigation functions from the Horizon response + const nextRef = useRef<(() => Promise>) | null>( + null + ) + const prevRef = useRef<(() => Promise>) | null>( + null + ) + if (pageState.queryKey !== currentQueryKey) { dispatch({ type: "RESET", queryKey: currentQueryKey }) } @@ -207,6 +215,9 @@ export function usePayments({ if (cursor) query = query.cursor(cursor) const res = await query.call() + const normalized = ( + await Promise.all(res.records.map(rec => normalizePayment(rec, resolvedAddress!, server))) + ).flat() const hasNext = res.records.length > limit const records = hasNext ? res.records.slice(0, limit) : res.records const normalized = records.map(rec => normalizePayment(rec, resolvedAddress!)) @@ -235,26 +246,6 @@ export function usePayments({ hasPrev: !!cursor, }) - setHasNext(res.records.length >= limit) - setHasPrev(!!cursor) - } catch (err) { - if (cancelledRef.current || fetchId !== requestRef.current) return - setPayments([]) - // Stale-while-revalidate: a failed fetch keeps the last known-good - // payments in place and only surfaces the error. - setError(toStellarError(err)) - } finally { - if (!cancelledRef.current && fetchId === requestRef.current) { - setLoading(false) - } - } - }, [resolvedAddress, network, limit, order, cursor]) - - const fetchNext = useCallback(async () => { - if (!nextRef.current) return - const fetchId = ++requestRef.current - setLoading(true) - setError(null) return { payments: normalized, hasNext, @@ -271,6 +262,11 @@ export function usePayments({ dispatch({ type: "FETCH_START", queryKey: currentQueryKey }) try { + const server = getHorizonServer(networkConfig) + const res = await nextRef.current() + const normalized = ( + await Promise.all(res.records.map(rec => normalizePayment(rec, resolvedAddress!, server))) + ).flat() const res = await pageState.next() const requestAddress = resolvedAddress if (!requestAddress) return @@ -330,6 +326,7 @@ export function usePayments({ } finally { setPageLoading(false) } + }, [resolvedAddress, limit, networkConfig]) }, [pageState.queryKey, pageState.next, currentQueryKey, resolvedAddress, limit]) const fetchPrev = useCallback(async () => { @@ -343,13 +340,14 @@ export function usePayments({ const normalized = res.records.map((rec) => normalizePayment(rec, requestAddress)) if (!prevRef.current) return - const fetchId = ++requestRef.current - setLoading(true) - setError(null) setPageLoading(true) setPageError(null) try { + const server = getHorizonServer(networkConfig) const res = await prevRef.current() + const normalized = ( + await Promise.all(res.records.map(rec => normalizePayment(rec, resolvedAddress!, server))) + ).flat() // Request limit+1 for prev too so hasPrev is symmetrically accurate. const hasPrev = res.records.length > limit const records = hasPrev ? res.records.slice(0, limit) : res.records @@ -423,6 +421,11 @@ export function usePayments({ } finally { setPageLoading(false) } + }, [resolvedAddress, limit, networkConfig]) + + const payments = pagePayments ?? data?.payments ?? [] + const error = pageError ?? (rawError ? toStellarError(rawError) : null) + const loading = pageLoading || cacheLoading }, [pageState.queryKey, pageState.prev, currentQueryKey, resolvedAddress, limit]) const error = pageState.error ?? (rawError ? toStellarError(rawError) : null) @@ -431,11 +434,11 @@ export function usePayments({ const isStale = error !== null && payments.length > 0 return { + payments, payments: pageState.payments ?? data?.payments ?? [], loading, error, isStale, - refetch: fetchPayments, refetch, fetchNext, fetchPrev, @@ -445,12 +448,31 @@ export function usePayments({ } // ── Normalize Payment Operations ─────────────────────────────────────────── -function normalizePayment(record: PaymentRecord, address: string): NormalizedPayment { +async function normalizePayment( + record: PaymentRecord, + address: string, + server: Horizon.Server +): Promise { const type = record.type const id = record.id const txHash = record.transaction_hash const createdAt = record.created_at + if ( + type === "payment" || + type === "create_account" || + type === "path_payment_strict_receive" || + type === "path_payment_strict_send" + ) { + let from = "" + let to = "" + let amount = "0" + let asset: Asset = "XLM" + let direction: "incoming" | "outgoing" = "outgoing" + + if (type === "payment") { + from = record.from + to = record.to let from = "" let to = "" let amount = "0" @@ -488,6 +510,36 @@ function normalizePayment(record: PaymentRecord, address: string): NormalizedPay asset = record.asset_type === "native" ? "XLM" + : { code: record.asset_code!, issuer: record.asset_issuer! } + direction = to === address ? "incoming" : "outgoing" + } else if (type === "create_account") { + from = record.funder + to = record.account + amount = record.starting_balance + asset = "XLM" + direction = to === address ? "incoming" : "outgoing" + } else if (type === "path_payment_strict_receive" || type === "path_payment_strict_send") { + from = record.from + to = record.to + direction = to === address ? "incoming" : "outgoing" + + if (direction === "incoming") { + amount = record.amount + asset = + record.asset_type === "native" + ? "XLM" + : { code: record.asset_code!, issuer: record.asset_issuer! } + } else { + amount = record.source_amount || record.amount + const srcAssetType = record.source_asset_type || record.asset_type + asset = + srcAssetType === "native" + ? "XLM" + : { + code: record.source_asset_code || record.asset_code!, + issuer: record.source_asset_issuer || record.asset_issuer!, + } + } : { code: record.asset_code || "", issuer: record.asset_issuer || "" } } else { amount = record.source_amount || record.amount @@ -500,7 +552,58 @@ function normalizePayment(record: PaymentRecord, address: string): NormalizedPay issuer: record.source_asset_issuer || record.asset_issuer || "", } } + + return [{ id, txHash, type, from, to, amount, asset, direction, createdAt }] + } + + if (type === "account_merge") { + const effects = await server.effects().forOperation(record.id).call() + const mergeEffect = effects.records.find( + eff => + (eff.type === "account_debited" || eff.type === "account_credited") && + "account" in eff && + eff.account === address + ) + + if (!mergeEffect || !("amount" in mergeEffect)) return [] + + return [ + { + id, + txHash, + type, + from: record.account, + to: record.into, + amount: mergeEffect.amount, + asset: "XLM", + direction: record.into === address ? "incoming" : "outgoing", + createdAt, + }, + ] + } + + if (type === "invoke_host_function") { + const changes = record.asset_balance_changes ?? [] + + return changes + .filter(change => change.from === address || change.to === address) + .map(change => ({ + id, + txHash, + type, + from: change.from, + to: change.to, + amount: change.amount, + asset: + change.asset_type === "native" + ? "XLM" + : { code: change.asset_code!, issuer: change.asset_issuer! }, + direction: change.to === address ? "incoming" : "outgoing", + createdAt, + })) } + // Unhandled operation type: filter it out rather than fabricating a payment. + return [] return { id, txHash, type, from, to, amount, asset, direction, createdAt } } \ No newline at end of file