Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
128 changes: 128 additions & 0 deletions sdk/src/methods/reputation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
submitReputationInvoice,
markReputationInvoicePaid,
handleDefault,
getReputationBonusConfig,
getReputationBonusReputation,
ReputationContractError,
} from "./reputation.js";
import type { ReputationProfile } from "./reputation.js";
Expand Down Expand Up @@ -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");
});
});
146 changes: 146 additions & 0 deletions sdk/src/methods/reputation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReputationBonusConfig> {
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<string, unknown>;
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<ReputationBonusScore> {
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<string, unknown>;

return {
invoicesSubmitted: Number(raw["invoices_submitted"] ?? 0),
invoicesPaid: Number(raw["invoices_paid"] ?? 0),
invoicesDefaulted: Number(raw["invoices_defaulted"] ?? 0),
score: Number(raw["score"] ?? 0),
};
}
Loading