From 175b67174cd77c41f29f9dd0da8e964d1e00d044 Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Sun, 26 Jul 2026 01:24:37 +0100 Subject: [PATCH] feat: add setDistributionContract SDK method, insurance pool health check, governance delegation examples, and dispute/appeal SDK examples - Issue #518: expose set_distribution_contract as setDistributionContract admin method in sdk/src/methods/admin.ts, export from index.ts, add tests - Issue #454: add checkInsurancePool to scripts/check-contract-health.ts verifying Admin storage key presence; wired into runHealthChecks - Issue #452: add delegateVotes/undelegateVotes examples and governance event listening to docs/sdk-integration.md - Issue #453: add appealInvoice example and dispute/appeal event subscription examples to docs/sdk-integration.md --- docs/sdk-integration.md | 103 ++++++++++++++++++++++ scripts/check-contract-health.ts | 72 ++++++++++++++- sdk/src/index.ts | 2 +- sdk/src/methods/admin.ts | 57 +++++++++++- sdk/tests/setDistributionContract.test.ts | 81 +++++++++++++++++ 5 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 sdk/tests/setDistributionContract.test.ts diff --git a/docs/sdk-integration.md b/docs/sdk-integration.md index 8cc0e6f5..2edae986 100644 --- a/docs/sdk-integration.md +++ b/docs/sdk-integration.md @@ -367,6 +367,54 @@ console.log(`Evidence hash: ${result.evidenceHash}`); **Errors:** `ILNError` `NotAuthorized` (caller is not the payer) or `InvalidStatus` (invoice not in a disputable state). +### Appeal a dispute ruling + +If a dispute ruling is unfavourable, the losing party can file an appeal within +the appeal window. + +```ts +import { appealInvoice, KeypairSigner } from "@iln/sdk"; + +const appeal = await appealInvoice({ + rpc: server, + contractAddress: CONTRACT_ID, + signer: new KeypairSigner(payerKeypair), + invoiceId: 129n, + reason: "Ruling ignored submitted evidence — see ticket #8842", +}); + +console.log(`Appeal tx: ${appeal.txHash}`); +``` + +**Returns:** `AppealInvoiceResult` — `{ txHash: string }`. + +**Errors:** `ILNError` `NotAuthorized`, `InvalidStatus` (no ruling to appeal), +or `AppealWindowClosed` (appeal period has expired). + +### Listen for dispute and appeal events + +Subscribe to real-time dispute and appeal events to update UIs or trigger +notifications without polling. + +```ts +import { subscribe } from "@iln/sdk"; + +const unsubscribe = subscribe( + server, + CONTRACT_ID, + { types: ["invoice_disputed", "dispute_resolved", "invoice_appealed", "appeal_resolved"] }, + (event) => { + console.log(event.type, event.invoiceId, event.ledger); + } +); + +// Stop listening when done. +unsubscribe(); +``` + +For historical dispute/appeal events, query the indexer's `/events` endpoint +with a `type` filter; see [docs/events.md](events.md) for the full catalogue. + --- ## Governance @@ -422,6 +470,61 @@ const active = await listProposals(server, CONTRACT_ID, account, NETWORK_PASSPHR `QuorumNotReached` (on execute). See [docs/governance.md](governance.md) for the full state machine. +### Delegate votes + +Token holders can delegate their voting power to another address, or undelegate +to reclaim it. + +```ts +import { delegateVotes, undelegateVotes } from "@iln/sdk"; + +const account = await server.getAccount(memberPublicKey); + +// Delegate voting power to a trusted representative. +const { txHash: delegateTx } = await delegateVotes( + server, + CONTRACT_ID, + delegatePublicKey, + account, + signTx, + NETWORK_PASSPHRASE +); +console.log(`Delegated votes in tx ${delegateTx}`); + +// Reclaim voting power at any time. +const { txHash: undelegateTx } = await undelegateVotes( + server, + CONTRACT_ID, + account, + signTx, + NETWORK_PASSPHRASE +); +console.log(`Undelegated votes in tx ${undelegateTx}`); +``` + +**Returns:** `{ txHash: string }` for both. + +**Errors:** `ILNError` `NotAuthorized` (no active delegation to undo) or +`InvalidGAddress` (malformed delegate address). + +### Listen for governance events + +```ts +import { subscribe } from "@iln/sdk"; + +const unsubscribe = subscribe( + server, + CONTRACT_ID, + { types: ["proposal_created", "vote_cast", "proposal_executed"] }, + (event) => { + console.log(event.type, event.ledger); + } +); + +// Stop listening when done. +unsubscribe(); +``` + --- ## Analytics diff --git a/scripts/check-contract-health.ts b/scripts/check-contract-health.ts index 167b43b8..dc6a3dcb 100644 --- a/scripts/check-contract-health.ts +++ b/scripts/check-contract-health.ts @@ -26,6 +26,8 @@ * HORIZON_URL Horizon endpoint (default: testnet) * INDEXER_URL Indexer base URL (default: http://localhost:3000) * NOTIFICATIONS_URL Notifications base URL (default: http://localhost:3001) + * INSURANCE_POOL_RPC_URL Soroban RPC endpoint for the insurance pool contract + * INSURANCE_POOL_ID Deployed insurance pool contract address * LEDGER_LAG_THRESHOLD Max acceptable ledger lag (default: 100) * HEALTH_TIMEOUT_MS Per-request timeout in ms (default: 5000) * SLACK_WEBHOOK_URL Incoming webhook used by --alert-slack @@ -54,6 +56,8 @@ export interface HealthConfig { horizonUrl: string; indexerUrl: string; notificationsUrl: string; + insurancePoolRpcUrl: string; + insurancePoolId: string; ledgerLagThreshold: number; timeoutMs: number; } @@ -81,6 +85,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HealthConfig { horizonUrl: (env.HORIZON_URL || "https://horizon-testnet.stellar.org").replace(/\/$/, ""), indexerUrl: (env.INDEXER_URL || "http://localhost:3000").replace(/\/$/, ""), notificationsUrl: (env.NOTIFICATIONS_URL || "http://localhost:3001").replace(/\/$/, ""), + insurancePoolRpcUrl: env.INSURANCE_POOL_RPC_URL || env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org", + insurancePoolId: env.INSURANCE_POOL_ID || "", ledgerLagThreshold: Number(env.LEDGER_LAG_THRESHOLD || 100), timeoutMs: Number(env.HEALTH_TIMEOUT_MS || 5000), }; @@ -247,6 +253,67 @@ export async function checkLedgerLag( } } +/** 5. Insurance pool contract — verifies it is initialized (Admin key present). */ +export async function checkInsurancePool( + cfg: HealthConfig, + deps: Deps = defaultDeps +): Promise { + const base: CheckResult = { + name: "insurance_pool", + status: "unknown", + critical: false, + latencyMs: null, + details: { contractId: cfg.insurancePoolId }, + error: null, + }; + + if (!cfg.insurancePoolId) { + return { ...base, status: "unknown", error: "INSURANCE_POOL_ID not configured" }; + } + + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "getLedgerEntries", + params: { + keys: [ + // DataKey::Admin is a unit enum variant — its XDR is a 1-element vec + // with symbol "Admin". We request it to confirm the pool is initialised. + Buffer.from( + JSON.stringify({ contractId: cfg.insurancePoolId, key: { type: "symbol", value: "Admin" } }) + ).toString("base64"), + ], + }, + }); + + try { + const { res, latencyMs } = await timedFetch( + deps, + cfg.insurancePoolRpcUrl, + { method: "POST", headers: { "content-type": "application/json" }, body }, + cfg.timeoutMs + ); + base.latencyMs = latencyMs; + if (!res.ok) { + return { ...base, status: "fail", error: `RPC returned HTTP ${res.status}` }; + } + const json: any = await res.json(); + if (json.error) { + return { ...base, status: "fail", error: `RPC error: ${JSON.stringify(json.error)}` }; + } + const entries: unknown[] = json.result?.entries ?? []; + const initialized = entries.length > 0; + return { + ...base, + status: initialized ? "ok" : "fail", + details: { ...base.details, initialized }, + error: initialized ? null : "Insurance pool Admin key not found — pool may not be initialized", + }; + } catch (e) { + return { ...base, status: "fail", error: errMsg(e) }; + } +} + /** 4. Notification service `/health` endpoint. */ export async function checkNotifications( cfg: HealthConfig, @@ -294,14 +361,15 @@ export async function runHealthChecks( checkContractRpc(cfg, deps), checkIndexer(cfg, deps), ]); - const [lag, notifications] = await Promise.all([ + const [lag, notifications, insurancePool] = await Promise.all([ checkLedgerLag(cfg, indexer.lastIndexedLedger, deps), checkNotifications(cfg, deps), + checkInsurancePool(cfg, deps), ]); // Drop the helper-only field before reporting. const { lastIndexedLedger: _ignored, ...indexerResult } = indexer; - const checks: CheckResult[] = [rpc, indexerResult, lag, notifications]; + const checks: CheckResult[] = [rpc, indexerResult, lag, notifications, insurancePool]; const healthy = checks.every((c) => !(c.critical && c.status === "fail")); diff --git a/sdk/src/index.ts b/sdk/src/index.ts index cc495dab..54da0b40 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -141,4 +141,4 @@ export { submitBatchTransaction, } from "./methods/batch.js"; export type { BatchContractCall, BatchTransactionOptions, BatchTransactionResult } from "./methods/batch.js"; -export { setAdmin, upgrade } from "./methods/admin.js"; +export { setAdmin, upgrade, setDistributionContract } from "./methods/admin.js"; diff --git a/sdk/src/methods/admin.ts b/sdk/src/methods/admin.ts index c84c7b00..d5af5b7e 100644 --- a/sdk/src/methods/admin.ts +++ b/sdk/src/methods/admin.ts @@ -9,7 +9,7 @@ import { } from "@stellar/stellar-sdk"; import { ILNError } from "../errors.js"; import { retry } from "../utils/retry.js"; -import { validateGAddress } from "../utils/validate.js"; +import { validateGAddress, validateContractId } from "../utils/validate.js"; /** * Set a new admin for the ILN contract. @@ -116,3 +116,58 @@ export async function upgrade( return { txHash: sendResult.hash }; } + +/** + * Set the distribution contract address on the ILN invoice_liquidity contract. + * Admin only — subject to the default rate limit. + */ +export async function setDistributionContract( + server: SorobanRpc.Server, + contractAddress: string, + distributionContract: string, + sourceAccount: Account, + signTransaction: (tx: Transaction) => Promise | Transaction, + networkPassphrase: string +): Promise<{ txHash: string }> { + validateContractId(distributionContract); + + const contract = new Contract(contractAddress); + const op = contract.call( + "set_distribution_contract", + nativeToScVal(distributionContract, { 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/tests/setDistributionContract.test.ts b/sdk/tests/setDistributionContract.test.ts new file mode 100644 index 00000000..e10911ac --- /dev/null +++ b/sdk/tests/setDistributionContract.test.ts @@ -0,0 +1,81 @@ +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { setDistributionContract } from "../src/methods/admin.js"; +import { Account, SorobanRpc } from "@stellar/stellar-sdk"; + +const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; +const MOCK_HASH = "abc123"; + +describe("setDistributionContract", () => { + 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 distributionContract is not a valid contract ID", async () => { + await expect( + setDistributionContract( + 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( + setDistributionContract( + 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, + }); + + vi.spyOn(SorobanRpc, "assembleTransaction" as never).mockReturnValue({ + build: () => ({} as never), + } as never); + + const result = await setDistributionContract( + mockServer, + VALID_CONTRACT, + VALID_CONTRACT, + mockAccount, + mockSign, + "passphrase" + ); + expect(result.txHash).toBe(MOCK_HASH); + }); +});