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
103 changes: 103 additions & 0 deletions docs/sdk-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 70 additions & 2 deletions scripts/check-contract-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +56,8 @@ export interface HealthConfig {
horizonUrl: string;
indexerUrl: string;
notificationsUrl: string;
insurancePoolRpcUrl: string;
insurancePoolId: string;
ledgerLagThreshold: number;
timeoutMs: number;
}
Expand Down Expand Up @@ -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),
};
Expand Down Expand Up @@ -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<CheckResult> {
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,
Expand Down Expand Up @@ -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"));

Expand Down
2 changes: 1 addition & 1 deletion sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
57 changes: 56 additions & 1 deletion sdk/src/methods/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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> | 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 };
}
81 changes: 81 additions & 0 deletions sdk/tests/setDistributionContract.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading