Skip to content

feat: governance execution verification, quadratic voting, oracle registry, insurance pool integration - #592

Merged
Levi-Ojukwu merged 4 commits into
Invoice-Liquidity-Network:mainfrom
Asta-wizard:fix/529-530-531-532-governance-insurance-oracle
Jul 27, 2026
Merged

feat: governance execution verification, quadratic voting, oracle registry, insurance pool integration#592
Levi-Ojukwu merged 4 commits into
Invoice-Liquidity-Network:mainfrom
Asta-wizard:fix/529-530-531-532-governance-insurance-oracle

Conversation

@Asta-wizard

Copy link
Copy Markdown
Contributor

Closes #529
Closes #530
Closes #531
Closes #532

What changed

#531 — Proposal execution verification

  • execute_proposal fired invoke_contract for the cross-contract action call and unconditionally marked the proposal Executed, even if the callee panicked or returned an error.
  • Switched to try_invoke_contract so the outcome is observable. On failure the proposal is left at Passed (timelock/eta untouched) instead of advancing to Executed, a ProposalExecutionFailed event is emitted, and execute_proposal returns a new ExecutionFailed error. Since the proposal stays Passed with its timelock already expired, calling execute_proposal again simply retries the same action.
  • Also fixes tests_benchmarks::setup_benchmark_env(), which called initialize() with the pre-Implement on-chain mechanism to update distribution reward formulas #544 3-argument signature and hadn't compiled since the distribution_contract parameter was added — this was blocking the entire iln_governance test binary from building.

#530 — Quadratic voting

  • Linear vote weighting (weight = balance + delegated) lets a holder with 100x the tokens of another get exactly 100x the voting influence.
  • Added a governance-controlled toggle (set_quadratic_voting_enabled / is_quadratic_voting_enabled, default off) that switches cast_vote to weight = isqrt(balance + delegated) — a whale with 100x the tokens ends up with only 10x the influence. isqrt is a floor integer square root via bounded binary search (no floating point in a #![no_std] contract).
  • The weight actually applied to each vote is now recorded (AppliedVoteWeight, same TTL as HasVoted) and readable via get_applied_vote_weight.
  • Default-off keeps existing/in-flight proposals on the linear model they were built against. Design tradeoffs are written up in docs/adr/ADR-009-quadratic-voting.md.

#532 — Governance-controlled oracle registry

  • Config.price_oracle was a single address used only for payer identity verification. Added an OracleFeedType registry (Price/Identity/Credit) resolved per-token-override → feed-type-default → (Identity only) the legacy price_oracle field, so existing set_price_oracle-only configs keep working unmodified.
  • fund_invoice now resolves through the registry for the Identity feed, keyed by the invoice's token.
  • Wired governance proposal actions (RegisterOracle, RemoveOracle) so the registry is actually governance-controlled via execute_proposal.
  • Health monitoring needed a dedicated entrypoint (check_oracle_health) rather than piggybacking on fund_invoice, since Soroban rolls back all storage writes for an invocation that returns Err — a health snapshot written just before fund_invoice's existing OracleDataStale rejection would never persist. Design tradeoffs are written up in docs/adr/ADR-010-oracle-registry.md.

#529 — Insurance pool integration

  • insurance_pool's own interface docs already described this follow-up: "the main invoice_liquidity contract, which (in a follow-up) invokes claim from its default-handling path via the generated InsurancePoolClient."
  • Added insurance_pool as a regular (not just dev) dependency so claim_default can use the generated InsurancePoolInterfaceClient directly. Deployed pool address is a new admin-gated DataKey::InsurancePool (set_insurance_pool/get_insurance_pool).
  • After the existing principal-refund loop in claim_default, checks whether the claiming LP is enrolled and, if so, attempts pool.claim(invoice_id, lp). The pool credits the LP directly from its own balance — invoice_liquidity never holds or forwards the payout.
  • Uses try_is_enrolled/try_claim (not the panicking variants) so a paused, empty, or unreachable pool degrades gracefully: claim_default still completes rather than reverting the whole default over an optional insurance top-up.

Why

How to test

  • cargo test -p iln_governance — 97/98 passing (1 pre-existing failure, test_vote_receipt_available_within_ttl, unrelated — confirmed failing identically against unmodified main).
  • cargo test -p invoice_liquidity --lib — 93/97 passing (4 pre-existing failures — test_upgrade_* x3 and test_admin_adds_token_with_different_decimals — confirmed failing identically against unmodified main logic; unrelated soroban-sdk API drift in tests_new_features.rs/tests_storage_layout.rs that predates this PR and isn't touched here).
  • cargo build --workspace and cargo clippy -p iln_governance --all-targets -- -D warnings — clean.
  • cargo fmt --all -- --check — no new diffs introduced by this PR (pre-existing formatting drift on main is left as-is, not touched).
  • pnpm --filter @iln/sdk exec vitest run — new/touched SDK tests (admin.test.ts, governance.test.ts additions) pass; pre-existing unrelated failures in errors.test.ts, getTokenDecimals.test.ts, nft.test.ts, etc. are untouched by this PR.

Notes for reviewers

  • invoice_liquidity's test suite (cargo test -p invoice_liquidity --lib) currently fails to compile on main due to pre-existing, unrelated soroban-sdk API drift in tests_new_features.rs and tests_storage_layout.rs (Address::random() no longer exists; a function's return type changed to Result<Vec<u64>, ConversionError> without call sites being updated). This isn't touched here — fixing it would be a separate, unrelated PR — but it's worth flagging since it means that crate's test binary hasn't actually been exercised in a while (the test/clippy CI jobs are also currently disabled via if: ${{ false }} in ci.yml, so this wasn't caught).
  • Two test files (tests_access_control.rs, tests_oracle_freshness.rs, plus a few others: tests_benchmarks.rs, tests_multi_token.rs, tests_lp_priority_queue.rs, tests_appeal.rs, tests_multisig_admin.rs) exist in invoice_liquidity/src but aren't declared as mods in lib.rs, so they never actually compile/run as part of cargo test. Not fixed here (out of scope for these 4 issues and would need its own review — some of those files have their own pre-existing issues once wired in), but flagging since it's a meaningful gap.

execute_proposal fired invoke_contract for the cross-contract action call
and unconditionally marked the proposal Executed and emitted
ProposalExecuted, even if the callee panicked or returned an error. A
failed cross-contract call (unauthorized, invalid params, paused target)
left the protocol thinking a governance action had applied when nothing
actually changed, with no way to retry.

Switch to try_invoke_contract so the outcome is observable. On failure,
the proposal is left at Passed (its eta_ledger is untouched) instead of
being advanced to Executed, a ProposalExecutionFailed event is emitted,
and execute_proposal returns the new ExecutionFailed error. Since the
proposal stays Passed with its timelock already expired, calling
execute_proposal again simply retries the same action - no separate
retry mechanism or state field is needed.

Also fixes tests_benchmarks::setup_benchmark_env(), which called
initialize() with the pre-Invoice-Liquidity-Network#544 3-argument signature and hasn't compiled
since the distribution_contract parameter was added - this was blocking
the entire crate's test binary from building.

Closes Invoice-Liquidity-Network#531
Linear vote weighting (weight = balance + delegated) lets a holder with
100x the tokens of another get exactly 100x the voting influence, so a
handful of large holders can dominate every proposal outcome regardless
of how many distinct holders disagree.

Add a governance-controlled toggle (set_quadratic_voting_enabled /
is_quadratic_voting_enabled, default off) that switches cast_vote to
weight = isqrt(balance + delegated) instead - a whale with 100x the
tokens ends up with only 10x the influence. isqrt is a floor integer
square root via bounded binary search (no floating point in a #![no_std]
contract). Combined balance is summed before the square root is taken,
not square-rooted per-component then summed, so splitting delegation
across multiple chains doesn't inflate total weight.

The weight actually applied to each vote is now recorded as a receipt
(AppliedVoteWeight, same temporary-storage TTL as HasVoted) and readable
via get_applied_vote_weight, so the exact weight a cast vote carried is
auditable independent of any balance changes afterward.

Default-off keeps existing and in-flight proposals on the linear model
they were built and tested against; toggling requires the same
ILN-contract-authorized governance pattern as set_min_quorum_bps.
Design tradeoffs (mid-window toggling, floor-sqrt quantization at small
balances, alternatives considered) are written up in
docs/adr/ADR-009-quadratic-voting.md.

Adds SDK bindings (setQuadraticVotingEnabled, isQuadraticVotingEnabled,
getAppliedVoteWeight) and the missing ExecutionFailed (Invoice-Liquidity-Network#531) error code
to GovernanceContractError's mapper.

Closes Invoice-Liquidity-Network#530
… registry

Config.price_oracle was a single Option<Address> used only for payer
identity/creditworthiness verification in fund_invoice. That doesn't
scale to a protocol wanting multiple oracle kinds (price feeds, identity,
credit scoring) or different providers per token (a USDC price feed and
an XLM price feed are different contracts).

Add an OracleFeedType registry (Price/Identity/Credit) resolved in
priority order: per-token override, then feed-type-wide default, then
(Identity only) the legacy price_oracle field - so existing
set_price_oracle-only configurations keep working unmodified.
fund_invoice now resolves through this registry for the Identity feed,
keyed by the invoice's token, instead of reading price_oracle directly.
All four registry mutators (register/remove, default and per-token) are
require_admin-gated, matching the update_fee_rate/add_token pattern.

Wire governance proposal actions (RegisterOracle, RemoveOracle) so these
mutators are actually governance-controlled via execute_proposal, the
same cross-contract-call pattern already used for fee rate/token/decay
parameter changes.

Health monitoring needed a dedicated entrypoint rather than piggybacking
on fund_invoice: Soroban rolls back all storage writes for an invocation
that returns Err, so a health snapshot written just before fund_invoice's
existing OracleDataStale rejection never persists. check_oracle_health
queries the resolved oracle and always records + returns the result
without ever erroring, so keepers can poll staleness trends
(consecutive_stale_count) independent of - and without risking - any
funding side effect. Design tradeoffs are written up in
docs/adr/ADR-010-oracle-registry.md.

Also fixes tests_benchmarks.rs's initialize() call (still missing the
distribution_contract arg from Invoice-Liquidity-Network#544) - discovered because it blocked
the crate's test binary from compiling at all.

Closes Invoice-Liquidity-Network#532
Currently, when an invoice defaults, an LP who opted into the insurance
pool still had no way to actually get compensated - claim_default had
no knowledge the pool existed. insurance_pool's own interface docs
already described this exact follow-up: "the main invoice_liquidity
contract, which (in a follow-up) invokes claim from its default-handling
path via the generated InsurancePoolClient."

Add insurance_pool as a regular (not just dev) dependency so
claim_default can use the generated InsurancePoolInterfaceClient
directly, matching the design already reviewed and written up in
docs/insurance-pool-design.md. Store the deployed pool address as a new
admin-gated DataKey::InsurancePool (set_insurance_pool/
get_insurance_pool, mirroring the set_price_oracle pattern).

After the existing principal-refund loop in claim_default, check
whether the claiming LP is enrolled and, if so, attempt
pool.claim(invoice_id, lp). The pool credits the LP directly out of its
own balance - invoice_liquidity never holds or forwards the payout,
just triggers the claim and emits InsuranceClaimAttempted reporting the
outcome.

Use try_is_enrolled/try_claim (not the panicking variants) so a paused,
empty, or unreachable pool degrades gracefully: claim_default still
completes - the refund and status update already happened in the same
atomic invocation - rather than reverting the whole default over an
optional insurance top-up.

Tests exercise the real insurance_pool contract (now a real dependency,
not a mock) end-to-end: enrolled LP gets both the principal refund and
the insurance payout, an unenrolled LP gets no attempt, and an empty
pool still lets claim_default succeed.

Closes Invoice-Liquidity-Network#529
@drips-wave

drips-wave Bot commented Jul 27, 2026

Copy link
Copy Markdown

@Asta-wizard Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Levi-Ojukwu
Levi-Ojukwu merged commit ddc5499 into Invoice-Liquidity-Network:main Jul 27, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants