diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 4ad471f6..86623504 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -44,6 +44,7 @@ export type { Unsubscribe, } from "./events/types.js"; +export { getInvoice, listInvoicesBySubmitter, listInvoicesByLP, getSubmitterInvoices, getPayerScore } from "./methods/queries.js"; export { getInvoice, listInvoicesBySubmitter, listInvoicesByLP, getSubmitterInvoices } from "./methods/queries.js"; export { getLpInvoices } from "./methods/lpInvoices.js"; export { getNftMetadata, getNftOwner } from "./methods/nft.js"; @@ -142,4 +143,4 @@ export { submitBatchTransaction, } from "./methods/batch.js"; export type { BatchContractCall, BatchTransactionOptions, BatchTransactionResult } from "./methods/batch.js"; -export { setAdmin, upgrade, setDistributionContract } from "./methods/admin.js"; +export { setAdmin, upgrade, setDistributionContract, addToken, removeToken, updateFeeRate, updateMaxDiscount, setPriceOracle, setMaxOracleAge } from "./methods/admin.js"; diff --git a/sdk/src/methods/admin.ts b/sdk/src/methods/admin.ts index d5af5b7e..141e48c9 100644 --- a/sdk/src/methods/admin.ts +++ b/sdk/src/methods/admin.ts @@ -171,3 +171,331 @@ export async function setDistributionContract( return { txHash: sendResult.hash }; } + +/** + * Add an approved token to the ILN invoice_liquidity contract. + * Admin only — subject to the default rate limit. + */ +export async function addToken( + server: SorobanRpc.Server, + contractAddress: string, + token: string, + decimals: number, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + validateContractId(token); + + const contract = new Contract(contractAddress); + const op = contract.call( + "add_token", + nativeToScVal(token, { type: "address" }), + nativeToScVal(decimals, { type: "u32" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signedTx = await signTransaction(assembledTx); + const sendResult = await retry(() => server.sendTransaction(signedTx)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + let status = await retry(() => server.getTransaction(sendResult.hash)); + let retries = 0; + while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) { + await new Promise(r => setTimeout(r, 2000)); + status = await retry(() => server.getTransaction(sendResult.hash)); + retries++; + } + + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed during execution"); + } + + return { txHash: sendResult.hash }; +} + +/** + * Update the protocol fee rate (in basis points) on the ILN invoice_liquidity + * contract. Admin only — subject to the economic-parameter cooldown. + */ +export async function updateFeeRate( + server: SorobanRpc.Server, + contractAddress: string, + rate: number, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + const contract = new Contract(contractAddress); + const op = contract.call( + "update_fee_rate", + nativeToScVal(rate, { type: "u32" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signedTx = await signTransaction(assembledTx); + const sendResult = await retry(() => server.sendTransaction(signedTx)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + let status = await retry(() => server.getTransaction(sendResult.hash)); + let retries = 0; + while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) { + await new Promise(r => setTimeout(r, 2000)); + status = await retry(() => server.getTransaction(sendResult.hash)); + retries++; + } + + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed during execution"); + } + + return { txHash: sendResult.hash }; +} + +/** + * Update the maximum invoice discount rate (in basis points) on the ILN + * invoice_liquidity contract. Admin only — subject to the economic-parameter + * cooldown. + */ +export async function updateMaxDiscount( + server: SorobanRpc.Server, + contractAddress: string, + rate: number, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + const contract = new Contract(contractAddress); + const op = contract.call( + "update_max_discount", + nativeToScVal(rate, { type: "u32" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signedTx = await signTransaction(assembledTx); + const sendResult = await retry(() => server.sendTransaction(signedTx)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + let status = await retry(() => server.getTransaction(sendResult.hash)); + let retries = 0; + while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) { + await new Promise(r => setTimeout(r, 2000)); + status = await retry(() => server.getTransaction(sendResult.hash)); + retries++; + } + + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed during execution"); + } + + return { txHash: sendResult.hash }; +} + +/** + * Set the price oracle contract address on the ILN invoice_liquidity + * contract. Admin only — subject to the default rate limit. + */ +export async function setPriceOracle( + server: SorobanRpc.Server, + contractAddress: string, + oracle: string, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + validateContractId(oracle); + + const contract = new Contract(contractAddress); + const op = contract.call( + "set_price_oracle", + nativeToScVal(oracle, { type: "address" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signedTx = await signTransaction(assembledTx); + const sendResult = await retry(() => server.sendTransaction(signedTx)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + let status = await retry(() => server.getTransaction(sendResult.hash)); + let retries = 0; + while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) { + await new Promise(r => setTimeout(r, 2000)); + status = await retry(() => server.getTransaction(sendResult.hash)); + retries++; + } + + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed during execution"); + } + + return { txHash: sendResult.hash }; +} + +/** + * Update the maximum oracle data age (in ledgers) on the ILN + * invoice_liquidity contract. Admin only — subject to the default rate + * limit. Setting this to 0 disables the freshness check entirely. + */ +export async function setMaxOracleAge( + server: SorobanRpc.Server, + contractAddress: string, + maxAgeLedgers: bigint, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + const contract = new Contract(contractAddress); + const op = contract.call( + "set_max_oracle_age", + nativeToScVal(maxAgeLedgers, { type: "u64" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signedTx = await signTransaction(assembledTx); + const sendResult = await retry(() => server.sendTransaction(signedTx)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + let status = await retry(() => server.getTransaction(sendResult.hash)); + let retries = 0; + while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) { + await new Promise(r => setTimeout(r, 2000)); + status = await retry(() => server.getTransaction(sendResult.hash)); + retries++; + } + + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed during execution"); + } + + return { txHash: sendResult.hash }; +} + +/** + * Remove an approved token from the ILN invoice_liquidity contract. + * Admin only — subject to the default rate limit. + */ +export async function removeToken( + server: SorobanRpc.Server, + contractAddress: string, + token: string, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + validateContractId(token); + + const contract = new Contract(contractAddress); + const op = contract.call( + "remove_token", + nativeToScVal(token, { type: "address" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + + const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build(); + const signedTx = await signTransaction(assembledTx); + const sendResult = await retry(() => server.sendTransaction(signedTx)); + if (sendResult.errorResult) { + throw new Error(`Transaction failed: ${sendResult.errorResult}`); + } + + let status = await retry(() => server.getTransaction(sendResult.hash)); + let retries = 0; + while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) { + await new Promise(r => setTimeout(r, 2000)); + status = await retry(() => server.getTransaction(sendResult.hash)); + retries++; + } + + if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed during execution"); + } + + return { txHash: sendResult.hash }; +} diff --git a/sdk/src/methods/queries.ts b/sdk/src/methods/queries.ts index 1ddf6ee1..84686baa 100644 --- a/sdk/src/methods/queries.ts +++ b/sdk/src/methods/queries.ts @@ -11,6 +11,7 @@ import type { Invoice, InvoiceState } from "@invoice-liquidity/types"; import { ILNError } from "../errors.js"; import { decodeInvoice } from "../utils/xdrDecoder.js"; import { retry } from "../utils/retry.js"; +import { validateGAddress } from "../utils/validate.js"; /** * Fetch a single invoice by its ID. @@ -228,3 +229,56 @@ export async function listInvoicesByLP( const rawArr = scValToNative(sim.result.retval) as Record[]; return rawArr.map(raw => decodeInvoice(raw as Record)); } + +/** + * Get a payer's reputation score (0-100, default 50). + * + * Wraps the `payer_score(payer)` view function. Performs a read-only + * simulation — no on-chain mutation, no transaction fees. + * @param server Soroban RPC server instance + * @param contractAddress The contract's address + * @param payer The payer's Stellar G... address + * @param sourceAccount Account used for simulation (does not consume sequence or fees) + * @param networkPassphrase The network passphrase + * @returns The payer's reputation score + * @throws {ILNError.InvalidAddress} If payer is not a valid Stellar address + * @throws {ILNError} On other simulation errors + * @example + * ```ts + * const score = await getPayerScore(server, contractAddress, "G...", sourceAccount, Networks.TESTNET); + * ``` + */ +export async function getPayerScore( + server: SorobanRpc.Server, + contractAddress: string, + payer: string, + sourceAccount: Account, + networkPassphrase: string +): Promise { + validateGAddress(payer); + + const contract = new Contract(contractAddress); + const op = contract.call( + "payer_score", + nativeToScVal(payer, { type: "address" }) + ); + + const tx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(tx)); + + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ILNError.fromError(sim.error); + } + if (!sim.result?.retval) { + return 0; + } + + return scValToNative(sim.result.retval) as number; +} diff --git a/sdk/tests/addToken.test.ts b/sdk/tests/addToken.test.ts new file mode 100644 index 00000000..5ec5a262 --- /dev/null +++ b/sdk/tests/addToken.test.ts @@ -0,0 +1,89 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { Account, SorobanRpc } 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 { addToken } from "../src/methods/admin.js"; + +const VALID_TOKEN = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("addToken", () => { + const mockServer = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + } as unknown as SorobanRpc.Server; + const mockAccount = new Account( + "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", + "1" + ); + const mockSign = vi.fn((tx) => tx); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws if token is not a valid contract ID", async () => { + await expect( + addToken( + mockServer, + VALID_TOKEN, + "not-a-valid-contract", + 7, + mockAccount, + mockSign, + "passphrase" + ) + ).rejects.toThrow(); + }); + + it("throws if simulation returns an error", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + error: "simulation failed", + }); + await expect( + addToken( + mockServer, + VALID_TOKEN, + VALID_TOKEN, + 7, + mockAccount, + mockSign, + "passphrase" + ) + ).rejects.toThrow(); + }); + + it("returns txHash on success", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + + const result = await addToken( + mockServer, + VALID_TOKEN, + VALID_TOKEN, + 7, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +}); diff --git a/sdk/tests/queries.test.ts b/sdk/tests/queries.test.ts index 9965a56d..715df60f 100644 --- a/sdk/tests/queries.test.ts +++ b/sdk/tests/queries.test.ts @@ -1,12 +1,27 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { getInvoice, listInvoicesBySubmitter, listInvoicesByLP, getSubmitterInvoices } from '../src/methods/queries.js'; +import { getInvoice, listInvoicesBySubmitter, listInvoicesByLP, getSubmitterInvoices, getPayerScore } from '../src/methods/queries.js'; import { ILNError } from '../src/errors.js'; -import { Account, SorobanRpc } from '@stellar/stellar-sdk'; +import { Account, SorobanRpc, scValToNative } from '@stellar/stellar-sdk'; + +// Mock scValToNative so getPayerScore tests can control the decoded result +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual('@stellar/stellar-sdk'); + return { + ...actual, + scValToNative: vi.fn().mockImplementation(actual.scValToNative), + }; +}); + +const mockScValToNative = scValToNative as unknown as ReturnType; describe('queries', () => { const mockServer = { simulateTransaction: vi.fn() } as unknown as SorobanRpc.Server; const mockAccount = new Account("GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", "1"); + beforeEach(() => { + mockScValToNative.mockClear(); + }); + it('getInvoice throws InvoiceNotFound', async () => { // @ts-ignore mockServer.simulateTransaction.mockResolvedValue({ error: 'NotFound' }); @@ -32,4 +47,33 @@ describe('queries', () => { const result = await getSubmitterInvoices(mockServer, "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", mockAccount, 'pass'); expect(result).toEqual([]); }); + + it('getPayerScore throws InvalidAddress for a malformed payer address', async () => { + await expect( + getPayerScore(mockServer, "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", "not-an-address", mockAccount, 'pass') + ).rejects.toThrow(ILNError.InvalidAddress); + }); + + it('getPayerScore throws on simulation error', async () => { + // @ts-ignore + mockServer.simulateTransaction.mockResolvedValue({ error: 'ContractError' }); + await expect( + getPayerScore(mockServer, "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", mockAccount, 'pass') + ).rejects.toThrow(); + }); + + it('getPayerScore returns 0 when retval is empty', async () => { + // @ts-ignore + mockServer.simulateTransaction.mockResolvedValue({ result: { retval: null } }); + const result = await getPayerScore(mockServer, "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", mockAccount, 'pass'); + expect(result).toBe(0); + }); + + it('getPayerScore returns the decoded score on success', async () => { + mockScValToNative.mockReturnValue(72); + // @ts-ignore + mockServer.simulateTransaction.mockResolvedValue({ result: { retval: {} } }); + const result = await getPayerScore(mockServer, "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", mockAccount, 'pass'); + expect(result).toBe(72); + }); }); diff --git a/sdk/tests/removeToken.test.ts b/sdk/tests/removeToken.test.ts new file mode 100644 index 00000000..27007d97 --- /dev/null +++ b/sdk/tests/removeToken.test.ts @@ -0,0 +1,86 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { Account, SorobanRpc } 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 { removeToken } from "../src/methods/admin.js"; + +const VALID_TOKEN = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("removeToken", () => { + const mockServer = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + } as unknown as SorobanRpc.Server; + const mockAccount = new Account( + "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", + "1" + ); + const mockSign = vi.fn((tx) => tx); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws if token is not a valid contract ID", async () => { + await expect( + removeToken( + mockServer, + VALID_TOKEN, + "not-a-valid-contract", + mockAccount, + mockSign, + "passphrase" + ) + ).rejects.toThrow(); + }); + + it("throws if simulation returns an error", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + error: "simulation failed", + }); + await expect( + removeToken( + mockServer, + VALID_TOKEN, + VALID_TOKEN, + mockAccount, + mockSign, + "passphrase" + ) + ).rejects.toThrow(); + }); + + it("returns txHash on success", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + + const result = await removeToken( + mockServer, + VALID_TOKEN, + VALID_TOKEN, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +}); diff --git a/sdk/tests/setMaxOracleAge.test.ts b/sdk/tests/setMaxOracleAge.test.ts new file mode 100644 index 00000000..7d12d37c --- /dev/null +++ b/sdk/tests/setMaxOracleAge.test.ts @@ -0,0 +1,85 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { Account, SorobanRpc } 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 { setMaxOracleAge } from "../src/methods/admin.js"; + +const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("setMaxOracleAge", () => { + const mockServer = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + } as unknown as SorobanRpc.Server; + const mockAccount = new Account( + "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", + "1" + ); + const mockSign = vi.fn((tx) => tx); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws if simulation returns an error", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + error: "simulation failed", + }); + await expect( + setMaxOracleAge(mockServer, VALID_CONTRACT, 17280n, mockAccount, mockSign, "passphrase") + ).rejects.toThrow(); + }); + + it("throws if the transaction fails during execution", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + }); + + await expect( + setMaxOracleAge(mockServer, VALID_CONTRACT, 17280n, mockAccount, mockSign, "passphrase") + ).rejects.toThrow("Transaction failed during execution"); + }); + + it("returns txHash on success", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + + const result = await setMaxOracleAge( + mockServer, + VALID_CONTRACT, + 17280n, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +}); diff --git a/sdk/tests/setPriceOracle.test.ts b/sdk/tests/setPriceOracle.test.ts new file mode 100644 index 00000000..103a5074 --- /dev/null +++ b/sdk/tests/setPriceOracle.test.ts @@ -0,0 +1,86 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { Account, SorobanRpc } 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 { setPriceOracle } from "../src/methods/admin.js"; + +const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("setPriceOracle", () => { + const mockServer = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + } as unknown as SorobanRpc.Server; + const mockAccount = new Account( + "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", + "1" + ); + const mockSign = vi.fn((tx) => tx); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws if oracle is not a valid contract ID", async () => { + await expect( + setPriceOracle( + mockServer, + VALID_CONTRACT, + "not-a-valid-contract", + mockAccount, + mockSign, + "passphrase" + ) + ).rejects.toThrow(); + }); + + it("throws if simulation returns an error", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + error: "simulation failed", + }); + await expect( + setPriceOracle( + mockServer, + VALID_CONTRACT, + VALID_CONTRACT, + mockAccount, + mockSign, + "passphrase" + ) + ).rejects.toThrow(); + }); + + it("returns txHash on success", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + + const result = await setPriceOracle( + mockServer, + VALID_CONTRACT, + VALID_CONTRACT, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +}); diff --git a/sdk/tests/updateFeeRate.test.ts b/sdk/tests/updateFeeRate.test.ts new file mode 100644 index 00000000..4aee2a62 --- /dev/null +++ b/sdk/tests/updateFeeRate.test.ts @@ -0,0 +1,85 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { Account, SorobanRpc } 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 { updateFeeRate } from "../src/methods/admin.js"; + +const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("updateFeeRate", () => { + const mockServer = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + } as unknown as SorobanRpc.Server; + const mockAccount = new Account( + "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", + "1" + ); + const mockSign = vi.fn((tx) => tx); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws if simulation returns an error", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + error: "simulation failed", + }); + await expect( + updateFeeRate(mockServer, VALID_CONTRACT, 500, mockAccount, mockSign, "passphrase") + ).rejects.toThrow(); + }); + + it("throws if the transaction fails during execution", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + }); + + await expect( + updateFeeRate(mockServer, VALID_CONTRACT, 500, mockAccount, mockSign, "passphrase") + ).rejects.toThrow("Transaction failed during execution"); + }); + + it("returns txHash on success", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + + const result = await updateFeeRate( + mockServer, + VALID_CONTRACT, + 500, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +}); diff --git a/sdk/tests/updateMaxDiscount.test.ts b/sdk/tests/updateMaxDiscount.test.ts new file mode 100644 index 00000000..0c2588b3 --- /dev/null +++ b/sdk/tests/updateMaxDiscount.test.ts @@ -0,0 +1,85 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { Account, SorobanRpc } 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 { updateMaxDiscount } from "../src/methods/admin.js"; + +const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("updateMaxDiscount", () => { + const mockServer = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + getTransaction: vi.fn(), + } as unknown as SorobanRpc.Server; + const mockAccount = new Account( + "GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS", + "1" + ); + const mockSign = vi.fn((tx) => tx); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("throws if simulation returns an error", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + error: "simulation failed", + }); + await expect( + updateMaxDiscount(mockServer, VALID_CONTRACT, 2000, mockAccount, mockSign, "passphrase") + ).rejects.toThrow(); + }); + + it("throws if the transaction fails during execution", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.FAILED, + }); + + await expect( + updateMaxDiscount(mockServer, VALID_CONTRACT, 2000, mockAccount, mockSign, "passphrase") + ).rejects.toThrow("Transaction failed during execution"); + }); + + it("returns txHash on success", async () => { + mockServer.simulateTransaction = vi.fn().mockResolvedValue({ + result: { auth: [], retval: undefined }, + transactionData: { build: () => ({}) }, + minResourceFee: "100", + }); + mockServer.sendTransaction = vi.fn().mockResolvedValue({ + hash: MOCK_HASH, + errorResult: undefined, + }); + mockServer.getTransaction = vi.fn().mockResolvedValue({ + status: SorobanRpc.Api.GetTransactionStatus.SUCCESS, + }); + + const result = await updateMaxDiscount( + mockServer, + VALID_CONTRACT, + 2000, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +});