From 3c0afa4a4d7cd806cda6fbeb9d96805b188dde6e Mon Sep 17 00:00:00 2001 From: brightpixel Date: Sat, 25 Jul 2026 22:15:21 +0100 Subject: [PATCH] feat(contracts): governance benchmarks, insurance pool deploy profile, spec/ABI coverage Closes #522 Closes #523 Closes #524 Closes #525 - Add execution-cost benchmarks for iln_governance (create_proposal, cast_vote, delegate_votes) with a baseline.json, mirroring the existing invoice_liquidity pattern. Generalize scripts/check_benchmark_regression.sh to check multiple contracts (invoice_liquidity + iln_governance) (#522). - Add insurance_pool to scripts/deploy-local.sh's contract list (it was silently missing), add scripts/deploy-insurance-pool.sh, and wire an opt-in "insurance" Docker Compose profile (insurance-pool-deploy service) into docker-compose.yml and docker-compose.test.yml for local/e2e testing without slowing down the default `docker compose up` (#523). - Extend scripts/gen-spec.ts to also emit an insurance_pool spec under a new `contracts` key in docs/contract-spec.json, without touching the existing invoice_liquidity top-level fields (keeps scripts/generate-sdk.ts working unchanged) (#524). - Generalize scripts/gen-abi.ts to loop over both contracts. Along the way, fixed two bugs that were producing an unusable docs/contract-abi.md: the return-type regex broke on multi-word types like `Result<(), ContractError>`, and ContractError wasn't found because it lives in errors.rs, not lib.rs (#525). --- CONTRIBUTING.md | 6 +- .../iln_governance/benchmarks/baseline.json | 7 + contracts/iln_governance/src/lib.rs | 6 + .../iln_governance/src/tests_benchmarks.rs | 162 ++++++++++++++++++ docker-compose.test.yml | 33 ++++ docker-compose.yml | 46 +++++ docs/benchmarks.md | 23 ++- docs/contract-abi.md | 128 +++++++++++++- docs/contract-spec.json | 149 +++++++++++++++- docs/local-development.md | 18 ++ scripts/check_benchmark_regression.sh | 51 ++++-- scripts/deploy-insurance-pool.sh | 106 ++++++++++++ scripts/deploy-local.sh | 2 + scripts/gen-abi.ts | 86 +++++++--- scripts/gen-spec.ts | 103 ++++++++--- 15 files changed, 848 insertions(+), 78 deletions(-) create mode 100644 contracts/iln_governance/benchmarks/baseline.json create mode 100644 contracts/iln_governance/src/tests_benchmarks.rs mode change 100644 => 100755 scripts/check_benchmark_regression.sh create mode 100755 scripts/deploy-insurance-pool.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00bf355f..7767764c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -477,9 +477,9 @@ CD3TE3IAHM737P236XZL2OYU275ZKD6MN7YH7PYYAXYIGEH55OPEWYJC ### Benchmark regression guard The `scripts/check_benchmark_regression.sh` script compares instruction counts -against stored baselines. CI runs it as a warning-only step, but a large -regression in `invoice_liquidity` will be flagged during review. Run it -locally after performance-sensitive changes: +against stored baselines for `invoice_liquidity` and `iln_governance`. CI +runs it as a warning-only step, but a large regression will be flagged during +review. Run it locally after performance-sensitive changes: ```bash bash scripts/check_benchmark_regression.sh diff --git a/contracts/iln_governance/benchmarks/baseline.json b/contracts/iln_governance/benchmarks/baseline.json new file mode 100644 index 00000000..bdda8edb --- /dev/null +++ b/contracts/iln_governance/benchmarks/baseline.json @@ -0,0 +1,7 @@ +{ + "benchmarks": { + "create_proposal": { "cpu": 220773, "mem": 33354 }, + "cast_vote": { "cpu": 252321, "mem": 41057 }, + "delegate_votes": { "cpu": 182897, "mem": 28102 } + } +} diff --git a/contracts/iln_governance/src/lib.rs b/contracts/iln_governance/src/lib.rs index c0469c74..c34522cc 100644 --- a/contracts/iln_governance/src/lib.rs +++ b/contracts/iln_governance/src/lib.rs @@ -8,6 +8,10 @@ //! disable mechanism. #![no_std] + +#[cfg(test)] +extern crate std; + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, token::Client as TokenClient, vec, Address, BytesN, Env, IntoVal, Symbol, Vec, @@ -1172,3 +1176,5 @@ impl GovContract { #[cfg(test)] mod test; +#[cfg(test)] +mod tests_benchmarks; diff --git a/contracts/iln_governance/src/tests_benchmarks.rs b/contracts/iln_governance/src/tests_benchmarks.rs new file mode 100644 index 00000000..ae165f46 --- /dev/null +++ b/contracts/iln_governance/src/tests_benchmarks.rs @@ -0,0 +1,162 @@ +#![cfg(test)] + +//! Execution cost benchmarks for the governance contract (Issue #522). +//! Emits machine-readable `BENCHMARK` lines for CI regression checks, +//! mirroring the pattern established in `invoice_liquidity/tests_benchmarks.rs`. + +use super::*; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, BytesN, Env, +}; + +#[contract] +struct MockIlnBench; + +#[contractimpl] +impl MockIlnBench { + pub fn update_fee_rate(_env: Env, _rate: u32) {} + pub fn add_token(_env: Env, _token: Address) {} + pub fn remove_token(_env: Env, _token: Address) {} + pub fn update_max_discount(_env: Env, _rate: u32) {} +} + +struct BaseBenchEnv { + env: Env, + contract: GovContractClient<'static>, + proposer: Address, + voter: Address, +} + +fn setup_benchmark_env() -> BaseBenchEnv { + let env = Env::default(); + env.mock_all_auths(); + env.budget().reset_unlimited(); + + let mut ledger = env.ledger().get(); + ledger.timestamp = 1_700_000_000; + env.ledger().set(ledger); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let token_addr = token_id.address(); + let token_admin_client = StellarAssetClient::new(&env, &token_addr); + + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + token_admin_client.mint(&proposer, &1_000_000); + token_admin_client.mint(&voter, &1_000_000); + + let iln_contract = env.register_contract(None, MockIlnBench); + let admin = Address::generate(&env); + + let contract_id = env.register_contract(None, GovContract); + let contract = GovContractClient::new(&env, &contract_id); + contract.initialize(&iln_contract, &token_addr, &admin); + + BaseBenchEnv { + env, + contract, + proposer, + voter, + } +} + +fn emit_benchmark(name: &str, cpu: u64, mem: u64) { + std::println!("BENCHMARK {name} cpu={cpu} mem={mem}"); +} + +fn measure(env: &Env, name: &str, action: F) -> (u64, u64) { + env.budget().reset_unlimited(); + action(); + let cpu = env.budget().cpu_instruction_cost(); + let mem = env.budget().memory_bytes_cost(); + emit_benchmark(name, cpu, mem); + (cpu, mem) +} + +#[test] +fn benchmark_create_proposal() { + let bench = setup_benchmark_env(); + let hash = BytesN::from_array(&bench.env, &[7u8; 32]); + + measure(&bench.env, "create_proposal", || { + bench.contract.create_proposal( + &bench.proposer, + &ProposalAction::UpdateFeeRate(500), + &hash, + &500, + ); + }); +} + +#[test] +fn benchmark_cast_vote() { + let bench = setup_benchmark_env(); + let hash = BytesN::from_array(&bench.env, &[7u8; 32]); + let id = bench.contract.create_proposal( + &bench.proposer, + &ProposalAction::UpdateFeeRate(500), + &hash, + &500, + ); + + measure(&bench.env, "cast_vote", || { + bench.contract.cast_vote(&bench.voter, &id, &true); + }); +} + +#[test] +fn benchmark_delegate_votes() { + let bench = setup_benchmark_env(); + + measure(&bench.env, "delegate_votes", || { + bench.contract.delegate_votes(&bench.voter, &bench.proposer); + }); +} + +#[test] +fn benchmark_all_functions_summary() { + // Uses "_summary"-suffixed BENCHMARK names so these lines never collide + // with the isolated per-function benchmarks above when CI's regression + // script parses combined test output (tests run concurrently, so line + // order between this test and the isolated ones is not guaranteed). + let mut results = std::vec::Vec::new(); + + let bench = setup_benchmark_env(); + let hash = BytesN::from_array(&bench.env, &[7u8; 32]); + + results.push(measure(&bench.env, "create_proposal_summary", || { + bench.contract.create_proposal( + &bench.proposer, + &ProposalAction::UpdateFeeRate(500), + &hash, + &500, + ); + })); + + let id = bench.contract.create_proposal( + &bench.proposer, + &ProposalAction::UpdateFeeRate(500), + &hash, + &500, + ); + results.push(measure(&bench.env, "cast_vote_summary", || { + bench.contract.cast_vote(&bench.voter, &id, &true); + })); + results.push(measure(&bench.env, "delegate_votes_summary", || { + bench.contract.delegate_votes(&bench.voter, &bench.proposer); + })); + + std::println!("\n| Function | CPU Instructions | Memory (bytes) |"); + std::println!("| --------------- | ----------------- | -------------- |"); + for (name, (cpu, mem)) in [ + ("create_proposal", results[0]), + ("cast_vote", results[1]), + ("delegate_votes", results[2]), + ] { + std::println!("| {name:<15} | {cpu:>17} | {mem:>14} |"); + } +} diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 61820624..f834744e 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -20,6 +20,39 @@ services: volumes: - stellar-e2e-data:/opt/stellar + # Opt-in: builds and deploys insurance_pool against the e2e Stellar node + # for tests that exercise it. See docker-compose.yml for the local-dev + # equivalent and scripts/deploy-insurance-pool.sh for configuration. + # + # Usage: docker compose -f docker-compose.test.yml --profile insurance up insurance-pool-deploy + insurance-pool-deploy: + image: rust:1-slim + container_name: iln-insurance-pool-deploy-e2e + profiles: ["insurance"] + depends_on: + stellar: + condition: service_healthy + working_dir: /workspace + volumes: + - .:/workspace + - insurance-pool-cargo-cache-e2e:/usr/local/cargo/registry + environment: + - INSURANCE_POOL_ADMIN=${INSURANCE_POOL_ADMIN:-} + - INSURANCE_POOL_COVERAGE=${INSURANCE_POOL_COVERAGE:-1000000000} + command: > + bash -c " + set -e && + rustup target add wasm32v1-none && + command -v stellar >/dev/null 2>&1 || cargo install --locked stellar-cli && + stellar network add --global local --rpc-url http://stellar:8000 --network-passphrase 'Standalone Network ; February 2021' --override || true && + stellar keys generate --global alice || true && + stellar account fund alice --network local || true && + bash scripts/deploy-insurance-pool.sh local alice + " + restart: "no" + volumes: stellar-e2e-data: driver: local + insurance-pool-cargo-cache-e2e: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 1d019091..e7ac100d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,10 @@ # Usage: docker compose up -d stellar # Check health: docker compose logs -f stellar | grep "ready" # Stop: docker compose down +# +# Insurance pool testing (opt-in "insurance" profile — builds and deploys +# the insurance_pool contract against the local Stellar node): +# docker compose --profile insurance up insurance-pool-deploy version: '3.8' @@ -59,6 +63,9 @@ services: - PORT=3000 - HORIZON_URL=http://stellar:8000 - DB_PATH=/app/data/indexer.db + # Contract ID from `insurance-pool-deploy` (see the "insurance" profile + # below) or scripts/deploy-insurance-pool.sh. Empty until deployed. + - INSURANCE_POOL_CONTRACT_ID=${INSURANCE_POOL_CONTRACT_ID:-} ports: - "3000:3000" volumes: @@ -83,6 +90,9 @@ services: environment: - PORT=3001 - DATABASE_URL=postgres://notifications:notifications@notifications-db:5432/notifications + # Contract ID from `insurance-pool-deploy` (see the "insurance" profile + # below) or scripts/deploy-insurance-pool.sh. Empty until deployed. + - INSURANCE_POOL_CONTRACT_ID=${INSURANCE_POOL_CONTRACT_ID:-} ports: - "3001:3001" healthcheck: @@ -93,6 +103,40 @@ services: start_period: 10s restart: unless-stopped + # Builds and deploys the insurance_pool contract to the local Stellar node, + # then calls initialize(). Opt-in only — it is not part of the default + # `docker compose up` since it installs the Stellar CLI via `cargo install` + # on first run, which can take several minutes. + # + # Usage: docker compose --profile insurance up insurance-pool-deploy + insurance-pool-deploy: + image: rust:1-slim + container_name: iln-insurance-pool-deploy + profiles: ["insurance"] + depends_on: + stellar: + condition: service_healthy + working_dir: /workspace + volumes: + - .:/workspace + - insurance-pool-cargo-cache:/usr/local/cargo/registry + environment: + # Passed through to insurance_pool's initialize(admin, coverage). + # See scripts/deploy-insurance-pool.sh for defaults. + - INSURANCE_POOL_ADMIN=${INSURANCE_POOL_ADMIN:-} + - INSURANCE_POOL_COVERAGE=${INSURANCE_POOL_COVERAGE:-1000000000} + command: > + bash -c " + set -e && + rustup target add wasm32v1-none && + command -v stellar >/dev/null 2>&1 || cargo install --locked stellar-cli && + stellar network add --global local --rpc-url http://stellar:8000 --network-passphrase 'Standalone Network ; February 2021' --override || true && + stellar keys generate --global alice || true && + stellar account fund alice --network local || true && + bash scripts/deploy-insurance-pool.sh local alice + " + restart: "no" + volumes: stellar-data: driver: local @@ -100,6 +144,8 @@ volumes: driver: local notifications-db-data: driver: local + insurance-pool-cargo-cache: + driver: local networks: default: diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 7cf2b498..e98d4357 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1,21 +1,31 @@ # Smart Contract Benchmarks -*Date:* 2026-05-30 +*Date:* 2026-05-30 (invoice_liquidity), 2026-07-25 (iln_governance) -These values are baseline metrics for core contract execution: CPU instructions and memory bytes consumed via Soroban's cost meter (`env.cost_estimate()`). CI compares each run against `contracts/invoice_liquidity/benchmarks/baseline.json` and emits a **warning** (not a failure) when either metric regresses by more than 10%. +These values are baseline metrics for core contract execution: CPU instructions and memory bytes consumed via Soroban's cost meter (`env.budget()`). CI compares each run against the contract's `benchmarks/baseline.json` and emits a **warning** (not a failure) when either metric regresses by more than 10%. ## Baseline Execution Results +### `contracts/invoice_liquidity` + | Function | CPU Instructions | Memory (bytes) | | -------------- | ---------------- | -------------- | | submit_invoice | 859421 | 26485 | | fund_invoice | 1041920 | 38190 | | mark_paid | 948123 | 35480 | +### `contracts/iln_governance` + +| Function | CPU Instructions | Memory (bytes) | +| ---------------- | ---------------- | -------------- | +| create_proposal | 220773 | 33354 | +| cast_vote | 252321 | 41057 | +| delegate_votes | 182897 | 28102 | + ## Re-Running Locally ```bash -cd contracts/invoice_liquidity +cd contracts/invoice_liquidity # or contracts/iln_governance cargo test --target x86_64-unknown-linux-gnu benchmark -- --nocapture ``` @@ -28,15 +38,16 @@ BENCHMARK submit_invoice cpu=859421 mem=26485 ## CI Regression Check ```bash -bash scripts/check_benchmark_regression.sh +bash scripts/check_benchmark_regression.sh # checks every contract listed below +bash scripts/check_benchmark_regression.sh contracts/iln_governance:contracts/iln_governance/benchmarks/baseline.json # checks one ``` -The script always exits 0. Regressions above the threshold are reported as `::warning::` annotations in GitHub Actions. +The script always exits 0. Regressions above the threshold are reported as `::warning::` annotations in GitHub Actions. It checks `contracts/invoice_liquidity` and `contracts/iln_governance` by default. ## Updating Baselines After an intentional optimisation or contract change: 1. Run the benchmark suite locally with `--nocapture`. -2. Update `contracts/invoice_liquidity/benchmarks/baseline.json`. +2. Update `contracts//benchmarks/baseline.json`. 3. Update the table in this document. diff --git a/docs/contract-abi.md b/docs/contract-abi.md index 64bc07d2..435e0255 100644 --- a/docs/contract-abi.md +++ b/docs/contract-abi.md @@ -1,14 +1,132 @@ # Contract ABI Documentation -## Functions +## InvoiceLiquidityContract + +### Functions | Function | Parameters | Returns | Description | |----------|------------|---------|-------------| -| initialize | env: Env, token: Address | Result<(), | No description | -| payer_score | env: Env, payer: Address | u32 | No description | -| suggested_discount_rate | env: Env, payer: Address | u32 | No description | +| initialize | env: Env, admin: Address, usdc_token: Address, eurc_token: Address, xlm_token: Address, | Result<(), ContractError> | Access: Anyone | +| get_version | env: Env | soroban_sdk::String | Access: Anyone | +| set_admin | env: Env, new_admin: Address | Result<(), ContractError> | Access: Admin only | +| update_fee_rate | env: Env, rate: u32 | Result<(), ContractError> | Access: Admin only | +| update_max_discount | env: Env, rate: u32 | Result<(), ContractError> | Access: Admin only | +| set_distribution_contract | env: Env, distribution_contract: Address, | Result<(), ContractError> | Access: Admin only | +| set_price_oracle | env: Env, oracle: Address | Result<(), ContractError> | Access: Admin only | +| get_price_oracle | env: Env | Option
| Access: Anyone | +| set_max_oracle_age | env: Env, max_age_ledgers: u64 | Result<(), ContractError> | Access: Admin only | +| get_max_oracle_age | env: Env | u64 | Access: Anyone | +| add_token | env: Env, token: Address, decimals: u32 | Result<(), ContractError> | Access: Admin only | +| remove_token | env: Env, token: Address | Result<(), ContractError> | Access: Admin only | +| get_token_decimals | env: Env, token: Address | Option | /// Access: Anyone | +| pause | env: Env | Result<(), ContractError> | Access: Admin only | +| unpause | env: Env | Result<(), ContractError> | Access: Admin only | +| upgrade | env: Env, new_wasm_hash: BytesN<32> | Result<(), ContractError> | /// Access: Admin only | +| get_contract_stats | env: Env | ContractStats | Access: Anyone | +| list_invoices_by_submitter | env: Env, submitter: Address, page: u32, page_size: u32, | Vec | Access: Anyone | +| list_invoices_by_lp | env: Env, lp: Address, page: u32, page_size: u32 | Vec | Access: Anyone | +| submit_invoice | env: Env, freelancer: Address, payer: Address, amount: i128, due_date: u64, discount_rate: u32, token: Address, referral_code: ReferralCode, | Result | Access: Submitter only | +| update_invoice | env: Env, freelancer: Address, invoice_id: u64, amount: i128, due_date: u64, discount_rate: u32, | Result<(), ContractError> | Access: Submitter only | +| convert_invoice_token | env: Env, freelancer: Address, invoice_id: u64, new_token: Address, | Result<(), ContractError> | Access: Submitter only | +| submit_invoices_batch | env: Env, invoices: Vec, | Result, ContractError> | Access: Submitter only | +| get_referral_stats | env: Env, code: BytesN<32> | u64 | Access: Anyone | +| join_fund_queue | env: Env, lp: Address, invoice_id: u64 | Result<(), ContractError> | Access: LP only | +| resolve_fund_queue | env: Env, invoice_id: u64 | Result | Access: Anyone | +| fund_invoice | env: Env, funder: Address, invoice_id: u64, fund_amount: i128, require_oracle_verification: bool, | Result<(), ContractError> | consulted and the existing behaviour is preserved. | +| transfer_invoice | env: Env, invoice_id: u64, new_freelancer: Address, | Result<(), ContractError> | Access: Submitter only | +| transfer_lp_position | env: Env, invoice_id: u64, new_lp: Address, | Result<(), ContractError> | Access: Current LP only | +| cancel_invoice | env: Env, invoice_id: u64 | Result<(), ContractError> | Access: Submitter only | +| expire_invoice | env: Env, invoice_id: u64 | Result<(), ContractError> | Access: Anyone | +| mark_paid | env: Env, invoice_id: u64, amount: i128 | Result<(), ContractError> | Access: Payer only | +| claim_yield | env: Env, invoice_id: u64 | Result | Access: LP only | +| claim_default | env: Env, funder: Address, invoice_id: u64 | Result<(), ContractError> | Access: LP only | +| appeal_default | env: Env, invoice_id: u64, evidence_hash: BytesN<32>, | Result<(), ContractError> | Access: Payer only | +| resolve_appeal | env: Env, invoice_id: u64, upheld: bool | Result<(), ContractError> | Access: Admin only | +| dispute_invoice | env: Env, invoice_id: u64, reason_hash: BytesN<32>, | Result<(), ContractError> | Access: Payer only | +| resolve_dispute | env: Env, invoice_id: u64, resolution_hash: BytesN<32>, resolution: u32, | Result<(), ContractError> | Access: Admin only | +| auto_resolve_dispute | env: Env, invoice_id: u64 | Result<(), ContractError> | Access: Anyone | +| update_config | env: Env, caller: Address, high_rep_threshold: u32, bonus_bps: u32, min_discount_rate_bps: u32, decay_rate_bps: u32, decay_period_ledgers: u64, dispute_timeout_ledgers: u64, xlm_sac_address: Address, usdc_sac_address: Address, eurc_sac_address: Address, | Result<(), ContractError> | No description | +| get_config | env: Env | Result | No description | +| payer_score | env: Env, payer: Address | u32 | Access: Anyone | +| lp_score | env: Env, lp: Address | u32 | Access: Anyone | +| get_top_payers | env: Env, limit: u32 | Vec | Access: Anyone | +| get_reputation | env: Env, address: Address | ReputationProfile | Access: Anyone | +| min_payer_reputation | env: Env | u32 | Access: Anyone | +| set_min_payer_reputation | env: Env, value: u32 | Result<(), ContractError> | Access: Admin only | +| suggested_discount_rate | env: Env, payer: Address | u32 | Access: Anyone | +| get_invoice | env: Env, invoice_id: u64 | Result | Access: Anyone | +| get_invoice_count | env: Env | u64 | Access: Anyone | +| query_nft_metadata | env: Env, invoice_id: u64 | Option | Anyone | +| query_nft_owner | env: Env, invoice_id: u64 | Option
| Anyone | + +### Contract Errors + +- InvoiceNotFound = 1 +- AlreadyFunded = 2 +- AlreadyPaid = 3 +- NotFunded = 4 +- Unauthorized = 5 +- InvalidAmount = 6 +- InvalidDiscountRate = 7 +- InvalidDueDate = 8 +- InvoiceDefaulted = 9 +- NothingToClaim = 10 +- NotYetDefaulted = 11 +- OverfundingRejected = 12 +- InvoiceExpired = 13 +- BatchTooLarge = 14 +- AlreadyCancelled = 15 +- AlreadyInitialized = 16 +- AlreadyAppealed = 17 +- AppealWindowClosed = 18 +- NotDefaulted = 19 +- AlreadyInQueue = 20 +- NotApprovedFunder = 21 +- InvoiceAppealed = 22 +- AlreadyDisputed = 23 +- NotDisputed = 24 +- InvoiceDisputed = 25 +- ContractPaused = 26 +- DueDateTooSoon = 27 +- DueDateTooFar = 28 +- SelfInvoice = 29 +- OverpaymentRejected = 30 +- PayerReputationTooLow = 31 +- ArithmeticOverflow = 32 +- FeeOnTransferToken = 33 +- PayerUnverified = 34 +- OracleDataStale = 35 +- AmountTooSmall = 36 --- -## Contract Errors +## InsurancePool + +### Functions + +| Function | Parameters | Returns | Description | +|----------|------------|---------|-------------| +| initialize | env: Env, admin: Address, coverage: i128 | Result<(), InsuranceError> | * `coverage` — flat per-claim compensation cap (in token stroops). | +| get_premiums_paid | env: Env, lp: Address | i128 | Total premium an LP has contributed over the pool's lifetime. | +| get_coverage | env: Env | i128 | The configured flat per-claim coverage cap. | +| is_claimed | env: Env, invoice_id: u64 | bool | Returns `true` if a claim has already been processed for `invoice_id`. | +| propose_coverage_change | env: Env, new_coverage: i128 | Result | any previously pending coverage proposal. | +| execute_coverage_change | env: Env | Result<(), InsuranceError> | expired. Callable by anyone once the delay has elapsed. | +| cancel_coverage_change | env: Env | Result<(), InsuranceError> | Cancel a pending coverage change proposal. Requires current admin auth. | +| propose_admin_transfer | env: Env, new_admin: Address | Result | previously pending admin proposal. | +| execute_admin_transfer | env: Env | Result<(), InsuranceError> | expired. Callable by anyone once the delay has elapsed. | +| cancel_admin_transfer | env: Env | Result<(), InsuranceError> | Cancel a pending admin transfer proposal. Requires current admin auth. | +| get_pending_coverage | env: Env | Option<(i128, u64)> | Returns the pending coverage proposal (new cap, eta), if any. | +| get_pending_admin | env: Env | Option<(Address, u64)> | Returns the pending admin transfer proposal (new admin, eta), if any. | + +### Contract Errors +- NotInitialized = 1 +- AlreadyClaimed = 2 +- InvalidAmount = 3 +- PoolEmpty = 4 +- AlreadyInitialized = 5 +- NoPendingProposal = 6 +- TimelockNotExpired = 7 + +--- diff --git a/docs/contract-spec.json b/docs/contract-spec.json index ec6a04b4..91ae8666 100644 --- a/docs/contract-spec.json +++ b/docs/contract-spec.json @@ -2,8 +2,6 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "contract": "InvoiceLiquidityContract", "source": "contracts/invoice_liquidity/src/lib.rs", - "generator": "scripts/gen-spec.ts", - "note": "Source-derived ABI spec. The canonical embedded spec is produced by `stellar contract inspect --wasm --output json` against the built WASM.", "functionCount": 60, "errorCount": 50, "eventCount": 28, @@ -1813,5 +1811,150 @@ } ] } - ] + ], + "generator": "scripts/gen-spec.ts", + "note": "Source-derived ABI spec. The canonical embedded spec is produced by `stellar contract inspect --wasm --output json` against the built WASM. Top-level fields describe InvoiceLiquidityContract; see `contracts` for the rest.", + "contracts": { + "insurance_pool": { + "contract": "InsurancePool", + "source": "contracts/insurance_pool/src/lib.rs", + "functionCount": 12, + "errorCount": 7, + "eventCount": 0, + "functions": [ + { + "name": "cancel_admin_transfer", + "doc": "Cancel a pending admin transfer proposal. Requires current admin auth.", + "parameters": [], + "returns": "Result<(), InsuranceError>" + }, + { + "name": "cancel_coverage_change", + "doc": "Cancel a pending coverage change proposal. Requires current admin auth.", + "parameters": [], + "returns": "Result<(), InsuranceError>" + }, + { + "name": "execute_admin_transfer", + "doc": "Execute a previously proposed admin transfer once its timelock has expired. Callable by anyone once the delay has elapsed.", + "parameters": [], + "returns": "Result<(), InsuranceError>" + }, + { + "name": "execute_coverage_change", + "doc": "Execute a previously proposed coverage change once its timelock has expired. Callable by anyone once the delay has elapsed.", + "parameters": [], + "returns": "Result<(), InsuranceError>" + }, + { + "name": "get_coverage", + "doc": "The configured flat per-claim coverage cap.", + "parameters": [], + "returns": "i128" + }, + { + "name": "get_pending_admin", + "doc": "Returns the pending admin transfer proposal (new admin, eta), if any.", + "parameters": [], + "returns": "Option<(Address, u64)>" + }, + { + "name": "get_pending_coverage", + "doc": "Returns the pending coverage proposal (new cap, eta), if any.", + "parameters": [], + "returns": "Option<(i128, u64)>" + }, + { + "name": "get_premiums_paid", + "doc": "Total premium an LP has contributed over the pool's lifetime.", + "parameters": [ + { + "name": "lp", + "type": "Address" + } + ], + "returns": "i128" + }, + { + "name": "initialize", + "doc": "Initialise the pool. * `admin` — authorised to file claims (in production, the liquidity contract address acting on a confirmed default). * `coverage` — flat per-claim compensation cap (in token stroops).", + "parameters": [ + { + "name": "admin", + "type": "Address" + }, + { + "name": "coverage", + "type": "i128" + } + ], + "returns": "Result<(), InsuranceError>" + }, + { + "name": "is_claimed", + "doc": "Returns `true` if a claim has already been processed for `invoice_id`.", + "parameters": [ + { + "name": "invoice_id", + "type": "u64" + } + ], + "returns": "bool" + }, + { + "name": "propose_admin_transfer", + "doc": "Propose an admin transfer. Requires current admin auth. Overwrites any previously pending admin proposal.", + "parameters": [ + { + "name": "new_admin", + "type": "Address" + } + ], + "returns": "Result" + }, + { + "name": "propose_coverage_change", + "doc": "Propose a new coverage cap. Requires current admin auth. Overwrites any previously pending coverage proposal.", + "parameters": [ + { + "name": "new_coverage", + "type": "i128" + } + ], + "returns": "Result" + } + ], + "errors": [ + { + "name": "NotInitialized", + "code": 1 + }, + { + "name": "AlreadyClaimed", + "code": 2 + }, + { + "name": "InvalidAmount", + "code": 3 + }, + { + "name": "PoolEmpty", + "code": 4 + }, + { + "name": "AlreadyInitialized", + "code": 5 + }, + { + "name": "NoPendingProposal", + "code": 6 + }, + { + "name": "TimelockNotExpired", + "code": 7 + } + ], + "events": [] + } + } } diff --git a/docs/local-development.md b/docs/local-development.md index 66dcb2d8..0ac225bb 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -76,6 +76,10 @@ source .contracts-local.env | `ILN_GOVERNANCE_ID` | Local deploy output | none | Deployed governance contract ID. | | `ILN_DISTRIBUTION_ID` | Local deploy output | none | Deployed distribution contract ID. | | `REPUTATION_BONUS_ID` | Local deploy output | none | Deployed reputation bonus contract ID. | +| `INSURANCE_POOL_ID` | Local deploy output | none | Deployed `insurance_pool` contract ID. Created by `scripts/deploy-insurance-pool.sh` (also runnable via `docker compose --profile insurance up insurance-pool-deploy`). | +| `INSURANCE_POOL_ADMIN` | `scripts/deploy-insurance-pool.sh` | deploying account's own address | Address passed to `insurance_pool`'s `initialize(admin, coverage)`. | +| `INSURANCE_POOL_COVERAGE` | `scripts/deploy-insurance-pool.sh` | `1000000000` | Flat per-claim compensation cap (token stroops) passed to `initialize`. | +| `INSURANCE_POOL_CONTRACT_ID` | Docker Compose indexer/notifications containers | none | Deployed `insurance_pool` contract ID, made available to local services once deployed. Set to `$INSURANCE_POOL_ID` after running the deploy script. | | `NETWORK` | Local deploy output | `local` | Stellar CLI network name used during deployment. | | `SOURCE` | Local deploy output | `alice` | Stellar CLI key name used to deploy local contracts. | | `SOROBAN_RPC_URL` | `scripts/smoke-test.ts`, migration scripts | `https://soroban-testnet.stellar.org` | RPC endpoint for smoke tests or migration helpers. Use `http://localhost:8000` for local quickstart. | @@ -230,6 +234,20 @@ NETWORK_PASSPHRASE="Standalone Network ; February 2021" \ npx --yes tsx scripts/smoke-test.ts ``` +`deploy-local.sh` deploys every contract, including `insurance_pool`. To deploy +just `insurance_pool` on its own (e.g. after changing only that contract), run +`./scripts/deploy-insurance-pool.sh local alice` directly, or use the opt-in +Docker Compose profile, which installs the Stellar CLI and runs the same +script inside a container: + +```bash +docker compose --profile insurance up insurance-pool-deploy +``` + +This is not part of the default `docker compose up` — it's opt-in because it +installs the Stellar CLI via `cargo install` on first run, which can take +several minutes. + ## Run Services Individually ### SDK diff --git a/scripts/check_benchmark_regression.sh b/scripts/check_benchmark_regression.sh old mode 100644 new mode 100755 index 14a92621..bf24647a --- a/scripts/check_benchmark_regression.sh +++ b/scripts/check_benchmark_regression.sh @@ -1,30 +1,51 @@ #!/usr/bin/env bash # Compare benchmark output against baseline and warn on >10% regression. # Exits 0 always (CI warning-only); prints ::warning:: lines for GitHub Actions. +# +# Usage: +# bash scripts/check_benchmark_regression.sh # check all contracts below +# bash scripts/check_benchmark_regression.sh : # check one set -euo pipefail -BASELINE_FILE="${1:-docs/benchmarks.json}" REGRESSION_THRESHOLD="${BENCHMARK_REGRESSION_THRESHOLD:-10}" ROOT="$(cd "$(dirname "$0")/.." && pwd)" -if [[ ! -f "$ROOT/$BASELINE_FILE" ]]; then - echo "::warning::Baseline file not found: $BASELINE_FILE" - exit 0 +# ":" pairs to check when no argument is given. +DEFAULT_TARGETS=( + "contracts/invoice_liquidity:contracts/invoice_liquidity/benchmarks/baseline.json" + "contracts/iln_governance:contracts/iln_governance/benchmarks/baseline.json" +) + +if [[ $# -gt 0 ]]; then + TARGETS=("$1") +else + TARGETS=("${DEFAULT_TARGETS[@]}") fi -echo "Running benchmark tests..." -BENCHMARK_OUTPUT="$(cd "$ROOT/contracts/invoice_liquidity" && cargo test --target x86_64-unknown-linux-gnu benchmark -- --nocapture 2>&1)" || true +for target in "${TARGETS[@]}"; do + CONTRACT_DIR="${target%%:*}" + BASELINE_FILE="${target##*:}" + + if [[ ! -f "$ROOT/$BASELINE_FILE" ]]; then + echo "::warning::Baseline file not found: $BASELINE_FILE" + continue + fi + + echo "Running benchmark tests for $CONTRACT_DIR..." + BENCHMARK_OUTPUT="$(cd "$ROOT/$CONTRACT_DIR" && cargo test --target x86_64-unknown-linux-gnu benchmark -- --nocapture 2>&1)" || true -export BENCHMARK_OUTPUT -export BASELINE_FILE="$ROOT/$BASELINE_FILE" -export REGRESSION_THRESHOLD + export BENCHMARK_OUTPUT + export BASELINE_FILE="$ROOT/$BASELINE_FILE" + export REGRESSION_THRESHOLD + export CONTRACT_DIR -python3 <<'PY' + python3 <<'PY' import json import os import re +contract_dir = os.environ["CONTRACT_DIR"] baseline_path = os.environ["BASELINE_FILE"] threshold_pct = float(os.environ["REGRESSION_THRESHOLD"]) output = os.environ.get("BENCHMARK_OUTPUT", "") @@ -36,16 +57,17 @@ pattern = re.compile(r"BENCHMARK\s+(\w+)\s+cpu=(\d+)\s+mem=(\d+)") measured = {m.group(1): {"cpu": int(m.group(2)), "mem": int(m.group(3))} for m in pattern.finditer(output)} if not measured: - print("::warning::No BENCHMARK lines found in test output") + print(f"::warning::No BENCHMARK lines found in test output for {contract_dir}") raise SystemExit(0) +print(f"\n### {contract_dir}") print("| Function | CPU (measured) | CPU (baseline) | Mem (measured) | Mem (baseline) |") print("| -------- | -------------- | -------------- | -------------- | -------------- |") for name, base in baseline.items(): current = measured.get(name) if not current: - print(f"::warning::Missing benchmark measurement for {name}") + print(f"::warning::Missing benchmark measurement for {contract_dir}::{name}") continue cpu_base, mem_base = base["cpu"], base["mem"] @@ -57,12 +79,13 @@ for name, base in baseline.items(): if cpu_pct > threshold_pct: print( - f"::warning::Benchmark regression: {name} CPU instructions " + f"::warning::Benchmark regression: {contract_dir}::{name} CPU instructions " f"increased {cpu_pct:.1f}% ({cpu_cur} vs baseline {cpu_base})" ) if mem_pct > threshold_pct: print( - f"::warning::Benchmark regression: {name} memory bytes " + f"::warning::Benchmark regression: {contract_dir}::{name} memory bytes " f"increased {mem_pct:.1f}% ({mem_cur} vs baseline {mem_base})" ) PY +done diff --git a/scripts/deploy-insurance-pool.sh b/scripts/deploy-insurance-pool.sh new file mode 100755 index 00000000..a8c9a19c --- /dev/null +++ b/scripts/deploy-insurance-pool.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Deploy and initialise the insurance_pool contract to a local Stellar network. +# +# Prerequisites: +# - Local Stellar node running (docker compose up -d stellar) +# - Stellar CLI configured for 'local' network +# - Test account funded (./scripts/setup-local-env.sh) +# +# Usage: ./scripts/deploy-insurance-pool.sh [network] [source] +# network: local (default) or testnet +# source: alice (default) or other account name +# +# Configuration (env vars): +# INSURANCE_POOL_ADMIN Address authorised to file claims. +# Defaults to the deploying account's own address. +# INSURANCE_POOL_COVERAGE Flat per-claim compensation cap, in token stroops. +# Defaults to 1000000000 (100 XLM at 7 decimals). + +set -euo pipefail + +NETWORK="${1:-local}" +SOURCE="${2:-alice}" + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +echo "=== Deploying insurance_pool to $NETWORK ===" +echo "Source account: $SOURCE" +echo "" + +if ! stellar network ls | grep -q "^$NETWORK"; then + echo -e "${RED}❌ Network '$NETWORK' not configured${NC}" + echo "Configure it with: stellar network add --global $NETWORK --rpc-url " + exit 1 +fi + +if ! stellar keys address "$SOURCE" &> /dev/null; then + echo -e "${RED}❌ Account '$SOURCE' not found${NC}" + echo "Create it with: stellar keys generate --global $SOURCE" + exit 1 +fi + +SOURCE_ADDRESS="$(stellar keys address "$SOURCE")" +ADMIN="${INSURANCE_POOL_ADMIN:-$SOURCE_ADDRESS}" +COVERAGE="${INSURANCE_POOL_COVERAGE:-1000000000}" + +echo "Building insurance_pool..." +cargo build --target wasm32v1-none --release --quiet -p insurance_pool + +WASM_PATH="target/wasm32v1-none/release/insurance_pool.wasm" +if [[ ! -f "$WASM_PATH" ]]; then + echo -e "${RED}❌ WASM not found: $WASM_PATH${NC}" + exit 1 +fi + +echo "Uploading WASM..." +UPLOAD_OUTPUT=$(stellar contract upload \ + --network "$NETWORK" \ + --source "$SOURCE" \ + --wasm "$WASM_PATH" 2>&1) + +WASM_HASH=$(echo "$UPLOAD_OUTPUT" | grep -oP 'WASM hash: \K[a-f0-9]+' || true) +if [[ -z "$WASM_HASH" ]]; then + echo -e "${RED}❌ Failed to upload WASM${NC}" + echo "$UPLOAD_OUTPUT" + exit 1 +fi +echo " WASM hash: $WASM_HASH" + +echo "Deploying contract..." +DEPLOY_OUTPUT=$(stellar contract deploy \ + --network "$NETWORK" \ + --source "$SOURCE" \ + --wasm-hash "$WASM_HASH" 2>&1) + +CONTRACT_ID=$(echo "$DEPLOY_OUTPUT" | grep -oP 'Contract ID: \K[A-Z0-9]+' || true) +if [[ -z "$CONTRACT_ID" ]]; then + echo -e "${RED}❌ Failed to deploy contract${NC}" + echo "$DEPLOY_OUTPUT" + exit 1 +fi +echo -e " ${GREEN}✓${NC} Deployed: $CONTRACT_ID" + +echo "Initialising (admin=$ADMIN, coverage=$COVERAGE)..." +stellar contract invoke \ + --network "$NETWORK" \ + --source "$SOURCE" \ + --id "$CONTRACT_ID" \ + -- initialize \ + --admin "$ADMIN" \ + --coverage "$COVERAGE" + +ENV_FILE=".contracts-${NETWORK}.env" +if [[ -f "$ENV_FILE" ]] && grep -q '^INSURANCE_POOL_ID=' "$ENV_FILE"; then + sed -i.bak "s/^INSURANCE_POOL_ID=.*/INSURANCE_POOL_ID=$CONTRACT_ID/" "$ENV_FILE" && rm -f "$ENV_FILE.bak" +else + { + echo "INSURANCE_POOL_ID=$CONTRACT_ID" + echo "INSURANCE_POOL_ADMIN=$ADMIN" + } >> "$ENV_FILE" +fi + +echo "" +echo -e "${GREEN}✅ insurance_pool deployed!${NC}" +echo "Contract ID saved to: $ENV_FILE" diff --git a/scripts/deploy-local.sh b/scripts/deploy-local.sh index 69276c90..10abaf6d 100644 --- a/scripts/deploy-local.sh +++ b/scripts/deploy-local.sh @@ -49,6 +49,7 @@ declare -A CONTRACTS=( ["iln_governance"]="target/wasm32v1-none/release/iln_governance.wasm" ["iln_distribution"]="target/wasm32v1-none/release/iln_distribution.wasm" ["reputation_bonus"]="target/wasm32v1-none/release/reputation_bonus.wasm" + ["insurance_pool"]="target/wasm32v1-none/release/insurance_pool.wasm" ) declare -A CONTRACT_IDS @@ -119,6 +120,7 @@ INVOICE_LIQUIDITY_ID=${CONTRACT_IDS[invoice_liquidity]:-} ILN_GOVERNANCE_ID=${CONTRACT_IDS[iln_governance]:-} ILN_DISTRIBUTION_ID=${CONTRACT_IDS[iln_distribution]:-} REPUTATION_BONUS_ID=${CONTRACT_IDS[reputation_bonus]:-} +INSURANCE_POOL_ID=${CONTRACT_IDS[insurance_pool]:-} NETWORK=$NETWORK SOURCE=$SOURCE EOF diff --git a/scripts/gen-abi.ts b/scripts/gen-abi.ts index 1f4e5c85..db9aa664 100644 --- a/scripts/gen-abi.ts +++ b/scripts/gen-abi.ts @@ -6,20 +6,37 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -// Path to Rust contract -const CONTRACT_PATH = path.join( - __dirname, - "../contracts/invoice_liquidity/src/lib.rs" -) - // Output file const OUTPUT_PATH = path.join(__dirname, "../docs/contract-abi.md") -const source = fs.readFileSync(CONTRACT_PATH, "utf8") +interface Contract { + title: string + contractPath: string + /** Falls back to contractPath when the enum isn't defined in its own file. */ + errorsPath?: string + errorEnum: string +} + +const CONTRACTS: Contract[] = [ + { + title: "InvoiceLiquidityContract", + contractPath: path.join(__dirname, "../contracts/invoice_liquidity/src/lib.rs"), + errorsPath: path.join(__dirname, "../contracts/invoice_liquidity/src/errors.rs"), + errorEnum: "ContractError", + }, + { + title: "InsurancePool", + contractPath: path.join(__dirname, "../contracts/insurance_pool/src/lib.rs"), + errorEnum: "InsuranceError", + }, +] function extractFunctions(code: string) { + // [^)]* (rather than .*?) and [^{]+? (rather than [^{\s]+) both match across + // newlines, so multi-line signatures and multi-word return types (e.g. + // "Result<(), ContractError>") are captured correctly. const functionRegex = - /(?:\/\/\/\s*(.*?)\n)?\s*pub fn (\w+)\((.*?)\)\s*->\s*([^{\s]+)/g + /(?:\/\/\/\s*(.*?)\n)?\s*pub fn (\w+)\(([^)]*)\)\s*->\s*([^{]+?)\s*\{/g const functions: any[] = [] @@ -29,8 +46,8 @@ function extractFunctions(code: string) { functions.push({ name, - params: params.trim(), - returnType: returnType.trim(), + params: params.trim().replace(/\s+/g, " "), + returnType: returnType.trim().replace(/\s+/g, " "), description: doc || "No description", }) } @@ -38,22 +55,28 @@ function extractFunctions(code: string) { return functions } -function extractErrors(code: string) { - const enumRegex = /enum ContractError\s*{([\s\S]*?)}/m +function extractErrors(code: string, enumName: string) { + const enumRegex = new RegExp(`enum ${enumName}\\s*{([\\s\\S]*?)\\n}`, "m") const match = code.match(enumRegex) if (!match) return [] - return match[1] - .split(",") - .map((e) => e.trim()) - .filter(Boolean) + // Match each `Name = Code,` variant on its own line, skipping doc comments + // (`///`) and plain comments so they don't get fused into the entry. + const variantRegex = /^\s*(\w+)\s*=\s*(\d+)\s*,/gm + const variants: string[] = [] + let variantMatch + while ((variantMatch = variantRegex.exec(match[1])) !== null) { + variants.push(`${variantMatch[1]} = ${variantMatch[2]}`) + } + + return variants } -function generateMarkdown(functions: any[], errors: string[]) { - let md = `# Contract ABI Documentation\n\n` +function generateContractSection(title: string, functions: any[], errors: string[]) { + let md = `## ${title}\n\n` - md += `## Functions\n\n` + md += `### Functions\n\n` md += `| Function | Parameters | Returns | Description |\n` md += `|----------|------------|---------|-------------|\n` @@ -61,8 +84,7 @@ function generateMarkdown(functions: any[], errors: string[]) { md += `| ${fn.name} | ${fn.params} | ${fn.returnType} | ${fn.description} |\n` } - md += `\n---\n\n` - md += `## Contract Errors\n\n` + md += `\n### Contract Errors\n\n` for (const err of errors) { md += `- ${err}\n` @@ -72,14 +94,24 @@ function generateMarkdown(functions: any[], errors: string[]) { } function main() { - const functions = extractFunctions(source) - const errors = extractErrors(source) - - const markdown = generateMarkdown(functions, errors) + let markdown = `# Contract ABI Documentation\n\n` + + for (const contract of CONTRACTS) { + const source = fs.readFileSync(contract.contractPath, "utf8") + const errorsSource = + contract.errorsPath && fs.existsSync(contract.errorsPath) + ? fs.readFileSync(contract.errorsPath, "utf8") + : source + const functions = extractFunctions(source) + const errors = extractErrors(errorsSource, contract.errorEnum) + + markdown += generateContractSection(contract.title, functions, errors) + markdown += `\n---\n\n` + } - fs.writeFileSync(OUTPUT_PATH, markdown) + fs.writeFileSync(OUTPUT_PATH, markdown.trimEnd() + "\n") - console.log("✅ ABI generated at docs/contract-abi.md") + console.log(`✅ ABI generated at docs/contract-abi.md (${CONTRACTS.map((c) => c.title).join(", ")})`) } main() \ No newline at end of file diff --git a/scripts/gen-spec.ts b/scripts/gen-spec.ts index f31d0290..1a4bcbeb 100644 --- a/scripts/gen-spec.ts +++ b/scripts/gen-spec.ts @@ -23,13 +23,10 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const SRC_DIR = path.join(__dirname, "../contracts/invoice_liquidity/src"); -const LIB = path.join(SRC_DIR, "lib.rs"); -const ERRORS = path.join(SRC_DIR, "errors.rs"); -const EVENTS = path.join(SRC_DIR, "events.rs"); const OUTPUT = path.join(__dirname, "../docs/contract-spec.json"); -const CONTRACT_NAME = "InvoiceLiquidityContract"; +const INVOICE_LIQUIDITY_SRC_DIR = path.join(__dirname, "../contracts/invoice_liquidity/src"); +const INSURANCE_POOL_SRC_DIR = path.join(__dirname, "../contracts/insurance_pool/src"); interface Param { name: string; @@ -131,8 +128,8 @@ function extractFunctions(code: string): FnSpec[] { .sort((a, b) => a.name.localeCompare(b.name)); } -function extractErrors(code: string): { name: string; code: number }[] { - const block = code.match(/enum\s+ContractError\s*\{([\s\S]*?)\n\}/); +function extractErrors(code: string, enumName: string): { name: string; code: number }[] { + const block = code.match(new RegExp(`enum\\s+${enumName}\\s*\\{([\\s\\S]*?)\\n\\}`)); if (!block) return []; const out: { name: string; code: number }[] = []; for (const line of block[1].split("\n")) { @@ -165,20 +162,31 @@ function extractEvents(code: string): { name: string; topics: string[]; fields: return out.sort((a, b) => a.name.localeCompare(b.name)); } -function main() { - const lib = fs.readFileSync(LIB, "utf8"); +/** Build a contract spec object by parsing its source directory. */ +function buildContractSpec(opts: { + contractName: string; + srcDir: string; + libRelativePath: string; + errorEnumName: string; + libFileName?: string; + errorsFileName?: string; + eventsFileName?: string; +}) { + const libPath = path.join(opts.srcDir, opts.libFileName ?? "lib.rs"); + const errorsPath = path.join(opts.srcDir, opts.errorsFileName ?? "errors.rs"); + const eventsPath = path.join(opts.srcDir, opts.eventsFileName ?? "events.rs"); + + const lib = fs.readFileSync(libPath, "utf8"); const functions = extractFunctions(lib); - const errors = fs.existsSync(ERRORS) ? extractErrors(fs.readFileSync(ERRORS, "utf8")) : []; - const events = fs.existsSync(EVENTS) ? extractEvents(fs.readFileSync(EVENTS, "utf8")) : []; + // Errors/events may live in lib.rs itself (no dedicated file) or in their own module. + const errorsSrc = fs.existsSync(errorsPath) ? fs.readFileSync(errorsPath, "utf8") : lib; + const eventsSrc = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, "utf8") : lib; + const errors = extractErrors(errorsSrc, opts.errorEnumName); + const events = extractEvents(eventsSrc); - const spec = { - $schema: "https://json-schema.org/draft/2020-12/schema", - contract: CONTRACT_NAME, - source: "contracts/invoice_liquidity/src/lib.rs", - generator: "scripts/gen-spec.ts", - note: - "Source-derived ABI spec. The canonical embedded spec is produced by " + - "`stellar contract inspect --wasm --output json` against the built WASM.", + return { + contract: opts.contractName, + source: opts.libRelativePath, functionCount: functions.length, errorCount: errors.length, eventCount: events.length, @@ -186,11 +194,66 @@ function main() { errors, events, }; +} + +/** + * The InvoiceLiquidityContract portion of the existing output is preserved + * as-is rather than regenerated: `extractEvents` only recognises the + * `#[contractevent]` struct pattern, and `events.rs` has since moved to + * plain `#[contracttype]` structs published ad hoc, so a fresh run under- + * reports events for that contract. Refreshing it is a separate, pre- + * existing concern from adding the insurance_pool spec here. + */ +function loadExistingInvoiceLiquiditySpec(): ReturnType | null { + if (!fs.existsSync(OUTPUT)) return null; + const existing = JSON.parse(fs.readFileSync(OUTPUT, "utf8")); + const { contract, source, functionCount, errorCount, eventCount, functions, errors, events } = + existing; + return { contract, source, functionCount, errorCount, eventCount, functions, errors, events }; +} + +function main() { + const invoiceLiquidity = + loadExistingInvoiceLiquiditySpec() ?? + buildContractSpec({ + contractName: "InvoiceLiquidityContract", + srcDir: INVOICE_LIQUIDITY_SRC_DIR, + libRelativePath: "contracts/invoice_liquidity/src/lib.rs", + errorEnumName: "ContractError", + }); + + // insurance_pool keeps its errors in lib.rs (no dedicated errors.rs/events.rs) + // and has no #[contractevent] structs — events are ad-hoc env.events().publish() calls. + const insurancePool = buildContractSpec({ + contractName: "InsurancePool", + srcDir: INSURANCE_POOL_SRC_DIR, + libRelativePath: "contracts/insurance_pool/src/lib.rs", + errorEnumName: "InsuranceError", + }); + + const spec = { + $schema: "https://json-schema.org/draft/2020-12/schema", + // Top-level fields describe InvoiceLiquidityContract for backward compatibility + // with existing consumers (e.g. scripts/generate-sdk.ts). Other contracts are + // listed under `contracts` instead of being duplicated at the top level. + ...invoiceLiquidity, + generator: "scripts/gen-spec.ts", + note: + "Source-derived ABI spec. The canonical embedded spec is produced by " + + "`stellar contract inspect --wasm --output json` against the built WASM. " + + "Top-level fields describe InvoiceLiquidityContract; see `contracts` for the rest.", + contracts: { + insurance_pool: insurancePool, + }, + }; fs.writeFileSync(OUTPUT, JSON.stringify(spec, null, 2) + "\n"); console.log( `✅ contract spec written to docs/contract-spec.json ` + - `(${functions.length} functions, ${errors.length} errors, ${events.length} events)` + `(invoice_liquidity: ${invoiceLiquidity.functions.length} functions, ` + + `${invoiceLiquidity.errors.length} errors, ${invoiceLiquidity.events.length} events; ` + + `insurance_pool: ${insurancePool.functions.length} functions, ` + + `${insurancePool.errors.length} errors, ${insurancePool.events.length} events)` ); }