diff --git a/sdk/src/index.ts b/sdk/src/index.ts index bd7bb93b..c43e7c26 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -111,9 +111,15 @@ export { submitReputationInvoice, markReputationInvoicePaid, handleDefault, + getReputationBonusConfig, + getReputationBonusReputation, ReputationContractError, } from "./methods/reputation.js"; -export type { ReputationBonusInvoice } from "./methods/reputation.js"; +export type { + ReputationBonusInvoice, + ReputationBonusConfig, + ReputationBonusScore, +} from "./methods/reputation.js"; export { getReferralStats } from "./methods/referralStats.js"; export { appealInvoice, resolveAppeal } from "./methods/appeal.js"; export type { AppealInvoiceResult, ResolveAppealResult } from "./methods/appeal.js"; diff --git a/sdk/src/methods/reputation.test.ts b/sdk/src/methods/reputation.test.ts index 6b06d2a6..f071f764 100644 --- a/sdk/src/methods/reputation.test.ts +++ b/sdk/src/methods/reputation.test.ts @@ -13,6 +13,8 @@ import { submitReputationInvoice, markReputationInvoicePaid, handleDefault, + getReputationBonusConfig, + getReputationBonusReputation, ReputationContractError, } from "./reputation.js"; import type { ReputationProfile } from "./reputation.js"; @@ -240,3 +242,129 @@ describe("handleDefault", () => { expect(result.txHash).toBe("txABC"); }); }); + +// --------------------------------------------------------------------------- +// reputation_bonus config & reputation view operations (#426) +// --------------------------------------------------------------------------- + +describe("getReputationBonusConfig", () => { + it("returns the decoded config on success", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue({ + high_rep_threshold: 80, + bonus_bps: 200, + min_discount_rate_bps: 100, + }); + + const result = await getReputationBonusConfig(server, CONTRACT_ID); + + expect(result).toEqual({ + highRepThreshold: 80, + bonusBps: 200, + minDiscountRateBps: 100, + }); + expect(server.simulateTransaction).toHaveBeenCalledTimes(1); + }); + + it("maps a contract error code to ReputationContractError.ConfigErrorUnauthorized", async () => { + const server = serverWith({ error: "HostError: Error(Contract, 4)" }); + + await expect( + getReputationBonusConfig(server, CONTRACT_ID) + ).rejects.toThrow(ReputationContractError.ConfigErrorUnauthorized); + }); + + it("throws when simulation returns no result", async () => { + const server = serverWith({ result: { retval: null } }); + + await expect( + getReputationBonusConfig(server, CONTRACT_ID) + ).rejects.toThrow("get_config simulation returned no result"); + }); + + it("propagates RPC connection errors", async () => { + const server = { + simulateTransaction: vi + .fn() + .mockRejectedValue(new Error("connect ECONNREFUSED")), + } as unknown as SorobanRpc.Server; + + await expect( + getReputationBonusConfig(server, CONTRACT_ID) + ).rejects.toThrow("connect ECONNREFUSED"); + }); +}); + +describe("getReputationBonusReputation — known address", () => { + it("returns a populated score on success", async () => { + const server = serverWith({ result: { retval: {} } }); + mockScValToNative.mockReturnValue({ + invoices_submitted: 12, + invoices_paid: 8, + invoices_defaulted: 1, + score: 75, + }); + + const result = await getReputationBonusReputation(server, CONTRACT_ID, VALID_GA); + + expect(result).toEqual({ + invoicesSubmitted: 12, + invoicesPaid: 8, + invoicesDefaulted: 1, + score: 75, + }); + }); +}); + +describe("getReputationBonusReputation — unknown address", () => { + it("returns a zeroed score when simulation returns no retval", async () => { + const server = serverWith({ result: { retval: null } }); + + const result = await getReputationBonusReputation(server, CONTRACT_ID, VALID_GA); + + expect(result).toEqual({ + invoicesSubmitted: 0, + invoicesPaid: 0, + invoicesDefaulted: 0, + score: 0, + }); + }); +}); + +describe("getReputationBonusReputation — invalid address", () => { + const server = serverWith({}); + + it("throws for empty string", async () => { + await expect( + getReputationBonusReputation(server, CONTRACT_ID, "") + ).rejects.toThrow("Invalid Stellar address"); + }); + + it("throws for short addresses", async () => { + await expect( + getReputationBonusReputation(server, CONTRACT_ID, "GABC") + ).rejects.toThrow("Invalid Stellar address"); + }); +}); + +describe("getReputationBonusReputation — RPC errors", () => { + it("throws when simulation returns an error object", async () => { + const server = serverWith({ error: "contract trap", _parsed: true }); + + await expect( + getReputationBonusReputation(server, CONTRACT_ID, VALID_GA) + ).rejects.toThrow("contract trap"); + }); + + it("propagates RPC connection errors", async () => { + const server = { + simulateTransaction: vi + .fn() + .mockRejectedValue(new Error("connect ECONNREFUSED")), + } as unknown as SorobanRpc.Server; + + await expect( + getReputationBonusReputation(server, CONTRACT_ID, VALID_GA) + ).rejects.toThrow("connect ECONNREFUSED"); + }); +}); diff --git a/sdk/src/methods/reputation.ts b/sdk/src/methods/reputation.ts index 4abd53f9..30dabd10 100644 --- a/sdk/src/methods/reputation.ts +++ b/sdk/src/methods/reputation.ts @@ -366,3 +366,149 @@ export async function handleDefault( ); return { txHash }; } + +// --------------------------------------------------------------------------- +// reputation_bonus config & reputation view operations (#426) +// --------------------------------------------------------------------------- + +/** Mirrors `Config` in contracts/reputation_bonus/src/config.rs. */ +export interface ReputationBonusConfig { + highRepThreshold: number; + bonusBps: number; + minDiscountRateBps: number; +} + +/** Mirrors `ReputationScore` in contracts/reputation_bonus/src/reputation.rs. */ +export interface ReputationBonusScore { + invoicesSubmitted: number; + invoicesPaid: number; + invoicesDefaulted: number; + score: number; +} + +/** + * Fetch the reputation_bonus contract's bonus configuration. + * + * Wraps the read-only `get_config()` view function. Performs a Soroban + * simulation only — no on-chain mutation, no transaction fees, and no + * signer required. + * + * @param server - Soroban RPC server for the target network + * @param contractId - Deployed reputation_bonus contract address + * @param networkPassphrase - Stellar network passphrase (default: TESTNET) + * @returns The bonus configuration (highRepThreshold, bonusBps, minDiscountRateBps) + * @throws {ReputationContractError.ConfigErrorUnauthorized} If no config has been set yet + * + * @example + * ```ts + * const config = await getReputationBonusConfig(server, CONTRACT_ID); + * console.log(`Bonus: ${config.bonusBps} bps above ${config.highRepThreshold} score`); + * ``` + */ +export async function getReputationBonusConfig( + server: SorobanRpc.Server, + contractId: string, + networkPassphrase: string = Networks.TESTNET +): Promise { + const contract = new Contract(contractId); + const op = contract.call("get_config"); + + const sourceAccount = new Account( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "0" + ); + + const simTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(simTx)); + + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ReputationContractError.fromError(sim.error); + } + if (!sim.result?.retval) { + throw new Error("get_config simulation returned no result"); + } + + const raw = scValToNative(sim.result.retval) as Record; + return { + highRepThreshold: Number(raw["high_rep_threshold"] ?? 0), + bonusBps: Number(raw["bonus_bps"] ?? 0), + minDiscountRateBps: Number(raw["min_discount_rate_bps"] ?? 0), + }; +} + +/** + * Fetch an address's reputation score from the reputation_bonus contract. + * + * Wraps the read-only `get_reputation(address)` view function. This targets + * the reputation_bonus contract, distinct from {@link getReputation} (which + * targets the invoice-liquidity contract) — the two contracts track + * reputation independently and `ReputationScore` has no `address` field. + * Unknown addresses return a zeroed score (matching the contract's + * lazy-init behaviour). + * + * @param server - Soroban RPC server for the target network + * @param contractId - Deployed reputation_bonus contract address + * @param address - Stellar G… address to look up + * @param networkPassphrase - Stellar network passphrase (default: TESTNET) + * @returns ReputationBonusScore (zeroed for unknown / never-active addresses) + * @throws When `address` is not a valid Stellar G-address + * @throws When the Soroban simulation fails (RPC unreachable, contract not found) + * + * @example + * ```ts + * const score = await getReputationBonusReputation(server, CONTRACT_ID, "GAA..."); + * console.log(`Score: ${score.score}, Submitted: ${score.invoicesSubmitted}`); + * ``` + */ +export async function getReputationBonusReputation( + server: SorobanRpc.Server, + contractId: string, + address: string, + networkPassphrase: string = Networks.TESTNET +): Promise { + if (!isValidGAddress(address)) { + throw new Error( + `Invalid Stellar address: "${address}". Must be a G… public key.` + ); + } + + const contract = new Contract(contractId); + const op = contract.call("get_reputation", new Address(address).toScVal()); + + const sourceAccount = new Account( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "0" + ); + + const simTx = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const sim = await retry(() => server.simulateTransaction(simTx)); + + if (SorobanRpc.Api.isSimulationError(sim)) { + throw ReputationContractError.fromError(sim.error); + } + + const raw = ( + sim.result?.retval ? scValToNative(sim.result.retval) : {} + ) as Record; + + return { + invoicesSubmitted: Number(raw["invoices_submitted"] ?? 0), + invoicesPaid: Number(raw["invoices_paid"] ?? 0), + invoicesDefaulted: Number(raw["invoices_defaulted"] ?? 0), + score: Number(raw["score"] ?? 0), + }; +}