Skip to content

feat: add pooled-model exploration (PRD + FundingBeacon + StabilityPool skeleton)#30

Draft
tiero wants to merge 2 commits into
masterfrom
claude/pooled-model-exploration-ocTK8
Draft

feat: add pooled-model exploration (PRD + FundingBeacon + StabilityPool skeleton)#30
tiero wants to merge 2 commits into
masterfrom
claude/pooled-model-exploration-ocTK8

Conversation

@tiero

@tiero tiero commented May 15, 2026

Copy link
Copy Markdown
Member

Captures the move from the isolated StabilityVault/StabilityOffer model to a
pooled model in response to quant feedback (positions non-fungible, Seeker
redemptions act as enforced margin calls). Variant B is chosen: Seekers
transfer-only, no on-chain redeem to BTC; Provider exit gated by leverage.
Index-based funding accrual via a standalone FundingBeacon.

  • docs/stability-pool-prd.md: full design doc (state model, leverage gates,
    contract surface, phased build, open questions).
  • examples/stability/funding_beacon.ark (Phase 1): monotone cumulative
    yield-index oracle; mirrors PriceBeacon shape.
  • examples/stability/stability_pool.ark (Phase 2 skeleton): provider-only
    flows (deposit, leverage-gated withdraw) with index-based accrual.
  • examples/stability/provider_share.ark (Phase 2 skeleton): per-Provider
    share UTXO; share-equity dilution math marked TODO for Phase 2 completion.

Compiles cleanly; full test suite passes; cargo fmt clean. Isolated-model
contracts and tests are intentionally left in place until the pooled model
is feature-complete.

Summary by CodeRabbit

  • New Features

    • Added stability pool system enabling provider deposits and withdrawals with leverage constraints
    • Introduced funding rate oracle for real-time collateral tracking
  • Documentation

    • Added product requirements document defining pooled economic model and system architecture

Review Change Stack

…ol skeleton)

Captures the move from the isolated StabilityVault/StabilityOffer model to a
pooled model in response to quant feedback (positions non-fungible, Seeker
redemptions act as enforced margin calls). Variant B is chosen: Seekers
transfer-only, no on-chain redeem to BTC; Provider exit gated by leverage.
Index-based funding accrual via a standalone FundingBeacon.

- docs/stability-pool-prd.md: full design doc (state model, leverage gates,
  contract surface, phased build, open questions).
- examples/stability/funding_beacon.ark (Phase 1): monotone cumulative
  yield-index oracle; mirrors PriceBeacon shape.
- examples/stability/stability_pool.ark (Phase 2 skeleton): provider-only
  flows (deposit, leverage-gated withdraw) with index-based accrual.
- examples/stability/provider_share.ark (Phase 2 skeleton): per-Provider
  share UTXO; share-equity dilution math marked TODO for Phase 2 completion.

Compiles cleanly; full test suite passes; cargo fmt clean. Isolated-model
contracts and tests are intentionally left in place until the pooled model
is feature-complete.
@github-actions

github-actions Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Playground Preview

A live preview of this PR's playground is available at:
https://arkade-os.github.io/compiler/pr-previews/pr-30/

Built from commit 5c5e00be884d2070850fb3744c59c618b25d681b · Workflow run

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

Walkthrough

This PR introduces a complete product requirements document and contract skeleton implementation for a perpetual-bond pooled stability pool system (Variant B). It includes an index-based funding oracle (FundingBeacon), provider capital representation (ProviderShare), and pool management (StabilityPool) with provider-only entry and leverage-gated exit flows.

Changes

Pooled StabilityPool System

Layer / File(s) Summary
Product requirements and system design
docs/stability-pool-prd.md
PRD defines the pooled economic model (seeker/provider capital, accrued funding from index delta), Variant B gating rules (no Seeker BTC redemption), action table (entry/exit/transfer permissions), contract surface (FundingBeacon/StabilityPool/ProviderShare/SeekerShare), open design questions, and build phase scope with explicit non-goals.
FundingBeacon dual-asset monotone oracle
examples/stability/funding_beacon.ark, examples/stability/funding_beacon.json
FundingBeacon maintains a monotone cumulative funding index (yieldTicker) and block height (yieldClock) updated by authorized oracle via signature. Provides update (with non-regression enforcement), passthrough (preserving beacon script/assets), and migrate (rotating oracle authority) entrypoints. Ark code defines logic; JSON descriptor includes ASM verification sequences and serverVariant fallback timelock behavior.
ProviderShare provider capital contract
examples/stability/provider_share.ark, examples/stability/provider_share.json
ProviderShare tracks provider's deposited sats and entry funding index, with redeem method validating provider signature. Phase 2 skeleton defers equity/fair-withdraw enforcement. JSON descriptor includes dual-variant redemption (server-signature authorization vs. exit-timelock fallback).
StabilityPool deposit and withdraw with leverage gating
examples/stability/stability_pool.ark, examples/stability/stability_pool.json
StabilityPool manages provider deposits and withdrawals constrained by leverage bound (3×seekerCapital ≤ totalAfter). providerDeposit creates ProviderShare outputs and refreshes pool yield index; providerWithdraw computes accrued funding from yield-index delta and enforces leverage constraint before allowing withdrawal. Both functions validate oracle freshness and dust thresholds, with full ASM verification in JSON descriptor.

Sequence Diagram

sequenceDiagram
  participant Provider
  participant DepositTx as Deposit Tx
  participant StabilityPool
  participant FundingBeacon
  participant ProviderShare
  Provider->>DepositTx: initiate providerDeposit(depositSats, providerPk)
  DepositTx->>StabilityPool: validate dust, beacon freshness
  StabilityPool->>FundingBeacon: read current yieldIndex/yieldClock
  StabilityPool->>StabilityPool: refresh poolYieldIndex
  StabilityPool->>FundingBeacon: passthrough beacon assets
  DepositTx->>ProviderShare: create output with depositedSats, entryIndex
  Provider->>DepositTx: initiate providerWithdraw(withdrawSats, providerPk)
  DepositTx->>StabilityPool: validate dust, beacon freshness
  StabilityPool->>StabilityPool: compute seekerCapital = aggregateSeekerUSD + accrued
  StabilityPool->>StabilityPool: check leverage (3×seekerCapital ≤ totalAfter)
  StabilityPool->>FundingBeacon: passthrough beacon assets
  DepositTx->>Provider: require SingleSig payout ≥ withdrawSats
Loading

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main addition: a pooled-model exploration with PRD, FundingBeacon, and StabilityPool skeleton.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pooled-model-exploration-ocTK8

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arkanaai arkanaai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Aggressive Code Review — #30

Reviewer: Arkana (AI)
Verdict: Request changes — protocol-critical code requires human review

This PR adds pooled-model contracts (FundingBeacon, StabilityPool, ProviderShare) plus a comprehensive PRD. The FundingBeacon is solid and mirrors PriceBeacon well. The StabilityPool skeleton has structural issues that need resolution before this should be considered even a reference skeleton.


🔴 CRITICAL — Exit variant on singleton pool UTXO

Files: stability_pool.ark (all functions), compiled stability_pool.json

The options { server = server; exit = exit; } pattern generates exit variants for providerDeposit and providerWithdraw where the gate key is providerPk — a function argument (witness-supplied), NOT a constructor parameter.

Compare with existing contracts:

  • PriceBeacon exit: gated by oraclePk (constructor param) ✅
  • StabilityVault exit: gated by seekerPk + providerPk (constructor params) ✅
  • StabilityPool exit: gated by providerPk (witness arg) ⚠️

For per-user VTXOs this pattern is safe — the user exits their own funds. For a singleton pool covenant holding everyone's capital, the exit path may allow an arbitrary party to drain the pool after the CSV timelock, since the gate key isn't baked into the script at creation time.

The compiled exit variant ASM (stability_pool.json lines ~1593-1600):

"<providerPk>", "<providerPkSig>", "OP_CHECKSIG",
"<exit>", "OP_CHECKSEQUENCEVERIFY", "OP_DROP"

Action needed: Clarify how the compiler resolves <providerPk> in exit variants when it's a function arg, not a constructor param. If it's witness-supplied, this is a fund-drain vector. The pool may need a dedicated poolAuthority constructor param as the exit-path gate, or a different options configuration.


🔴 HIGH — No covenant-level verification of ProviderShare input

File: stability_pool.ark:227-296 (providerWithdraw)

The pool's providerWithdraw function assumes input[3] is a legitimate ProviderShare UTXO but never introspects tx.inputs[3].scriptPubKey to verify it. The pool trusts the ProviderShare contract to self-enforce, but there's no binding between the two.

In the cooperative (server) path, the server gates this. In the exit path, there's no gate at all.

Risk: A crafted UTXO at input[3] that is NOT a ProviderShare could be used to satisfy the providerWithdraw spending path. The pool would decrement its value and pay out to the "provider" without a legitimate share being burned.

Fix: Add require(tx.inputs[3].scriptPubKey == new ProviderShare(providerPk, ..., exit), "input[3] not a valid ProviderShare"). This requires either passing depositedSats/entryIndex as function args or reading them from the input.


🟡 MEDIUM — No on-chain rate cap on FundingBeacon

File: funding_beacon.ark:38-59 (update)

PRD §5 says: "Christian flagged death-spiral risk if rate is unbounded in distress. Decide whether to cap the on-chain index growth rate. Strongly recommend yes for v0."

But the update() function only enforces monotonicity (newIndex >= currentIndex). There is no cap on the per-block index growth rate. A compromised or malicious oracle can set an arbitrarily high index in a single update, instantly shifting all provider capital to seekers.

Suggestion: Enforce (newIndex - currentIndex) / (newBlockHeight - currentHeight) <= MAX_RATE_PER_BLOCK on-chain, even if conservative. The oracle trust surface is already the biggest risk vector; bounding it on-chain is cheap defense-in-depth.


🟡 MEDIUM — No tests for any new contracts

Files: No new files in tests/

The PR description says "full test suite passes" but there are zero new tests for FundingBeacon, StabilityPool, or ProviderShare. The existing tests/beacon_test.rs only covers PriceBeacon.

At minimum, Phase 1 (FundingBeacon) should ship with:

  • Compilation test (parses, produces valid artifact)
  • update: monotonicity enforcement (index regression rejected, height regression rejected)
  • passthrough: assets preserved
  • migrate: oracle key rotated, index/height preserved

🟡 MEDIUM — Compiler type-mixing warnings

Files: funding_beacon.json, stability_pool.json

Both compiled artifacts emit warnings about uint64le/scriptnum mixing:

warning[type]: fn update: comparison '>=' mixes uint64le ('int') with scriptnum ('uint64le')

These implicit conversions can cause subtle comparison bugs at boundary values (e.g., large indices near 2^63). The code should use explicit le64ToScriptNum() conversions as the compiler suggests.


🟢 LOW — Redundant undercollateralisation check

File: stability_pool.ark:249-253

require(totalAfter > seekerCapital, "withdraw would undercollateralise");
// ...
int leverageBound = seekerCapital * 3;
require(leverageBound <= totalAfter, "leverage gate: provider withdraw blocked");

The leverage gate (3 × seekerCapital ≤ totalAfter) is strictly tighter than the undercollateralisation check (totalAfter > seekerCapital). The first require is always satisfied if the second passes. Not harmful, but unnecessary gas in script execution.


🟢 LOW — Overflow risk at scale

File: stability_pool.ark:243-245

int seekerCapitalNominal = aggregateSeekerUSD * 100000000 / currentPrice;
int fundingAccrued = aggregateSeekerUSD * deltaIndex / 100000000;

With 64-bit signed ints, aggregateSeekerUSD * 100000000 overflows at ~$920M aggregate seeker exposure (9.2×10^10 cents × 10^8 = 9.2×10^18, near 2^63). The PRD §6 flags this. Not blocking for v0 exploration, but the production version needs checked arithmetic or a wider intermediate.


ℹ️ INFO — Design observations

  1. FundingBeacon is clean. Mirrors PriceBeacon shape correctly. update/passthrough/migrate all enforce monotonicity and script survival. The migrate function correctly requires both old and new oracle signatures in the exit path (2-of-2). Well done.

  2. ProviderShare redeem() is a bare signature check. The TODO acknowledging missing equity math is fine for a skeleton, but the comment at provider_share.ark:13 ("v0 trusts the pool to enforce the leverage gate") creates a circular trust dependency — the pool also doesn't verify the share. One of them needs to do the binding.

  3. PRD is thorough. §5 open questions are well-identified. The phased approach is sensible. Leaving isolated-model contracts in place until pooled is complete is the right call.


Verdict

Request changes. The critical exit-variant concern and the missing ProviderShare input verification need resolution before merge. The FundingBeacon rate-cap and test coverage gaps should be addressed in this PR or tracked as immediate follow-ups.

⚠️ This is protocol-critical code (pooled BTC covenant). Human review required before merge regardless of AI approval status.

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/stability-pool-prd.md`:
- Around line 241-243: The overflow estimate is incorrect: with $1B = 1e11
cents, multiplying aggregateSeekerUSD by INDEX_SCALE = 1e8 yields 1e19 which
exceeds signed 64-bit; update the doc and the StabilityPool math by either
lowering INDEX_SCALE or switching the affected arithmetic (e.g., in functions
that use aggregateSeekerUSD × INDEX_SCALE) to a wider integer type/bignum or
rebase units to avoid multiplying two large scales; specifically, correct the
numeric example in the text and add a note in the StabilityPool implementation
referencing INDEX_SCALE and aggregateSeekerUSD about required bitwidth or
mitigation.

In `@examples/stability/funding_beacon.ark`:
- Around line 80-96: The passthrough() path currently uses >= checks which allow
bumping yieldTicker/yieldClock assets without oracleSig; change the two asset
checks in passthrough() so tx.outputs[0].assets.lookup(yieldTicker) ==
tx.inputs[0].assets.lookup(yieldTicker) and
tx.outputs[0].assets.lookup(yieldClock) ==
tx.inputs[0].assets.lookup(yieldClock) to enforce immutability of those oracle
values (leave FundingBeacon script equality as-is and ensure only update() can
modify these assets).

In `@examples/stability/stability_pool.ark`:
- Around line 102-141: The deposit/withdrawal checks assume the FundingBeacon
appears at tx.outputs[2] while FundingBeacon.update()/passthrough()/migrate()
recreate the beacon at tx.outputs[0], so change the output indexing or the
contracts to agree: either update the StabilityPool checks to expect the funding
beacon at tx.outputs[0] (adjust the price/funding passthrough requires to use
tx.outputs[0].assets.lookup for yieldTicker/yieldClock) or modify
FundingBeacon.* (update, passthrough, migrate) to reconstruct the beacon at the
index used by StabilityPool (tx.outputs[2]); pick one consistent convention and
update all references (StabilityPool, FundingBeacon.update,
FundingBeacon.passthrough, FundingBeacon.migrate and the output index checks
around the ProviderShare) so a single transaction can satisfy both contracts.
- Around line 175-177: The math for seekerCapitalNominal and fundingAccrued can
overflow 64-bit: avoid multiplying aggregateSeekerUSD by 100000000 or deltaIndex
directly. Change calculations in the seekerCapitalNominal, deltaIndex, and
fundingAccrued logic to use a wider integer or safe multiply/divide routine
(e.g., 128-bit intermediate or a checked mulDiv) and/or reorder to divide first
(compute aggregateSeekerUSD / currentPrice then * scale, or use
mulDiv(aggregateSeekerUSD, scale, currentPrice)) and validate bounds on
deltaIndex (currentIndex - poolYieldIndex) before use; ensure all references to
seekerCapitalNominal and fundingAccrued use the overflow-safe implementation and
add unit checks to prevent wrapped values from passing collateral/leverage
checks.
- Around line 159-227: providerWithdraw currently uses providerPk from the
witness without proving a matching ProviderShare is being burned; require that
tx.inputs[3] is the ProviderShare being redeemed and that its on-chain
entitlement binds to providerPk and withdrawSats. Concretely, validate
tx.inputs[3].scriptPubKey == new ProviderShare(...expected params...) or
otherwise read the ProviderShare fields from tx.inputs[3] and require its owner
pubkey equals providerPk and its recorded share/entitlement covers withdrawSats
(and consume the share by requiring it as an input); keep the existing
pool-level checks (aggregateSeekerUSD, poolYieldIndex, exit) but block the
fallback path unless the above ProviderShare ownership/entitlement checks pass.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d7bfa807-a619-4428-9522-4e002509f62d

📥 Commits

Reviewing files that changed from the base of the PR and between 43d9036 and cff0665.

📒 Files selected for processing (7)
  • docs/stability-pool-prd.md
  • examples/stability/funding_beacon.ark
  • examples/stability/funding_beacon.json
  • examples/stability/provider_share.ark
  • examples/stability/provider_share.json
  • examples/stability/stability_pool.ark
  • examples/stability/stability_pool.json

Comment on lines +241 to +243
6. **Index unit.** `INDEX_SCALE = 1e8` gives sat-precision per cent of USD.
Worth running the numbers at $1B aggregateSeekerUSD to confirm no
overflow risk (Arkade ints are 64-bit signed → `1e10 × 1e8 = 1e18`, fits).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The overflow estimate here is off by an order of magnitude.

$1B is 1e11 cents, so aggregateSeekerUSD × INDEX_SCALE is 1e19, not 1e18. That exceeds signed 64-bit and matches the overflow risk in the current StabilityPool math.

🧰 Tools
🪛 LanguageTool

[uncategorized] ~241-~241: In American English, “percent” is the recommended spelling.
Context: ...INDEX_SCALE = 1e8 gives sat-precision per cent of USD. Worth running the numbers at...

(PER_CENT)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/stability-pool-prd.md` around lines 241 - 243, The overflow estimate is
incorrect: with $1B = 1e11 cents, multiplying aggregateSeekerUSD by INDEX_SCALE
= 1e8 yields 1e19 which exceeds signed 64-bit; update the doc and the
StabilityPool math by either lowering INDEX_SCALE or switching the affected
arithmetic (e.g., in functions that use aggregateSeekerUSD × INDEX_SCALE) to a
wider integer type/bignum or rebase units to avoid multiplying two large scales;
specifically, correct the numeric example in the text and add a note in the
StabilityPool implementation referencing INDEX_SCALE and aggregateSeekerUSD
about required bitwidth or mitigation.

Comment on lines +80 to +96
function passthrough() {
require(
tx.outputs[0].scriptPubKey == new FundingBeacon(yieldTicker, yieldClock, oraclePk, exit),
"beacon script must survive"
);

int currentIndex = tx.inputs[0].assets.lookup(yieldTicker);
require(
tx.outputs[0].assets.lookup(yieldTicker) >= currentIndex,
"index asset must survive"
);

int currentHeight = tx.inputs[0].assets.lookup(yieldClock);
require(
tx.outputs[0].assets.lookup(yieldClock) >= currentHeight,
"clock asset must survive"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

passthrough() must preserve the oracle values exactly.

This path is callable without oracleSig, so the >= checks let any reader bump yieldIndex or yieldClock. That can spoof funding accrual and make stale data appear fresh. These assets need to be immutable on passthrough(); only update() should move them.

Suggested fix
   function passthrough() {
     require(
       tx.outputs[0].scriptPubKey == new FundingBeacon(yieldTicker, yieldClock, oraclePk, exit),
       "beacon script must survive"
     );

     int currentIndex = tx.inputs[0].assets.lookup(yieldTicker);
     require(
-      tx.outputs[0].assets.lookup(yieldTicker) >= currentIndex,
+      tx.outputs[0].assets.lookup(yieldTicker) == currentIndex,
       "index asset must survive"
     );

     int currentHeight = tx.inputs[0].assets.lookup(yieldClock);
     require(
-      tx.outputs[0].assets.lookup(yieldClock) >= currentHeight,
+      tx.outputs[0].assets.lookup(yieldClock) == currentHeight,
       "clock asset must survive"
     );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function passthrough() {
require(
tx.outputs[0].scriptPubKey == new FundingBeacon(yieldTicker, yieldClock, oraclePk, exit),
"beacon script must survive"
);
int currentIndex = tx.inputs[0].assets.lookup(yieldTicker);
require(
tx.outputs[0].assets.lookup(yieldTicker) >= currentIndex,
"index asset must survive"
);
int currentHeight = tx.inputs[0].assets.lookup(yieldClock);
require(
tx.outputs[0].assets.lookup(yieldClock) >= currentHeight,
"clock asset must survive"
);
function passthrough() {
require(
tx.outputs[0].scriptPubKey == new FundingBeacon(yieldTicker, yieldClock, oraclePk, exit),
"beacon script must survive"
);
int currentIndex = tx.inputs[0].assets.lookup(yieldTicker);
require(
tx.outputs[0].assets.lookup(yieldTicker) == currentIndex,
"index asset must survive"
);
int currentHeight = tx.inputs[0].assets.lookup(yieldClock);
require(
tx.outputs[0].assets.lookup(yieldClock) == currentHeight,
"clock asset must survive"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/stability/funding_beacon.ark` around lines 80 - 96, The
passthrough() path currently uses >= checks which allow bumping
yieldTicker/yieldClock assets without oracleSig; change the two asset checks in
passthrough() so tx.outputs[0].assets.lookup(yieldTicker) ==
tx.inputs[0].assets.lookup(yieldTicker) and
tx.outputs[0].assets.lookup(yieldClock) ==
tx.inputs[0].assets.lookup(yieldClock) to enforce immutability of those oracle
values (leave FundingBeacon script equality as-is and ensure only update() can
modify these assets).

Comment on lines +102 to +141
require(
tx.outputs[0].scriptPubKey == new StabilityPool(
priceTicker, priceClock, yieldTicker, yieldClock,
aggregateSeekerUSD, currentIndex, exit
),
"invalid pool output"
);
int poolValueAfter = tx.inputs[0].value + depositSats;
require(
tx.outputs[0].value >= poolValueAfter,
"pool deposit not credited"
);

// PriceBeacon passthrough
require(
tx.outputs[1].assets.lookup(priceTicker) >= currentPrice,
"price beacon must survive"
);
require(
tx.outputs[1].assets.lookup(priceClock) >= priceHeight,
"price clock must survive"
);

// FundingBeacon passthrough
require(
tx.outputs[2].assets.lookup(yieldTicker) >= currentIndex,
"funding beacon must survive"
);
require(
tx.outputs[2].assets.lookup(yieldClock) >= yieldHeight,
"funding clock must survive"
);

// ProviderShare output for the depositor
require(
tx.outputs[3].scriptPubKey == new ProviderShare(
providerPk, depositSats, currentIndex, exit
),
"invalid provider share output"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

The output ordering here is incompatible with FundingBeacon.

These paths require the funding beacon to reappear at tx.outputs[2], but FundingBeacon.update()/passthrough()/migrate() all reconstruct the beacon at tx.outputs[0]. A deposit/withdraw tx cannot satisfy both contracts as written.

Also applies to: 193-220

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/stability/stability_pool.ark` around lines 102 - 141, The
deposit/withdrawal checks assume the FundingBeacon appears at tx.outputs[2]
while FundingBeacon.update()/passthrough()/migrate() recreate the beacon at
tx.outputs[0], so change the output indexing or the contracts to agree: either
update the StabilityPool checks to expect the funding beacon at tx.outputs[0]
(adjust the price/funding passthrough requires to use
tx.outputs[0].assets.lookup for yieldTicker/yieldClock) or modify
FundingBeacon.* (update, passthrough, migrate) to reconstruct the beacon at the
index used by StabilityPool (tx.outputs[2]); pick one consistent convention and
update all references (StabilityPool, FundingBeacon.update,
FundingBeacon.passthrough, FundingBeacon.migrate and the output index checks
around the ProviderShare) so a single transaction can satisfy both contracts.

Comment on lines +159 to +227
function providerWithdraw(int withdrawSats, pubkey providerPk) {
require(withdrawSats >= 330, "withdraw below dust");

int currentPrice = tx.inputs[1].assets.lookup(priceTicker);
require(currentPrice > 0, "invalid price beacon");
int priceHeight = tx.inputs[1].assets.lookup(priceClock);
int priceAge = tx.time - priceHeight;
require(priceAge <= 144, "stale price oracle");

int currentIndex = tx.inputs[2].assets.lookup(yieldTicker);
int yieldHeight = tx.inputs[2].assets.lookup(yieldClock);
int yieldAge = tx.time - yieldHeight;
require(yieldAge <= 144, "stale funding oracle");
require(currentIndex >= poolYieldIndex, "yield index regressed");

// Compute current seeker capital claim including accrued funding.
int seekerCapitalNominal = aggregateSeekerUSD * 100000000 / currentPrice;
int deltaIndex = currentIndex - poolYieldIndex;
int fundingAccrued = aggregateSeekerUSD * deltaIndex / 100000000;
int seekerCapital = seekerCapitalNominal + fundingAccrued;

int totalAfter = tx.inputs[0].value - withdrawSats;
require(totalAfter > seekerCapital, "withdraw would undercollateralise");

// Leverage gate: 3 × seekerCapital <= totalAfter ⇔ leverage_after <= 1.5
int leverageBound = seekerCapital * 3;
require(leverageBound <= totalAfter, "leverage gate: provider withdraw blocked");

// The ProviderShare being burned is at input[3]; the share contract
// enforces its own redemption rules (signature check, equity math).
// The pool covenant here only enforces pool-level invariants.

// Pool output: refreshed yield index, unchanged aggregateSeekerUSD,
// value reduced by withdrawSats.
require(
tx.outputs[0].scriptPubKey == new StabilityPool(
priceTicker, priceClock, yieldTicker, yieldClock,
aggregateSeekerUSD, currentIndex, exit
),
"invalid pool output"
);
require(tx.outputs[0].value >= totalAfter, "pool value mismatch");

// PriceBeacon passthrough
require(
tx.outputs[1].assets.lookup(priceTicker) >= currentPrice,
"price beacon must survive"
);
require(
tx.outputs[1].assets.lookup(priceClock) >= priceHeight,
"price clock must survive"
);

// FundingBeacon passthrough
require(
tx.outputs[2].assets.lookup(yieldTicker) >= currentIndex,
"funding beacon must survive"
);
require(
tx.outputs[2].assets.lookup(yieldClock) >= yieldHeight,
"funding clock must survive"
);

// Provider payout
require(
tx.outputs[3].scriptPubKey == new SingleSig(providerPk),
"output 3 not provider"
);
require(tx.outputs[3].value >= withdrawSats, "provider underpaid");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

providerWithdraw() never proves ownership of a ProviderShare.

providerPk is just witness data for the payout script here, and the function never inspects tx.inputs[3] despite the comment saying a share is being burned there. In the generated fallback path, any signer can pick their own providerPk after exit and withdraw up to the leverage limit. This flow needs to stay disabled until the withdraw is cryptographically bound to a matching burned share and its entitlement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/stability/stability_pool.ark` around lines 159 - 227,
providerWithdraw currently uses providerPk from the witness without proving a
matching ProviderShare is being burned; require that tx.inputs[3] is the
ProviderShare being redeemed and that its on-chain entitlement binds to
providerPk and withdrawSats. Concretely, validate tx.inputs[3].scriptPubKey ==
new ProviderShare(...expected params...) or otherwise read the ProviderShare
fields from tx.inputs[3] and require its owner pubkey equals providerPk and its
recorded share/entitlement covers withdrawSats (and consume the share by
requiring it as an input); keep the existing pool-level checks
(aggregateSeekerUSD, poolYieldIndex, exit) but block the fallback path unless
the above ProviderShare ownership/entitlement checks pass.

Comment on lines +175 to +177
int seekerCapitalNominal = aggregateSeekerUSD * 100000000 / currentPrice;
int deltaIndex = currentIndex - poolYieldIndex;
int fundingAccrued = aggregateSeekerUSD * deltaIndex / 100000000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This leverage math overflows before the scale the PRD calls out.

aggregateSeekerUSD * 100000000 already exceeds signed 64-bit above roughly $922M, and aggregateSeekerUSD * deltaIndex has no bound at all. Once either multiply wraps, the collateral and leverage checks are evaluating garbage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/stability/stability_pool.ark` around lines 175 - 177, The math for
seekerCapitalNominal and fundingAccrued can overflow 64-bit: avoid multiplying
aggregateSeekerUSD by 100000000 or deltaIndex directly. Change calculations in
the seekerCapitalNominal, deltaIndex, and fundingAccrued logic to use a wider
integer or safe multiply/divide routine (e.g., 128-bit intermediate or a checked
mulDiv) and/or reorder to divide first (compute aggregateSeekerUSD /
currentPrice then * scale, or use mulDiv(aggregateSeekerUSD, scale,
currentPrice)) and validate bounds on deltaIndex (currentIndex - poolYieldIndex)
before use; ensure all references to seekerCapitalNominal and fundingAccrued use
the overflow-safe implementation and add unit checks to prevent wrapped values
from passing collateral/leverage checks.

…into claude/pooled-model-exploration-ocTK8

@arkanaai arkanaai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Follow-up Review — #30 (post-merge of master)

Reviewer: Arkana (AI)
Scope: Only the new merge commit 9c2c8c4 (brings in #25: validation layer + tests from master). The stability pool contracts are unchanged — my previous review findings still apply.


Previous findings status

All critical/high findings from my first review remain unresolved — the stability pool contract files were not modified in this update:

  1. 🔴 Exit variant on singleton pool UTXO — still open
  2. 🔴 No covenant-level verification of ProviderShare input — still open
  3. 🟡 No on-chain rate cap on FundingBeacon — still open
  4. 🟡 No tests for new contracts — partially addressed (see below)
  5. 🟡 Compiler type-mixing warnings — still open

New findings from the merge

🟡 MEDIUM — Missing named roundtrip tests for protocol-critical contracts

The merge brings in tests/compilation_roundtrip_test.rs with named tests for price_beacon, stability_vault, and stability_offer — but not for the three new contracts in this PR: funding_beacon.ark, stability_pool.ark, provider_share.ark.

The generic sweep test (all_examples_compile_and_satisfy_invariants) catches them, but named tests provide better failure diagnostics for protocol-critical code. Add:

#[test]
fn roundtrip_funding_beacon() { ... }
#[test]
fn roundtrip_stability_pool() { ... }
#[test]
fn roundtrip_provider_share() { ... }

ℹ️ INFO — New validator correctly handles these contracts

Verified that the new AST validator (src/validator/mod.rs) correctly passes all three stability pool contracts:

  • Non-empty names, unique function names, unique parameters ✅
  • options.server with corresponding options.exit
  • All non-internal functions contain require() statements ✅

No false positives or negatives for this PR's contracts.

ℹ️ INFO — Output invariant errors don't halt compilation

src/compiler/mod.rs:266-278: The new output validator surfaces detected compiler bugs as warnings but still returns Ok(json). For a Bitcoin L2 compiler, a detected compiler bug should halt compilation. This is a concern for the compiler generally (not specific to this PR), but worth noting since this PR's contracts will now run through this pipeline.


Verdict

Request changes — previous critical findings remain unresolved. The merge itself is clean and the validator integration is correct. Please address the exit-variant and ProviderShare verification concerns from the first review before merge.

⚠️ Protocol-critical code (pooled BTC covenant). Human review required.

🤖 Generated with Claude Code

@tiero tiero marked this pull request as draft May 31, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants