Skip to content

Commit 1486098

Browse files
authored
Merge pull request #586 from DeborahOlaboye/feat/sdk-governance-dispute-insurance-distribution
2 parents db7d07c + 175b671 commit 1486098

5 files changed

Lines changed: 311 additions & 4 deletions

File tree

docs/sdk-integration.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,54 @@ console.log(`Evidence hash: ${result.evidenceHash}`);
367367
**Errors:** `ILNError` `NotAuthorized` (caller is not the payer) or
368368
`InvalidStatus` (invoice not in a disputable state).
369369

370+
### Appeal a dispute ruling
371+
372+
If a dispute ruling is unfavourable, the losing party can file an appeal within
373+
the appeal window.
374+
375+
```ts
376+
import { appealInvoice, KeypairSigner } from "@iln/sdk";
377+
378+
const appeal = await appealInvoice({
379+
rpc: server,
380+
contractAddress: CONTRACT_ID,
381+
signer: new KeypairSigner(payerKeypair),
382+
invoiceId: 129n,
383+
reason: "Ruling ignored submitted evidence — see ticket #8842",
384+
});
385+
386+
console.log(`Appeal tx: ${appeal.txHash}`);
387+
```
388+
389+
**Returns:** `AppealInvoiceResult``{ txHash: string }`.
390+
391+
**Errors:** `ILNError` `NotAuthorized`, `InvalidStatus` (no ruling to appeal),
392+
or `AppealWindowClosed` (appeal period has expired).
393+
394+
### Listen for dispute and appeal events
395+
396+
Subscribe to real-time dispute and appeal events to update UIs or trigger
397+
notifications without polling.
398+
399+
```ts
400+
import { subscribe } from "@iln/sdk";
401+
402+
const unsubscribe = subscribe(
403+
server,
404+
CONTRACT_ID,
405+
{ types: ["invoice_disputed", "dispute_resolved", "invoice_appealed", "appeal_resolved"] },
406+
(event) => {
407+
console.log(event.type, event.invoiceId, event.ledger);
408+
}
409+
);
410+
411+
// Stop listening when done.
412+
unsubscribe();
413+
```
414+
415+
For historical dispute/appeal events, query the indexer's `/events` endpoint
416+
with a `type` filter; see [docs/events.md](events.md) for the full catalogue.
417+
370418
---
371419

372420
## Governance
@@ -422,6 +470,61 @@ const active = await listProposals(server, CONTRACT_ID, account, NETWORK_PASSPHR
422470
`QuorumNotReached` (on execute). See [docs/governance.md](governance.md) for the
423471
full state machine.
424472

473+
### Delegate votes
474+
475+
Token holders can delegate their voting power to another address, or undelegate
476+
to reclaim it.
477+
478+
```ts
479+
import { delegateVotes, undelegateVotes } from "@iln/sdk";
480+
481+
const account = await server.getAccount(memberPublicKey);
482+
483+
// Delegate voting power to a trusted representative.
484+
const { txHash: delegateTx } = await delegateVotes(
485+
server,
486+
CONTRACT_ID,
487+
delegatePublicKey,
488+
account,
489+
signTx,
490+
NETWORK_PASSPHRASE
491+
);
492+
console.log(`Delegated votes in tx ${delegateTx}`);
493+
494+
// Reclaim voting power at any time.
495+
const { txHash: undelegateTx } = await undelegateVotes(
496+
server,
497+
CONTRACT_ID,
498+
account,
499+
signTx,
500+
NETWORK_PASSPHRASE
501+
);
502+
console.log(`Undelegated votes in tx ${undelegateTx}`);
503+
```
504+
505+
**Returns:** `{ txHash: string }` for both.
506+
507+
**Errors:** `ILNError` `NotAuthorized` (no active delegation to undo) or
508+
`InvalidGAddress` (malformed delegate address).
509+
510+
### Listen for governance events
511+
512+
```ts
513+
import { subscribe } from "@iln/sdk";
514+
515+
const unsubscribe = subscribe(
516+
server,
517+
CONTRACT_ID,
518+
{ types: ["proposal_created", "vote_cast", "proposal_executed"] },
519+
(event) => {
520+
console.log(event.type, event.ledger);
521+
}
522+
);
523+
524+
// Stop listening when done.
525+
unsubscribe();
526+
```
527+
425528
---
426529

427530
## Analytics

scripts/check-contract-health.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
* HORIZON_URL Horizon endpoint (default: testnet)
2727
* INDEXER_URL Indexer base URL (default: http://localhost:3000)
2828
* NOTIFICATIONS_URL Notifications base URL (default: http://localhost:3001)
29+
* INSURANCE_POOL_RPC_URL Soroban RPC endpoint for the insurance pool contract
30+
* INSURANCE_POOL_ID Deployed insurance pool contract address
2931
* LEDGER_LAG_THRESHOLD Max acceptable ledger lag (default: 100)
3032
* HEALTH_TIMEOUT_MS Per-request timeout in ms (default: 5000)
3133
* SLACK_WEBHOOK_URL Incoming webhook used by --alert-slack
@@ -54,6 +56,8 @@ export interface HealthConfig {
5456
horizonUrl: string;
5557
indexerUrl: string;
5658
notificationsUrl: string;
59+
insurancePoolRpcUrl: string;
60+
insurancePoolId: string;
5761
ledgerLagThreshold: number;
5862
timeoutMs: number;
5963
}
@@ -81,6 +85,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HealthConfig {
8185
horizonUrl: (env.HORIZON_URL || "https://horizon-testnet.stellar.org").replace(/\/$/, ""),
8286
indexerUrl: (env.INDEXER_URL || "http://localhost:3000").replace(/\/$/, ""),
8387
notificationsUrl: (env.NOTIFICATIONS_URL || "http://localhost:3001").replace(/\/$/, ""),
88+
insurancePoolRpcUrl: env.INSURANCE_POOL_RPC_URL || env.SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org",
89+
insurancePoolId: env.INSURANCE_POOL_ID || "",
8490
ledgerLagThreshold: Number(env.LEDGER_LAG_THRESHOLD || 100),
8591
timeoutMs: Number(env.HEALTH_TIMEOUT_MS || 5000),
8692
};
@@ -247,6 +253,67 @@ export async function checkLedgerLag(
247253
}
248254
}
249255

256+
/** 5. Insurance pool contract — verifies it is initialized (Admin key present). */
257+
export async function checkInsurancePool(
258+
cfg: HealthConfig,
259+
deps: Deps = defaultDeps
260+
): Promise<CheckResult> {
261+
const base: CheckResult = {
262+
name: "insurance_pool",
263+
status: "unknown",
264+
critical: false,
265+
latencyMs: null,
266+
details: { contractId: cfg.insurancePoolId },
267+
error: null,
268+
};
269+
270+
if (!cfg.insurancePoolId) {
271+
return { ...base, status: "unknown", error: "INSURANCE_POOL_ID not configured" };
272+
}
273+
274+
const body = JSON.stringify({
275+
jsonrpc: "2.0",
276+
id: 1,
277+
method: "getLedgerEntries",
278+
params: {
279+
keys: [
280+
// DataKey::Admin is a unit enum variant — its XDR is a 1-element vec
281+
// with symbol "Admin". We request it to confirm the pool is initialised.
282+
Buffer.from(
283+
JSON.stringify({ contractId: cfg.insurancePoolId, key: { type: "symbol", value: "Admin" } })
284+
).toString("base64"),
285+
],
286+
},
287+
});
288+
289+
try {
290+
const { res, latencyMs } = await timedFetch(
291+
deps,
292+
cfg.insurancePoolRpcUrl,
293+
{ method: "POST", headers: { "content-type": "application/json" }, body },
294+
cfg.timeoutMs
295+
);
296+
base.latencyMs = latencyMs;
297+
if (!res.ok) {
298+
return { ...base, status: "fail", error: `RPC returned HTTP ${res.status}` };
299+
}
300+
const json: any = await res.json();
301+
if (json.error) {
302+
return { ...base, status: "fail", error: `RPC error: ${JSON.stringify(json.error)}` };
303+
}
304+
const entries: unknown[] = json.result?.entries ?? [];
305+
const initialized = entries.length > 0;
306+
return {
307+
...base,
308+
status: initialized ? "ok" : "fail",
309+
details: { ...base.details, initialized },
310+
error: initialized ? null : "Insurance pool Admin key not found — pool may not be initialized",
311+
};
312+
} catch (e) {
313+
return { ...base, status: "fail", error: errMsg(e) };
314+
}
315+
}
316+
250317
/** 4. Notification service `/health` endpoint. */
251318
export async function checkNotifications(
252319
cfg: HealthConfig,
@@ -294,14 +361,15 @@ export async function runHealthChecks(
294361
checkContractRpc(cfg, deps),
295362
checkIndexer(cfg, deps),
296363
]);
297-
const [lag, notifications] = await Promise.all([
364+
const [lag, notifications, insurancePool] = await Promise.all([
298365
checkLedgerLag(cfg, indexer.lastIndexedLedger, deps),
299366
checkNotifications(cfg, deps),
367+
checkInsurancePool(cfg, deps),
300368
]);
301369

302370
// Drop the helper-only field before reporting.
303371
const { lastIndexedLedger: _ignored, ...indexerResult } = indexer;
304-
const checks: CheckResult[] = [rpc, indexerResult, lag, notifications];
372+
const checks: CheckResult[] = [rpc, indexerResult, lag, notifications, insurancePool];
305373

306374
const healthy = checks.every((c) => !(c.critical && c.status === "fail"));
307375

sdk/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,4 @@ export {
141141
submitBatchTransaction,
142142
} from "./methods/batch.js";
143143
export type { BatchContractCall, BatchTransactionOptions, BatchTransactionResult } from "./methods/batch.js";
144-
export { setAdmin, upgrade } from "./methods/admin.js";
144+
export { setAdmin, upgrade, setDistributionContract } from "./methods/admin.js";

sdk/src/methods/admin.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "@stellar/stellar-sdk";
1010
import { ILNError } from "../errors.js";
1111
import { retry } from "../utils/retry.js";
12-
import { validateGAddress } from "../utils/validate.js";
12+
import { validateGAddress, validateContractId } from "../utils/validate.js";
1313

1414
/**
1515
* Set a new admin for the ILN contract.
@@ -116,3 +116,58 @@ export async function upgrade(
116116

117117
return { txHash: sendResult.hash };
118118
}
119+
120+
/**
121+
* Set the distribution contract address on the ILN invoice_liquidity contract.
122+
* Admin only — subject to the default rate limit.
123+
*/
124+
export async function setDistributionContract(
125+
server: SorobanRpc.Server,
126+
contractAddress: string,
127+
distributionContract: string,
128+
sourceAccount: Account,
129+
signTransaction: (tx: Transaction) => Promise<Transaction> | Transaction,
130+
networkPassphrase: string
131+
): Promise<{ txHash: string }> {
132+
validateContractId(distributionContract);
133+
134+
const contract = new Contract(contractAddress);
135+
const op = contract.call(
136+
"set_distribution_contract",
137+
nativeToScVal(distributionContract, { type: "address" })
138+
);
139+
140+
const tx = new TransactionBuilder(sourceAccount, {
141+
fee: BASE_FEE,
142+
networkPassphrase,
143+
})
144+
.addOperation(op)
145+
.setTimeout(30)
146+
.build();
147+
148+
const sim = await retry(() => server.simulateTransaction(tx));
149+
if (SorobanRpc.Api.isSimulationError(sim)) {
150+
throw ILNError.fromError(sim.error);
151+
}
152+
153+
const assembledTx = SorobanRpc.assembleTransaction(tx, sim).build();
154+
const signedTx = await signTransaction(assembledTx);
155+
const sendResult = await retry(() => server.sendTransaction(signedTx));
156+
if (sendResult.errorResult) {
157+
throw new Error(`Transaction failed: ${sendResult.errorResult}`);
158+
}
159+
160+
let status = await retry(() => server.getTransaction(sendResult.hash));
161+
let retries = 0;
162+
while (status.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && retries < 15) {
163+
await new Promise(r => setTimeout(r, 2000));
164+
status = await retry(() => server.getTransaction(sendResult.hash));
165+
retries++;
166+
}
167+
168+
if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
169+
throw new Error("Transaction failed during execution");
170+
}
171+
172+
return { txHash: sendResult.hash };
173+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { vi, describe, it, expect, beforeEach } from "vitest";
2+
import { setDistributionContract } from "../src/methods/admin.js";
3+
import { Account, SorobanRpc } from "@stellar/stellar-sdk";
4+
5+
const VALID_CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4";
6+
const MOCK_HASH = "abc123";
7+
8+
describe("setDistributionContract", () => {
9+
const mockServer = {
10+
simulateTransaction: vi.fn(),
11+
sendTransaction: vi.fn(),
12+
getTransaction: vi.fn(),
13+
} as unknown as SorobanRpc.Server;
14+
const mockAccount = new Account(
15+
"GAGZSXAR7P7PASD2PGYISBMEZCMSI35TRJXYZTZNNCAUZRDEMHQM2XJS",
16+
"1"
17+
);
18+
const mockSign = vi.fn((tx) => tx);
19+
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
});
23+
24+
it("throws if distributionContract is not a valid contract ID", async () => {
25+
await expect(
26+
setDistributionContract(
27+
mockServer,
28+
VALID_CONTRACT,
29+
"not-a-valid-contract",
30+
mockAccount,
31+
mockSign,
32+
"passphrase"
33+
)
34+
).rejects.toThrow();
35+
});
36+
37+
it("throws if simulation returns an error", async () => {
38+
mockServer.simulateTransaction = vi.fn().mockResolvedValue({
39+
error: "simulation failed",
40+
});
41+
await expect(
42+
setDistributionContract(
43+
mockServer,
44+
VALID_CONTRACT,
45+
VALID_CONTRACT,
46+
mockAccount,
47+
mockSign,
48+
"passphrase"
49+
)
50+
).rejects.toThrow();
51+
});
52+
53+
it("returns txHash on success", async () => {
54+
mockServer.simulateTransaction = vi.fn().mockResolvedValue({
55+
result: { auth: [], retval: undefined },
56+
transactionData: { build: () => ({}) },
57+
minResourceFee: "100",
58+
});
59+
mockServer.sendTransaction = vi.fn().mockResolvedValue({
60+
hash: MOCK_HASH,
61+
errorResult: undefined,
62+
});
63+
mockServer.getTransaction = vi.fn().mockResolvedValue({
64+
status: SorobanRpc.Api.GetTransactionStatus.SUCCESS,
65+
});
66+
67+
vi.spyOn(SorobanRpc, "assembleTransaction" as never).mockReturnValue({
68+
build: () => ({} as never),
69+
} as never);
70+
71+
const result = await setDistributionContract(
72+
mockServer,
73+
VALID_CONTRACT,
74+
VALID_CONTRACT,
75+
mockAccount,
76+
mockSign,
77+
"passphrase"
78+
);
79+
expect(result.txHash).toBe(MOCK_HASH);
80+
});
81+
});

0 commit comments

Comments
 (0)