From 2b50392c45044b7f69f64e775652230bc2dcd436 Mon Sep 17 00:00:00 2001 From: Nathanpvd Date: Fri, 28 Aug 2026 12:52:07 +0100 Subject: [PATCH 1/4] fix: fix(hooks): normalizePayment renders Soroban transfers as bl (#226) --- packages/core/src/hooks/usePayments.ts | 163 +++++++++++++++++-------- 1 file changed, 113 insertions(+), 50 deletions(-) diff --git a/packages/core/src/hooks/usePayments.ts b/packages/core/src/hooks/usePayments.ts index 916ff67..87bee27 100644 --- a/packages/core/src/hooks/usePayments.ts +++ b/packages/core/src/hooks/usePayments.ts @@ -74,7 +74,9 @@ export function usePayments({ if (cursor) query = query.cursor(cursor) const res = await query.call() - const normalized = res.records.map(rec => normalizePayment(rec, resolvedAddress!)) + const normalized = ( + await Promise.all(res.records.map(rec => normalizePayment(rec, resolvedAddress!, server))) + ).flat() nextRef.current = res.records.length > 0 ? () => res.next() : null prevRef.current = res.records.length > 0 ? () => res.prev() : null @@ -104,8 +106,11 @@ export function usePayments({ setPageLoading(true) setPageError(null) try { + const server = getHorizonServer(networkConfig) const res = await nextRef.current() - const normalized = res.records.map(rec => normalizePayment(rec, resolvedAddress!)) + const normalized = ( + await Promise.all(res.records.map(rec => normalizePayment(rec, resolvedAddress!, server))) + ).flat() setPagePayments(normalized) nextRef.current = res.records.length > 0 ? () => res.next() : null @@ -119,15 +124,18 @@ export function usePayments({ } finally { setPageLoading(false) } - }, [resolvedAddress, limit]) + }, [resolvedAddress, limit, networkConfig]) const fetchPrev = useCallback(async () => { if (!prevRef.current) return setPageLoading(true) setPageError(null) try { + const server = getHorizonServer(networkConfig) const res = await prevRef.current() - const normalized = res.records.map(rec => normalizePayment(rec, resolvedAddress!)) + const normalized = ( + await Promise.all(res.records.map(rec => normalizePayment(rec, resolvedAddress!, server))) + ).flat() setPagePayments(normalized) nextRef.current = res.records.length > 0 ? () => res.next() : null @@ -141,7 +149,7 @@ export function usePayments({ } finally { setPageLoading(false) } - }, [resolvedAddress, limit]) + }, [resolvedAddress, limit, networkConfig]) const error = pageError ?? (rawError ? toStellarError(rawError) : null) const loading = pageLoading || cacheLoading @@ -159,62 +167,117 @@ 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 - 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 - amount = record.amount - 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 === "account_merge") { - from = record.source_account - to = record.into - amount = "0" - 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") { + 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 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!, - } + 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!, + } + } } + + 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, + })) } - return { id, txHash, type, from, to, amount, asset, direction, createdAt } + // Unhandled operation type: filter it out rather than fabricating a payment. + return [] } From 7b3290a8d1eb80671d96ae35b18fa00f6e555aa0 Mon Sep 17 00:00:00 2001 From: Nathanpvd Date: Fri, 28 Aug 2026 12:52:09 +0100 Subject: [PATCH 2/4] fix: fix(hooks): normalizePayment renders Soroban transfers as bl (#226) --- packages/core/src/hooks/usePayments.test.tsx | 240 +++++++++---------- 1 file changed, 114 insertions(+), 126 deletions(-) diff --git a/packages/core/src/hooks/usePayments.test.tsx b/packages/core/src/hooks/usePayments.test.tsx index b6bb2d1..fba8f61 100644 --- a/packages/core/src/hooks/usePayments.test.tsx +++ b/packages/core/src/hooks/usePayments.test.tsx @@ -2,6 +2,19 @@ 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"), @@ -15,6 +28,8 @@ 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(), @@ -29,21 +44,24 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( ) describe("usePayments", () => { - const address = "G_TARGET" - beforeEach(() => { jest.clearAllMocks() mockQuery.forAccount.mockReturnValue(mockQuery) 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 }), + }) }) it("handles empty state and returns empty array", async () => { mockCall.mockResolvedValueOnce({ records: [] }) - const { result } = renderHook(() => usePayments({ address }), { wrapper }) + const { result } = renderHook(() => usePayments({ address: TARGET }), { wrapper }) await waitFor(() => expect(result.current.loading).toBe(false)) @@ -53,145 +71,116 @@ describe("usePayments", () => { expect(result.current.hasPrev).toBe(false) }) - it("normalizes native XLM payment operations", async () => { - const rawRecords = [ - { - id: "100", + it.each([ + { + name: "native payment", + record: nativePayment, + expected: { + id: nativePayment.id, + txHash: nativePayment.transaction_hash, type: "payment", - transaction_hash: "tx_1", - created_at: "2026-06-25T18:00:00Z", - from: "G_SENDER", - to: address, + from: SENDER, + to: TARGET, amount: "10.5", - asset_type: "native", + asset: "XLM", + direction: "incoming", + createdAt: nativePayment.created_at, }, - ] - - mockCall.mockResolvedValueOnce({ records: rawRecords }) - - const { result } = renderHook(() => usePayments({ address }), { wrapper }) - - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.payments).toHaveLength(1) - expect(result.current.payments[0]).toEqual({ - id: "100", - txHash: "tx_1", - type: "payment", - from: "G_SENDER", - to: address, - amount: "10.5", - asset: "XLM", - direction: "incoming", - createdAt: "2026-06-25T18:00:00Z", - }) - }) - - it("normalizes issued asset payments correctly", async () => { - const rawRecords = [ - { - id: "101", - type: "payment", - transaction_hash: "tx_2", - created_at: "2026-06-25T18:01:00Z", - from: address, - to: "G_RECEIVER", - amount: "500.0", - asset_type: "credit_alphanum4", - asset_code: "USDC", - asset_issuer: "G_ISSUER", - }, - ] - - mockCall.mockResolvedValueOnce({ records: rawRecords }) - - const { result } = renderHook(() => usePayments({ address }), { wrapper }) - - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.payments).toHaveLength(1) - expect(result.current.payments[0]).toEqual({ - id: "101", - txHash: "tx_2", - type: "payment", - from: address, - to: "G_RECEIVER", - amount: "500.0", - asset: { code: "USDC", issuer: "G_ISSUER" }, - direction: "outgoing", - createdAt: "2026-06-25T18:01:00Z", - }) - }) - - it("handles create_account and account_merge operations as native payments", async () => { - const rawRecords = [ - { - id: "102", + }, + { + name: "create account", + record: createAccount, + expected: { + id: createAccount.id, + txHash: createAccount.transaction_hash, type: "create_account", - transaction_hash: "tx_3", - created_at: "2026-06-25T18:02:00Z", - funder: "G_SENDER", - account: address, - starting_balance: "1.5", + from: SENDER, + to: TARGET, + amount: "1.5", + asset: "XLM", + direction: "incoming", + createdAt: createAccount.created_at, }, - { - id: "103", + }, + { + name: "account merge", + record: accountMerge, + expected: { + id: accountMerge.id, + txHash: accountMerge.transaction_hash, type: "account_merge", - transaction_hash: "tx_4", - created_at: "2026-06-25T18:03:00Z", - account: address, - into: "G_RECEIVER", - amount: "2.5", + from: TARGET, + to: RECEIVER, + amount: "25.5", + asset: "XLM", + direction: "outgoing", + createdAt: accountMerge.created_at, }, - ] - - mockCall.mockResolvedValueOnce({ records: rawRecords }) + }, + { + 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 }), { wrapper }) + const { result } = renderHook(() => usePayments({ address: TARGET }), { wrapper }) await waitFor(() => expect(result.current.loading).toBe(false)) - expect(result.current.payments).toHaveLength(2) - expect(result.current.payments[0].type).toBe("create_account") - expect(result.current.payments[0].direction).toBe("incoming") - expect(result.current.payments[0].asset).toBe("XLM") - - expect(result.current.payments[1].type).toBe("account_merge") - expect(result.current.payments[1].direction).toBe("outgoing") - expect(result.current.payments[1].asset).toBe("XLM") + expect(result.current.payments).toEqual([expected]) }) it("handles pagination via fetchNext and fetchPrev", async () => { const page1 = { - records: [ - { - id: "200", - type: "payment", - transaction_hash: "tx_p1", - created_at: "2026-06-25T18:10:00Z", - from: "G_SENDER", - to: address, - amount: "1.0", - asset_type: "native", - }, - ], + records: [{ ...nativePayment, id: "200" }], next: mockNext, prev: mockPrev, } const page2 = { - records: [ - { - id: "201", - type: "payment", - transaction_hash: "tx_p2", - created_at: "2026-06-25T18:11:00Z", - from: "G_SENDER", - to: address, - amount: "2.0", - asset_type: "native", - }, - ], + records: [{ ...nativePayment, id: "201" }], next: mockNext, prev: mockPrev, } @@ -199,14 +188,13 @@ describe("usePayments", () => { mockCall.mockResolvedValueOnce(page1) mockNext.mockResolvedValueOnce(page2) - const { result } = renderHook(() => usePayments({ address, limit: 1 }), { wrapper }) + 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) - // Fetch next page await act(async () => { await result.current.fetchNext() }) @@ -218,7 +206,7 @@ describe("usePayments", () => { it("handles errors gracefully", async () => { mockCall.mockRejectedValueOnce(new Error("Network Error")) - const { result } = renderHook(() => usePayments({ address }), { wrapper }) + const { result } = renderHook(() => usePayments({ address: TARGET }), { wrapper }) await waitFor(() => expect(result.current.loading).toBe(false)) From ee6d4981710aac263a770138a897a5b16da8cb77 Mon Sep 17 00:00:00 2001 From: Nathanpvd Date: Fri, 28 Aug 2026 12:52:10 +0100 Subject: [PATCH 3/4] fix: fix(hooks): normalizePayment renders Soroban transfers as bl (#226) --- .../__tests__/fixtures/horizon-payments.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/core/src/__tests__/fixtures/horizon-payments.ts 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, + }, + ], +} From 1e9e1f17724406c097f1182b3b4e31fb000d7017 Mon Sep 17 00:00:00 2001 From: Nathanpvd Date: Fri, 28 Aug 2026 12:52:12 +0100 Subject: [PATCH 4/4] fix: fix(hooks): normalizePayment renders Soroban transfers as bl (#226) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e51d2d..9b661e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,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. - Wallet network mismatch detection now compares provider intent with the network reported by every adapter.