From 01d87b5ae1b3254b2b8c226165e256eb54ad61c7 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Sat, 25 Jul 2026 08:09:41 +0100 Subject: [PATCH 1/5] Fix duplicate InsuranceContractError merge collision PR #508 (issues 475-478) added a local InsuranceContractError class directly in insurance.ts. PR #510 (issues 459-465, merged after) added a second, separately-written InsuranceContractError to errors.ts and imported it into insurance.ts. Both PRs' diffs were additive (new import lines, new class blocks) so they merged into main without a git conflict marker, but the result doesn't compile: duplicate identifier errors in index.ts and insurance.ts, plus literal duplicate import statements in insurance.test.ts (InsuranceContractError, and separately SorobanRpc/ Keypair/Address) merged in from both PRs' test-file edits. Keeps insurance.ts's original local class (the one actually used at runtime by its own simulateCall/submitCall) and removes the duplicate from errors.ts, the now-dangling import in insurance.ts, and the duplicate import lines in insurance.test.ts. `npx tsc --noEmit` in sdk/ now shows zero errors beyond the one pre-existing unrelated xdrDecoder.ts issue; full vitest suite matches the known baseline (11 pre-existing unrelated failures in getTokenDecimals.test.ts/nft.test.ts). --- sdk/src/errors.ts | 47 ------------------------------- sdk/src/index.ts | 2 +- sdk/src/methods/insurance.test.ts | 2 -- sdk/src/methods/insurance.ts | 1 - 4 files changed, 1 insertion(+), 51 deletions(-) diff --git a/sdk/src/errors.ts b/sdk/src/errors.ts index 4dc6b436..cfe32e91 100644 --- a/sdk/src/errors.ts +++ b/sdk/src/errors.ts @@ -176,50 +176,3 @@ ILNError.InvoiceNotCancellable = InvoiceNotCancellable; ILNError.InvalidAddress = InvalidAddress; ILNError.InvalidTransfer = InvalidTransfer; ILNError.InsufficientAmount = InsufficientAmount; - -/** - * insurance_pool has its own `InsuranceError` enum - * (contracts/insurance_pool/src/lib.rs) whose numeric codes are NOT the same - * as invoice_liquidity's `ContractError` codes above (e.g. code 2 is - * AlreadyClaimed here, but AlreadyFunded there). Issue #461 asked to add - * insurance error codes to `ILNErrorCode` (sdk/src/signers/FreighterSigner.ts) - * — that enum is unrelated (wallet-connection UX codes like WalletNotInstalled/ - * UserRejected), so adding contract error codes there would be incorrect. - * This class follows the same dedicated-mapper pattern used for - * GovernanceContractError (sdk/src/methods/governance.ts). - */ -export class InsuranceContractError extends Error { - constructor(message: string, public readonly code?: number) { - super(message); - this.name = "InsuranceContractError"; - } - - static NotInitialized = class NotInitialized extends InsuranceContractError { - constructor(msg = "Insurance pool not initialized") { super(msg, 1); } - }; - static AlreadyClaimed = class AlreadyClaimed extends InsuranceContractError { - constructor(msg = "Claim already processed for this invoice") { super(msg, 2); } - }; - static InvalidAmount = class InvalidAmount extends InsuranceContractError { - constructor(msg = "Premium or coverage amount must be positive") { super(msg, 3); } - }; - static PoolEmpty = class PoolEmpty extends InsuranceContractError { - constructor(msg = "Insurance pool has no balance available") { super(msg, 4); } - }; - static AlreadyInitialized = class AlreadyInitialized extends InsuranceContractError { - constructor(msg = "Insurance pool already initialized") { super(msg, 5); } - }; - - static fromError(error: unknown): Error { - const match = String(error).match(/Error\(Contract, (\d+)\)/); - if (!match) return error instanceof Error ? error : new Error(String(error)); - switch (parseInt(match[1] || "", 10)) { - case 1: return new InsuranceContractError.NotInitialized(); - case 2: return new InsuranceContractError.AlreadyClaimed(); - case 3: return new InsuranceContractError.InvalidAmount(); - case 4: return new InsuranceContractError.PoolEmpty(); - case 5: return new InsuranceContractError.AlreadyInitialized(); - default: return new InsuranceContractError(`insurance_pool error: ${String(error)}`); - } - } -} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 1a00fbc9..a90f393f 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -76,7 +76,7 @@ export type { CreateProposalResult, } from "./types/governance.js"; -export { ILNError, InsuranceContractError } from "./errors.js"; +export { ILNError } from "./errors.js"; export { disputeInvoice, sha256Hex } from "./methods/disputeInvoice.js"; export type { DisputeInvoiceParams, DisputeInvoiceResult } from "./methods/disputeInvoice.js"; export { resolveDispute, DisputeRuling } from "./methods/resolveDispute.js"; diff --git a/sdk/src/methods/insurance.test.ts b/sdk/src/methods/insurance.test.ts index 5786be71..fd2d3a28 100644 --- a/sdk/src/methods/insurance.test.ts +++ b/sdk/src/methods/insurance.test.ts @@ -10,8 +10,6 @@ import { claimInsurance, InsuranceContractError, } from "./insurance.js"; -import { InsuranceContractError } from "../errors.js"; -import { SorobanRpc, Keypair, Address } from "@stellar/stellar-sdk"; import { SorobanRpc, Keypair, Address, Account } from "@stellar/stellar-sdk"; // --------------------------------------------------------------------------- diff --git a/sdk/src/methods/insurance.ts b/sdk/src/methods/insurance.ts index 9bed16af..48cf41b1 100644 --- a/sdk/src/methods/insurance.ts +++ b/sdk/src/methods/insurance.ts @@ -17,7 +17,6 @@ import { } from "@stellar/stellar-sdk"; import { retry } from "../utils/retry.js"; import { validateGAddress, validateContractId } from "../utils/validate.js"; -import { InsuranceContractError } from "../errors.js"; import type { InsurancePoolInfo } from "@invoice-liquidity/types"; /** From ca2dbd258c05f284f1444a583a4172e99a56ab3d Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Sat, 25 Jul 2026 08:15:29 +0100 Subject: [PATCH 2/5] Add SDK pause and unpause methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (closes #470) pause(client)/unpause(client) wrap the contract's pause()/unpause() emergency admin controls. Both require the contract admin's signature — client.signer must be the stored admin — and map simulation errors (e.g. Unauthorized) through the existing ILNError.fromError. Adds tests for both, including the no-signer guard and error mapping. --- sdk/src/methods/adminControls.test.ts | 85 +++++++++++++++++++++++++++ sdk/src/methods/adminControls.ts | 70 ++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 sdk/src/methods/adminControls.test.ts create mode 100644 sdk/src/methods/adminControls.ts diff --git a/sdk/src/methods/adminControls.test.ts b/sdk/src/methods/adminControls.test.ts new file mode 100644 index 00000000..26254a2b --- /dev/null +++ b/sdk/src/methods/adminControls.test.ts @@ -0,0 +1,85 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { ILNError } from "../errors.js"; +import { ILNClient } from "../client.js"; +import { Account } from "@stellar/stellar-sdk"; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + SorobanRpc: { ...actual.SorobanRpc, assembleTransaction: vi.fn(() => ({ build: () => ({}) })) }, + }; +}); + +import { pause, unpause } from "./adminControls.js"; + +const ADMIN = "GBR7RT4MZTLKK2JNZPOSWVY74VFDR4HVR24QZNH2WONHPQFJZPKHWOTP"; +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const PASS = "Test SDF Network ; September 2015"; + +function makeClient(withSigner = true) { + return ILNClient.custom({ + rpcUrl: "https://fake-rpc.example.org", + networkPassphrase: PASS, + contractId: CONTRACT, + ...(withSigner ? { signer: { publicKey: ADMIN, signTransaction: vi.fn().mockResolvedValue("signed-xdr") } } : {}), + }); +} + +function mockRpc(overrides: Record = {}) { + return { + getAccount: vi.fn().mockResolvedValue(new Account(ADMIN, "1")), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "txPAUSE" }), + ...overrides, + }; +} + +describe("pause", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits and returns txHash", async () => { + const client = makeClient(true); + Object.assign(client, { rpc: mockRpc() }); + + const res = await pause(client); + expect(res.txHash).toBe("txPAUSE"); + }); + + it("throws ILNError.Unauthorized when caller is not admin", async () => { + const client = makeClient(true); + Object.assign(client, { + rpc: mockRpc({ + simulateTransaction: vi.fn().mockResolvedValue({ error: "Error(Contract, 5)", _parsed: true }), + }), + }); + + await expect(pause(client)).rejects.toBeInstanceOf(ILNError.Unauthorized); + }); + + it("throws when no signer is configured", async () => { + const client = makeClient(false); + await expect(pause(client)).rejects.toThrow("pause requires a client configured with a signer"); + }); +}); + +describe("unpause", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits and returns txHash", async () => { + const client = makeClient(true); + Object.assign(client, { rpc: mockRpc() }); + + const res = await unpause(client); + expect(res.txHash).toBe("txPAUSE"); + }); + + it("throws when no signer is configured", async () => { + const client = makeClient(false); + await expect(unpause(client)).rejects.toThrow("unpause requires a client configured with a signer"); + }); +}); diff --git a/sdk/src/methods/adminControls.ts b/sdk/src/methods/adminControls.ts new file mode 100644 index 00000000..899475f6 --- /dev/null +++ b/sdk/src/methods/adminControls.ts @@ -0,0 +1,70 @@ +// @ts-nocheck +/** + * pause / unpause — SDK helpers for the contract's emergency admin + * controls (issue #470). + */ +import { Contract, SorobanRpc, TransactionBuilder, BASE_FEE } from "@stellar/stellar-sdk"; +import type { ILNClient } from "../client.js"; +import { ILNError } from "../errors.js"; +import { retry } from "../utils/retry.js"; + +export interface PauseResult { + txHash: string; +} + +async function callAdminOp(client: ILNClient, methodName: string): Promise { + if (!client.signer) { + throw new Error(`${methodName} requires a client configured with a signer (the contract admin)`); + } + + const contract = new Contract(client.contractId); + const op = contract.call(methodName); + + const account = await retry(() => client.rpc.getAccount(client.signer!.publicKey)); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: client.networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => client.rpc.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await client.signer.signTransaction(assembledTx, client.rpc); + const sendResult = await retry(() => client.rpc.sendTransaction(signed)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + return { txHash: sendResult.hash }; +} + +/** + * Pause the contract, blocking all state-mutating calls (submissions, + * funding, payments, etc.) until {@link unpause} is called. + * + * Wraps `pause()`. Requires the contract admin's signature — + * `client.signer` must be the stored admin. + * + * @throws {ILNError.Unauthorized} If `client.signer` is not the contract admin + */ +export async function pause(client: ILNClient): Promise { + return callAdminOp(client, "pause"); +} + +/** + * Unpause the contract, resuming normal operation. + * + * Wraps `unpause()`. Requires the contract admin's signature — + * `client.signer` must be the stored admin. + * + * @throws {ILNError.Unauthorized} If `client.signer` is not the contract admin + */ +export async function unpause(client: ILNClient): Promise { + return callAdminOp(client, "unpause"); +} From 9c5176fdc347d63645e07c7122ec50faa950a376 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Sat, 25 Jul 2026 08:15:40 +0100 Subject: [PATCH 3/5] Add SDK transferInvoice method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (closes #469) transferInvoice(client, invoiceId, newFreelancer) wraps transfer_invoice(invoice_id, new_freelancer), requiring the invoice's *current* freelancer's signature. Validates the invoice is currently Pending client-side (via getInvoice) before submitting — the contract enforces this too and updates the submitter index on both the old and new freelancer. Adds tests covering the happy path, the Pending-status validation, and the no-signer guard. --- sdk/src/methods/transferInvoice.test.ts | 86 +++++++++++++++++++++++++ sdk/src/methods/transferInvoice.ts | 76 ++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 sdk/src/methods/transferInvoice.test.ts create mode 100644 sdk/src/methods/transferInvoice.ts diff --git a/sdk/src/methods/transferInvoice.test.ts b/sdk/src/methods/transferInvoice.test.ts new file mode 100644 index 00000000..741ba058 --- /dev/null +++ b/sdk/src/methods/transferInvoice.test.ts @@ -0,0 +1,86 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { ILNClient } from "../client.js"; +import { Account } from "@stellar/stellar-sdk"; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + scValToNative: vi.fn(), + SorobanRpc: { ...actual.SorobanRpc, assembleTransaction: vi.fn(() => ({ build: () => ({}) })) }, + }; +}); + +import { scValToNative } from "@stellar/stellar-sdk"; +import { transferInvoice } from "./transferInvoice.js"; + +const mockScValToNative = scValToNative as unknown as vi.Mock; + +const FREELANCER = "GBR7RT4MZTLKK2JNZPOSWVY74VFDR4HVR24QZNH2WONHPQFJZPKHWOTP"; +const NEW_FREELANCER = "GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37"; +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const PASS = "Test SDF Network ; September 2015"; + +function rawInvoice(status: string) { + return { + id: "42", + freelancer: FREELANCER, + payer: FREELANCER, + token: CONTRACT, + amount: "1000", + due_date: "1700000000", + discount_rate: "300", + status: { tag: status }, + amount_funded: "0", + amount_paid: "0", + submitter_reputation: "50", + }; +} + +function makeClient(withSigner = true) { + return ILNClient.custom({ + rpcUrl: "https://fake-rpc.example.org", + networkPassphrase: PASS, + contractId: CONTRACT, + ...(withSigner ? { signer: { publicKey: FREELANCER, signTransaction: vi.fn().mockResolvedValue("signed-xdr") } } : {}), + }); +} + +function mockRpc(overrides: Record = {}) { + return { + getAccount: vi.fn().mockResolvedValue(new Account(FREELANCER, "1")), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "txTRANSFER" }), + ...overrides, + }; +} + +describe("transferInvoice", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits a transfer for a Pending invoice", async () => { + mockScValToNative.mockReturnValue(rawInvoice("Pending")); + const client = makeClient(true); + Object.assign(client, { rpc: mockRpc() }); + + const res = await transferInvoice(client, 42n, NEW_FREELANCER); + expect(res.txHash).toBe("txTRANSFER"); + }); + + it("throws when the invoice is not Pending", async () => { + mockScValToNative.mockReturnValue(rawInvoice("Funded")); + const client = makeClient(true); + Object.assign(client, { rpc: mockRpc() }); + + await expect(transferInvoice(client, 42n, NEW_FREELANCER)).rejects.toThrow("not Pending"); + }); + + it("throws when no signer is configured", async () => { + const client = makeClient(false); + await expect(transferInvoice(client, 42n, NEW_FREELANCER)).rejects.toThrow( + "transferInvoice requires a client configured with a signer" + ); + }); +}); diff --git a/sdk/src/methods/transferInvoice.ts b/sdk/src/methods/transferInvoice.ts new file mode 100644 index 00000000..a2df4aff --- /dev/null +++ b/sdk/src/methods/transferInvoice.ts @@ -0,0 +1,76 @@ +// @ts-nocheck +/** + * transferInvoice — SDK helper for reassigning a pending invoice to a new + * freelancer (issue #469). + */ +import { Contract, SorobanRpc, TransactionBuilder, BASE_FEE, nativeToScVal } from "@stellar/stellar-sdk"; +import type { ILNClient } from "../client.js"; +import { ILNError } from "../errors.js"; +import { retry } from "../utils/retry.js"; + +export interface TransferInvoiceResult { + txHash: string; +} + +/** + * Transfer an invoice's ownership to a new freelancer. + * + * Wraps `transfer_invoice(invoice_id, new_freelancer)`. Requires the + * *current* freelancer's signature — `client.signer` must be the + * invoice's existing submitter. Validates the invoice is currently + * `Pending` client-side before submitting (the contract enforces this + * too, and updates the submitter index on both sides of the transfer). + * + * @param client Configured {@link ILNClient} — `client.signer` must be the invoice's current freelancer + * @param invoiceId Invoice to transfer (must currently be `Pending`) + * @param newFreelancer The new freelancer's Stellar address + * @throws {ILNError.InvoiceNotFound} If the invoice id is unknown + * @throws {ILNError.AlreadyFunded} If the invoice has already been (partially) funded + * @throws {ILNError.AlreadyPaid} If the invoice has already been paid + */ +export async function transferInvoice( + client: ILNClient, + invoiceId: bigint, + newFreelancer: string +): Promise { + if (!client.signer) { + throw new Error("transferInvoice requires a client configured with a signer (the invoice's current freelancer)"); + } + + const { getInvoice } = await import("./queries.js"); + const account = await retry(() => client.rpc.getAccount(client.signer!.publicKey)); + + const invoice = await getInvoice(client.rpc, client.contractId, invoiceId, account, client.networkPassphrase); + if (invoice.status !== "Pending") { + throw new Error(`Invoice ${invoiceId} is ${invoice.status}, not Pending — cannot be transferred`); + } + + const contract = new Contract(client.contractId); + const op = contract.call( + "transfer_invoice", + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(newFreelancer, { type: "address" }) + ); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: client.networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => client.rpc.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await client.signer.signTransaction(assembledTx, client.rpc); + const sendResult = await retry(() => client.rpc.sendTransaction(signed)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + return { txHash: sendResult.hash }; +} From 1a74e3c9495317db9908356c000146c1eaa1d62b Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Sat, 25 Jul 2026 08:15:51 +0100 Subject: [PATCH 4/5] Add SDK convertInvoiceToken method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (closes #468) convertInvoiceToken(client, freelancerAddress, invoiceId, newToken) wraps convert_invoice_token(freelancer, invoice_id, new_token), requiring the freelancer's signature. Validates the invoice is currently Pending client-side before submitting. Note: token-approval is enforced entirely by the contract — there's no public query to check the allowlist client-side, so an unapproved token surfaces as ILNError.Unauthorized from the simulation rather than a pre-flight check, documented in the JSDoc. Adds tests covering the happy path, Pending-status validation, and error-code mapping for an unapproved token. --- sdk/src/methods/convertInvoiceToken.test.ts | 101 ++++++++++++++++++++ sdk/src/methods/convertInvoiceToken.ts | 85 ++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 sdk/src/methods/convertInvoiceToken.test.ts create mode 100644 sdk/src/methods/convertInvoiceToken.ts diff --git a/sdk/src/methods/convertInvoiceToken.test.ts b/sdk/src/methods/convertInvoiceToken.test.ts new file mode 100644 index 00000000..7d1fc4e5 --- /dev/null +++ b/sdk/src/methods/convertInvoiceToken.test.ts @@ -0,0 +1,101 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { ILNError } from "../errors.js"; +import { ILNClient } from "../client.js"; +import { Account } from "@stellar/stellar-sdk"; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + scValToNative: vi.fn(), + SorobanRpc: { ...actual.SorobanRpc, assembleTransaction: vi.fn(() => ({ build: () => ({}) })) }, + }; +}); + +import { scValToNative } from "@stellar/stellar-sdk"; +import { convertInvoiceToken } from "./convertInvoiceToken.js"; + +const mockScValToNative = scValToNative as unknown as vi.Mock; + +const FREELANCER = "GBR7RT4MZTLKK2JNZPOSWVY74VFDR4HVR24QZNH2WONHPQFJZPKHWOTP"; +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const NEW_TOKEN = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; +const PASS = "Test SDF Network ; September 2015"; + +function rawInvoice(status: string) { + return { + id: "42", + freelancer: FREELANCER, + payer: FREELANCER, + token: CONTRACT, + amount: "1000", + due_date: "1700000000", + discount_rate: "300", + status: { tag: status }, + amount_funded: "0", + amount_paid: "0", + submitter_reputation: "50", + }; +} + +function makeClient(withSigner = true) { + return ILNClient.custom({ + rpcUrl: "https://fake-rpc.example.org", + networkPassphrase: PASS, + contractId: CONTRACT, + ...(withSigner ? { signer: { publicKey: FREELANCER, signTransaction: vi.fn().mockResolvedValue("signed-xdr") } } : {}), + }); +} + +function mockRpc(overrides: Record = {}) { + return { + getAccount: vi.fn().mockResolvedValue(new Account(FREELANCER, "1")), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "txCONVERT" }), + ...overrides, + }; +} + +describe("convertInvoiceToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits a token change for a Pending invoice", async () => { + mockScValToNative.mockReturnValue(rawInvoice("Pending")); + const client = makeClient(true); + Object.assign(client, { rpc: mockRpc() }); + + const res = await convertInvoiceToken(client, FREELANCER, 42n, NEW_TOKEN); + expect(res.txHash).toBe("txCONVERT"); + }); + + it("throws when the invoice is not Pending", async () => { + mockScValToNative.mockReturnValue(rawInvoice("Funded")); + const client = makeClient(true); + Object.assign(client, { rpc: mockRpc() }); + + await expect(convertInvoiceToken(client, FREELANCER, 42n, NEW_TOKEN)).rejects.toThrow("not Pending"); + }); + + it("maps an unapproved-token simulation error through ILNError.fromError", async () => { + mockScValToNative.mockReturnValueOnce(rawInvoice("Pending")); + const client = makeClient(true); + Object.assign(client, { + rpc: mockRpc({ + simulateTransaction: vi.fn().mockResolvedValue({ error: "Error(Contract, 5)", _parsed: true }), + }), + }); + + await expect(convertInvoiceToken(client, FREELANCER, 42n, NEW_TOKEN)).rejects.toBeInstanceOf( + ILNError.Unauthorized + ); + }); + + it("throws when no signer is configured", async () => { + const client = makeClient(false); + await expect(convertInvoiceToken(client, FREELANCER, 42n, NEW_TOKEN)).rejects.toThrow( + "convertInvoiceToken requires a client configured with a signer" + ); + }); +}); diff --git a/sdk/src/methods/convertInvoiceToken.ts b/sdk/src/methods/convertInvoiceToken.ts new file mode 100644 index 00000000..7de5badb --- /dev/null +++ b/sdk/src/methods/convertInvoiceToken.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +/** + * convertInvoiceToken — SDK helper for changing a pending invoice's + * settlement token (issue #468). + */ +import { Contract, SorobanRpc, TransactionBuilder, BASE_FEE, nativeToScVal } from "@stellar/stellar-sdk"; +import type { ILNClient } from "../client.js"; +import { ILNError } from "../errors.js"; +import { retry } from "../utils/retry.js"; + +export interface ConvertInvoiceTokenResult { + txHash: string; +} + +/** + * Change the settlement token for a pending invoice. + * + * Wraps `convert_invoice_token(freelancer, invoice_id, new_token)`. + * Requires the freelancer's signature — `client.signer` must be + * `freelancerAddress`. Validates the invoice is currently `Pending` + * client-side before submitting. + * + * Token-approval is enforced by the contract (there is no public query + * to check the allowlist client-side), so an unapproved `newToken` + * surfaces as `ILNError.Unauthorized` from the simulation rather than a + * pre-flight check here. + * + * @param client Configured {@link ILNClient} — `client.signer` must be `freelancerAddress` + * @param freelancerAddress The invoice's freelancer (must match `client.signer`) + * @param invoiceId Invoice to update (must currently be `Pending`) + * @param newToken The new settlement token's contract address (must be an approved token) + * @throws {ILNError.InvoiceNotFound} If the invoice id is unknown + * @throws {ILNError.AlreadyFunded} If the invoice has already been (partially) funded + * @throws {ILNError.AlreadyPaid} If the invoice has already been paid + * @throws {ILNError.InvoiceExpired} If the invoice's due date has passed + * @throws {ILNError.Unauthorized} If `newToken` is not an approved token, or the caller isn't the freelancer + */ +export async function convertInvoiceToken( + client: ILNClient, + freelancerAddress: string, + invoiceId: bigint, + newToken: string +): Promise { + if (!client.signer) { + throw new Error("convertInvoiceToken requires a client configured with a signer (the invoice's freelancer)"); + } + + const { getInvoice } = await import("./queries.js"); + const account = await retry(() => client.rpc.getAccount(client.signer!.publicKey)); + + const invoice = await getInvoice(client.rpc, client.contractId, invoiceId, account, client.networkPassphrase); + if (invoice.status !== "Pending") { + throw new Error(`Invoice ${invoiceId} is ${invoice.status}, not Pending — token cannot be changed`); + } + + const contract = new Contract(client.contractId); + const op = contract.call( + "convert_invoice_token", + nativeToScVal(freelancerAddress, { type: "address" }), + nativeToScVal(invoiceId, { type: "u64" }), + nativeToScVal(newToken, { type: "address" }) + ); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: client.networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => client.rpc.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await client.signer.signTransaction(assembledTx, client.rpc); + const sendResult = await retry(() => client.rpc.sendTransaction(signed)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + return { txHash: sendResult.hash }; +} From 60541265855bec1f502874bc371152dd08d618f3 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Sat, 25 Jul 2026 08:16:04 +0100 Subject: [PATCH 5/5] Enhance disputeInvoice with error mapping and pre-flight validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (closes #464) disputeInvoice previously relied on rpc.prepareTransaction's implicit simulation, which throws raw (untyped) errors — no ILNError mapping at all. Rewrote the write path to explicitly simulate, check isSimulationError, and throw through ILNError.fromError, so callers get typed errors (AlreadyDisputed, InvoiceDefaulted, InvoiceExpired, etc.) instead of opaque RPC exceptions. Added a pre-flight check: fetches the invoice via getInvoice and validates it's in a disputable status (Pending/PartiallyFunded/Funded, mirroring the contract's own match arm) before submitting. Retry-with-backoff was already present via the existing retry() utility on getAccount/prepareTransaction/sendTransaction — extended it to the new simulateTransaction/getNetwork calls too, and expanded the JSDoc with the full set of @throws now that they're typed. Adds a test file for this method (previously had none) covering the happy path, the pre-flight status check, and error-code mapping. --- sdk/src/index.ts | 6 ++ sdk/src/methods/disputeInvoice.test.ts | 116 +++++++++++++++++++++++++ sdk/src/methods/disputeInvoice.ts | 63 +++++++++++--- 3 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 sdk/src/methods/disputeInvoice.test.ts diff --git a/sdk/src/index.ts b/sdk/src/index.ts index a90f393f..ac53bafd 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -113,6 +113,12 @@ export { submitInvoicesBatch } from "./methods/submitInvoicesBatch.js"; export type { BatchInvoiceItem, SubmitInvoicesBatchResult } from "./methods/submitInvoicesBatch.js"; export { joinFundQueue, resolveFundQueue } from "./methods/fundQueue.js"; export type { JoinFundQueueResult, ResolveFundQueueResult } from "./methods/fundQueue.js"; +export { pause, unpause } from "./methods/adminControls.js"; +export type { PauseResult } from "./methods/adminControls.js"; +export { transferInvoice } from "./methods/transferInvoice.js"; +export type { TransferInvoiceResult } from "./methods/transferInvoice.js"; +export { convertInvoiceToken } from "./methods/convertInvoiceToken.js"; +export type { ConvertInvoiceTokenResult } from "./methods/convertInvoiceToken.js"; export { TokenRegistry, tokenRegistry } from "./utils/tokenRegistry.js"; export type { TokenInfo, NetworkName } from "./utils/tokenRegistry.js"; export { diff --git a/sdk/src/methods/disputeInvoice.test.ts b/sdk/src/methods/disputeInvoice.test.ts new file mode 100644 index 00000000..2a018c56 --- /dev/null +++ b/sdk/src/methods/disputeInvoice.test.ts @@ -0,0 +1,116 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { ILNError } from "../errors.js"; +import { Account } from "@stellar/stellar-sdk"; + +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + scValToNative: vi.fn(), + SorobanRpc: { ...actual.SorobanRpc, assembleTransaction: vi.fn(() => ({ build: () => ({}) })) }, + }; +}); + +import { scValToNative } from "@stellar/stellar-sdk"; +import { disputeInvoice, sha256Hex } from "./disputeInvoice.js"; + +const mockScValToNative = scValToNative as unknown as vi.Mock; + +const PAYER = "GBR7RT4MZTLKK2JNZPOSWVY74VFDR4HVR24QZNH2WONHPQFJZPKHWOTP"; +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const PASS = "Test SDF Network ; September 2015"; + +function rawInvoice(status: string) { + return { + id: "42", + freelancer: PAYER, + payer: PAYER, + token: CONTRACT, + amount: "1000", + due_date: "1700000000", + discount_rate: "300", + status: { tag: status }, + amount_funded: "0", + amount_paid: "0", + submitter_reputation: "50", + }; +} + +function mockSigner() { + return { + publicKey: PAYER, + signTransaction: vi.fn().mockResolvedValue("signed-xdr"), + }; +} + +function mockRpc(overrides: Record = {}) { + return { + getAccount: vi.fn().mockResolvedValue(new Account(PAYER, "1")), + getNetwork: vi.fn().mockResolvedValue({ passphrase: PASS }), + simulateTransaction: vi.fn().mockResolvedValue({ result: { retval: {} } }), + sendTransaction: vi.fn().mockResolvedValue({ status: "PENDING", hash: "txDISPUTE" }), + ...overrides, + }; +} + +describe("disputeInvoice", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits a dispute for a Funded invoice and returns the evidence hash", async () => { + mockScValToNative.mockReturnValue(rawInvoice("Funded")); + const rpc = mockRpc(); + + const result = await disputeInvoice({ + rpc: rpc as any, + contractAddress: CONTRACT, + signer: mockSigner(), + invoiceId: 42n, + evidence: "Payment already settled", + }); + + expect(result.txHash).toBe("txDISPUTE"); + expect(result.evidenceHash).toBe(await sha256Hex("Payment already settled")); + }); + + it("throws when the invoice is not in a disputable status", async () => { + mockScValToNative.mockReturnValue(rawInvoice("Paid")); + const rpc = mockRpc(); + + await expect( + disputeInvoice({ + rpc: rpc as any, + contractAddress: CONTRACT, + signer: mockSigner(), + invoiceId: 42n, + evidence: "too late", + }) + ).rejects.toThrow("cannot be disputed"); + }); + + it("maps simulation errors through ILNError.fromError", async () => { + mockScValToNative.mockReturnValueOnce(rawInvoice("Funded")); + const rpc = mockRpc({ + simulateTransaction: vi.fn().mockResolvedValue({ error: "Error(Contract, 23)", _parsed: true }), + }); + + await expect( + disputeInvoice({ + rpc: rpc as any, + contractAddress: CONTRACT, + signer: mockSigner(), + invoiceId: 42n, + evidence: "duplicate", + }) + ).rejects.toBeInstanceOf(ILNError.AlreadyDisputed); + }); +}); + +describe("sha256Hex", () => { + it("returns a stable 64-char lower-case hex digest", async () => { + const hash = await sha256Hex("hello world"); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(await sha256Hex("hello world")).toBe(hash); + }); +}); diff --git a/sdk/src/methods/disputeInvoice.ts b/sdk/src/methods/disputeInvoice.ts index 8e785061..d9140ace 100644 --- a/sdk/src/methods/disputeInvoice.ts +++ b/sdk/src/methods/disputeInvoice.ts @@ -1,6 +1,7 @@ // @ts-nocheck /** - * disputeInvoice — SDK helper for disputing an invoice (issue #225). + * disputeInvoice — SDK helper for disputing an invoice (issue #225, error + * handling and pre-flight validation added for issue #464). * * Hashes the caller-supplied evidence string with SHA-256 (via the * Stellar SDK's built-in Buffer / crypto utilities) and forwards the @@ -8,6 +9,7 @@ */ import { Contract, SorobanRpc, xdr } from "@stellar/stellar-sdk"; import type { ISigner as Signer } from "../signers/ISigner.js"; +import { ILNError } from "../errors.js"; import { retry } from "../utils/retry.js"; export interface DisputeInvoiceParams { @@ -55,10 +57,29 @@ export async function sha256Hex(text: string): Promise { .join(""); } +/** Invoice statuses `dispute_invoice` accepts (mirrors the contract's own match arm). */ +const DISPUTABLE_STATUSES = new Set(["Pending", "PartiallyFunded", "Funded"]); + /** * Dispute an invoice by submitting a SHA-256 hash of the caller's evidence * to the `dispute_invoice` contract entry point. * + * Validates the invoice is currently in a disputable status (`Pending`, + * `PartiallyFunded`, or `Funded`) before submitting, and maps all + * simulation errors through {@link ILNError.fromError} so callers get + * typed errors (e.g. `ILNError.AlreadyDisputed`) instead of raw RPC + * exceptions. All RPC calls retry transient network failures with + * exponential back-off via {@link retry}. + * + * @throws {ILNError.InvoiceNotFound} If the invoice id is unknown + * @throws {ILNError.AlreadyPaid} If the invoice has already been paid + * @throws {ILNError.AlreadyDisputed} If a dispute has already been filed + * @throws {ILNError.InvoiceDefaulted} If the invoice has already defaulted + * @throws {ILNError.InvoiceAppealed} If the invoice is under appeal + * @throws {ILNError.InvoiceExpired} If the invoice has expired + * @throws {ILNError.AlreadyCancelled} If the invoice was cancelled + * @throws {ILNError.ContractPaused} If the contract is currently paused + * * @example * ```ts * const result = await disputeInvoice({ @@ -81,6 +102,16 @@ export async function disputeInvoice( const evidenceHash = await sha256Hex(evidence); const hashBytes = Buffer.from(evidenceHash, "hex"); + const account = await retry(() => rpc.getAccount(signer.publicKey)); + const { TransactionBuilder } = await import("@stellar/stellar-sdk"); + const networkPassphrase = (await retry(() => rpc.getNetwork())).passphrase; + + const { getInvoice } = await import("./queries.js"); + const invoice = await getInvoice(rpc, contractAddress, invoiceId, account, networkPassphrase); + if (!DISPUTABLE_STATUSES.has(invoice.status)) { + throw new ILNError(`Invoice ${invoiceId} is ${invoice.status} and cannot be disputed`); + } + const contract = new Contract(contractAddress); const operation = contract.call( "dispute_invoice", @@ -88,21 +119,25 @@ export async function disputeInvoice( xdr.ScVal.scvBytes(hashBytes) ); - const account = await retry(() => rpc.getAccount(signer.publicKey)); - const { TransactionBuilder } = await import("@stellar/stellar-sdk"); - const networkPassphrase = (await rpc.getNetwork()).passphrase; - const built = await retry(() => rpc.prepareTransaction( - new TransactionBuilder(account, { - fee: String(fee), - networkPassphrase, - }) - .addOperation(operation) - .setTimeout(30) - .build() - )); + const tx = new TransactionBuilder(account, { + fee: String(fee), + networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); - const signed = await signer.signTransaction(built as any, rpc); + const sim = await retry(() => rpc.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signed = await signer.signTransaction(assembledTx as any, rpc); const response = await retry(() => rpc.sendTransaction(signed)); + if (response.errorResult) { + throw new Error(`Transaction failed: ${response.errorResult}`); + } return { txHash: response.hash, evidenceHash }; }