Skip to content

feat: add preview functions for reactor operations#19

Open
arunabha003 wants to merge 1 commit into
StabilityNexus:mainfrom
arunabha003:feat/reactor-preview-functions
Open

feat: add preview functions for reactor operations#19
arunabha003 wants to merge 1 commit into
StabilityNexus:mainfrom
arunabha003:feat/reactor-preview-functions

Conversation

@arunabha003

@arunabha003 arunabha003 commented Mar 30, 2026

Copy link
Copy Markdown

Addressed Issues:

closes #18

Summary

  • add read-only preview functions for fission, fusion, and both transmute flows
  • expose expected outputs, fees, and intermediate value-path data for integrations and testing
  • add focused tests proving preview results match actual execution across bootstrap, steady-state, and beta-decay scenarios

Validation

  • forge test -vv

Additional Notes:

AI Usage Disclosure:

We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact. AI slop is strongly discouraged and may lead to banning and blocking. Do not spam our repos with AI slop.

Check one of the checkboxes below:

  • This PR does not contain AI-generated code at all.
  • This PR contains AI-generated code. I have read the AI Usage Policy and this PR complies with this policy. I have tested the code locally and I am responsible for it.

I have used the following AI models and tools: TODO

Checklist

  • My PR addresses a single issue, fixes a single bug or makes a single improvement.
  • My code follows the project's code style and conventions
  • If applicable, I have made corresponding changes or additions to the documentation
  • If applicable, I have made corresponding changes or additions to tests
  • My changes generate no new warnings or errors
  • I have joined the Discord server and I will share a link to this PR with the project maintainers there
  • I have read the Contribution Guidelines
  • Once I submit my PR, CodeRabbit AI will automatically review it and I will address CodeRabbit's comments.
  • I have filled this PR template completely and carefully, and I understand that my PR may be closed without review otherwise.

Summary by CodeRabbit

  • New Features

    • Added four read-only query functions to preview estimated outcomes for fission, fusion, and transmutation operations without executing state changes or ledger updates.
  • Tests

    • Added comprehensive test suite covering bootstrap scenarios, steady-state execution, time-dependent decay behavior, and validation that all previewed values match actual transaction results.

@coderabbitai

coderabbitai Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR adds four read-only preview functions to StableCoinReactor enabling off-chain quotation of fission, fusion, and transmute operation outputs and fees. Internal fee calculators use block-timestamp-based logic without state mutations. Test mocks and comprehensive test coverage validate preview accuracy against execution behavior.

Changes

Cohort / File(s) Summary
Preview Functions & Helpers
src/StableCoin.sol
Added four external view preview functions (previewFission, previewFusion, previewTransmuteProtonToNeutron, previewTransmuteNeutronToProton) with internal fee calculators (_previewBetaPlusFeeWad, _previewBetaMinusFeeWad) that compute outputs and fees without state changes; minor formatting adjustments to existing function signatures.
Test Infrastructure
test/StableCoinPreview.t.sol, test/mocks/MockERC20.sol, test/mocks/MockPyth.sol
New test contract with five validation functions confirming preview outputs match execution behavior across bootstrap/steady-state fission, fusion, and transmute with decay; supporting mock ERC20 and mock Pyth oracle for test isolation and configuration.

Poem

🐰 A rabbit's delight in transparent code,
Preview functions light the load,
No more guessing fees in flight,
Quote before you transact right!
Mocks and tests dance hand-in-hand,
Fission, fusion, all well-planned!

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 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 clearly and concisely describes the main change: adding preview functions for reactor operations, which matches the primary objective and changeset.
Linked Issues check ✅ Passed All four preview functions requested in issue #18 are implemented: previewFission, previewFusion, previewTransmuteProtonToNeutron, and previewTransmuteNeutronToProton, with comprehensive tests validating outputs and fees.
Out of Scope Changes check ✅ Passed All changes are within scope: new preview functions in StableCoin.sol, comprehensive test suite in StableCoinPreview.t.sol, and necessary test mocks (MockERC20, MockPyth). No unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
test/StableCoinPreview.t.sol (1)

119-125: Assert the transmute preview intermediates too.

grossBase and netBase are part of the new public API, but both tests discard them. A regression in those fields would still pass even though integrations may rely on them. reactor.protonPriceInBase() / reactor.neutronPriceInBase() are enough to derive and pin them here.

Also applies to: 137-142

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/StableCoinPreview.t.sol` around lines 119 - 125, The test calls
reactor.previewTransmuteProtonToNeutron and discards the returned grossBase and
netBase, which can regress silently; update the test to assert those
intermediates by computing expectedGrossBase and expectedNetBase using
reactor.protonPriceInBase() / reactor.neutronPriceInBase() and the input amount
(15e18), then compare them to the preview output (grossBase, netBase) before
calling reactor.transmuteProtonToNeutron; apply the same assertions in the other
similar test block (lines around 137-142) so both
previewTransmuteProtonToNeutron and previewTransmuteNeutronToProton return
pinned grossBase/netBase values that match derived expectations.
src/StableCoin.sol (1)

436-480: Share the decay projection logic between preview and execution.

_decayLedger, _previewBetaPlusFeeWad, and _previewBetaMinusFeeWad each implement the same decay step. Extracting a single helper that projects decayedVolumeBase for a timestamp will make it much harder for preview math and execution math to drift apart later.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/StableCoin.sol` around lines 436 - 480, The decay step is duplicated
across _decayLedger, _previewBetaPlusFeeWad, and _previewBetaMinusFeeWad;
extract a single helper (e.g. _projectDecayedVolumeBase(uint256 atTs) internal
view returns (int256)) that takes a timestamp and returns the projected
decayedVolumeBase using decayPerSecondWad, lastDecayTs and _rpow, preserving
sign handling. Replace the repeated block in _previewBetaPlusFeeWad and
_previewBetaMinusFeeWad to call this helper (pass block.timestamp) and likewise
have _decayLedger call it when computing the new decayedVolumeBase so previews
and execution share identical decay logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/StableCoin.sol`:
- Around line 261-275: The preview functions compute outputs assuming the caller
can burn tokens later, but they don't guard impossible cases where protonIn or
neutronIn exceed PROTON_TOKEN.totalSupply() or NEUTRON_TOKEN.totalSupply(); add
explicit checks in the preview paths (the functions that mirror the
state-changing flows which perform burns) to require protonIn <=
protonSupplyCached and neutronIn <= neutronSupplyCached as appropriate,
returning/reverting early with a clear error (e.g., "insufficient total supply
for burn") before price math (use the existing protonSupplyCached and
neutronSupplyCached variables and the same totalSupply() calls used elsewhere);
apply the same guard to the other preview variant mentioned (the block around
the alternate snippet at lines 286-300).
- Around line 240-250: previewFusion currently computes
neutronBurn/protonBurn/feeAmount/netBaseOut even when m > reserveBalance, but
actual fusion would revert when transferring more than reserve; update
previewFusion (the function named previewFusion that reads reserve(), computes
reserveBalance, neutronBurn, protonBurn, feeAmount, netBaseOut) to check and
revert when m > reserveBalance (e.g., require(m <= reserveBalance, "insufficient
reserve") or mirror the same condition used in fusion) before calculating and
returning the quote so previewFusion matches on-chain behavior.

In `@test/mocks/MockPyth.sol`:
- Around line 10-11: The mock currently stores a single Price in currentPrice
and ignores the requested feed id; modify the mock to be id-sensitive by
changing the storage to a mapping(bytes32 => Price) (or similar) keyed by the
feed id, update setPrice to accept an id parameter (e.g. bytes32 id) and assign
the Price into mapping[id], and update all getters (the functions that currently
read currentPrice) to lookup and return mapping[id] instead of the single
currentPrice; also update tests to call setPrice(PRICE_ID, ...) so the mock
returns the correct feed-specific value.

---

Nitpick comments:
In `@src/StableCoin.sol`:
- Around line 436-480: The decay step is duplicated across _decayLedger,
_previewBetaPlusFeeWad, and _previewBetaMinusFeeWad; extract a single helper
(e.g. _projectDecayedVolumeBase(uint256 atTs) internal view returns (int256))
that takes a timestamp and returns the projected decayedVolumeBase using
decayPerSecondWad, lastDecayTs and _rpow, preserving sign handling. Replace the
repeated block in _previewBetaPlusFeeWad and _previewBetaMinusFeeWad to call
this helper (pass block.timestamp) and likewise have _decayLedger call it when
computing the new decayedVolumeBase so previews and execution share identical
decay logic.

In `@test/StableCoinPreview.t.sol`:
- Around line 119-125: The test calls reactor.previewTransmuteProtonToNeutron
and discards the returned grossBase and netBase, which can regress silently;
update the test to assert those intermediates by computing expectedGrossBase and
expectedNetBase using reactor.protonPriceInBase() / reactor.neutronPriceInBase()
and the input amount (15e18), then compare them to the preview output
(grossBase, netBase) before calling reactor.transmuteProtonToNeutron; apply the
same assertions in the other similar test block (lines around 137-142) so both
previewTransmuteProtonToNeutron and previewTransmuteNeutronToProton return
pinned grossBase/netBase values that match derived expectations.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd7c727b-acb1-4874-b202-7a5d34d13924

📥 Commits

Reviewing files that changed from the base of the PR and between 3b68843 and a200d77.

📒 Files selected for processing (4)
  • src/StableCoin.sol
  • test/StableCoinPreview.t.sol
  • test/mocks/MockERC20.sol
  • test/mocks/MockPyth.sol

Comment thread src/StableCoin.sol
Comment on lines +240 to +250
uint256 reserveBalance = reserve();
require(reserveBalance > 0, "R=0");

uint256 neutronSupplyTotal = NEUTRON_TOKEN.totalSupply();
uint256 protonSupplyTotal = PROTON_TOKEN.totalSupply();
require(neutronSupplyTotal > 0 && protonSupplyTotal > 0, "empty S");

neutronBurn = Math.mulDiv(m, neutronSupplyTotal, reserveBalance);
protonBurn = Math.mulDiv(m, protonSupplyTotal, reserveBalance);
feeAmount = Math.mulDiv(m, FUSION_FEE, WAD);
netBaseOut = m - feeAmount;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reject previews for withdrawals larger than the reserve.

In src/StableCoin.sol Line 380-Line 381, fusion transfers net + fee == m base tokens out of the vault. If m > reserveBalance, execution must revert, but previewFusion still returns a quote.

🛡️ Proposed fix
         uint256 reserveBalance = reserve();
         require(reserveBalance > 0, "R=0");
+        require(m <= reserveBalance, "amount>reserve");
 
         uint256 neutronSupplyTotal = NEUTRON_TOKEN.totalSupply();
         uint256 protonSupplyTotal = PROTON_TOKEN.totalSupply();
📝 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
uint256 reserveBalance = reserve();
require(reserveBalance > 0, "R=0");
uint256 neutronSupplyTotal = NEUTRON_TOKEN.totalSupply();
uint256 protonSupplyTotal = PROTON_TOKEN.totalSupply();
require(neutronSupplyTotal > 0 && protonSupplyTotal > 0, "empty S");
neutronBurn = Math.mulDiv(m, neutronSupplyTotal, reserveBalance);
protonBurn = Math.mulDiv(m, protonSupplyTotal, reserveBalance);
feeAmount = Math.mulDiv(m, FUSION_FEE, WAD);
netBaseOut = m - feeAmount;
uint256 reserveBalance = reserve();
require(reserveBalance > 0, "R=0");
require(m <= reserveBalance, "amount>reserve");
uint256 neutronSupplyTotal = NEUTRON_TOKEN.totalSupply();
uint256 protonSupplyTotal = PROTON_TOKEN.totalSupply();
require(neutronSupplyTotal > 0 && protonSupplyTotal > 0, "empty S");
neutronBurn = Math.mulDiv(m, neutronSupplyTotal, reserveBalance);
protonBurn = Math.mulDiv(m, protonSupplyTotal, reserveBalance);
feeAmount = Math.mulDiv(m, FUSION_FEE, WAD);
netBaseOut = m - feeAmount;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/StableCoin.sol` around lines 240 - 250, previewFusion currently computes
neutronBurn/protonBurn/feeAmount/netBaseOut even when m > reserveBalance, but
actual fusion would revert when transferring more than reserve; update
previewFusion (the function named previewFusion that reads reserve(), computes
reserveBalance, neutronBurn, protonBurn, feeAmount, netBaseOut) to check and
revert when m > reserveBalance (e.g., require(m <= reserveBalance, "insufficient
reserve") or mirror the same condition used in fusion) before calculating and
returning the quote so previewFusion matches on-chain behavior.

Comment thread src/StableCoin.sol
Comment on lines +261 to +275
uint256 reserveTokens = reserve();
uint256 protonSupplyCached = PROTON_TOKEN.totalSupply();
uint256 neutronSupplyCached = NEUTRON_TOKEN.totalSupply();

Price memory price = PYTH_ORACLE.getPriceNoOlderThan(PRICE_ID, MAXIMUM_AGE);
uint256 basePrice = _pythPriceToWad(price);

uint256 protonPriceBase = _protonPriceInBase(reserveTokens, protonSupplyCached, neutronSupplyCached, basePrice);
uint256 neutronPriceBase = _neutronPriceInBase(reserveTokens, neutronSupplyCached, basePrice);
require(protonPriceBase > 0 && neutronPriceBase > 0, "bad price");

grossBase = Math.mulDiv(protonIn, protonPriceBase, WAD);
feeWad = _previewBetaPlusFeeWad(reserveTokens);
netBase = Math.mulDiv(grossBase, (WAD - feeWad), WAD);
neutronOut = Math.mulDiv(netBase, WAD, neutronPriceBase);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard impossible preview burns with total-supply checks.

The state-changing paths burn the input token on Line 515 and Line 571. If protonIn or neutronIn is larger than the corresponding total supply, no caller can satisfy that burn, but these previews still return outputs.

🛡️ Proposed fix
 function previewTransmuteProtonToNeutron(uint256 protonIn)
     external
     view
     returns (uint256 neutronOut, uint256 feeWad, uint256 grossBase, uint256 netBase)
 {
     require(protonIn > 0, "amount=0");
 
     uint256 reserveTokens = reserve();
     uint256 protonSupplyCached = PROTON_TOKEN.totalSupply();
     uint256 neutronSupplyCached = NEUTRON_TOKEN.totalSupply();
+    require(protonIn <= protonSupplyCached, "amount>totalSupply");
 function previewTransmuteNeutronToProton(uint256 neutronIn)
     external
     view
     returns (uint256 protonOut, uint256 feeWad, uint256 grossBase, uint256 netBase)
 {
     require(neutronIn > 0, "amount=0");
 
     uint256 reserveTokens = reserve();
     uint256 protonSupplyCached = PROTON_TOKEN.totalSupply();
     uint256 neutronSupplyCached = NEUTRON_TOKEN.totalSupply();
+    require(neutronIn <= neutronSupplyCached, "amount>totalSupply");

Also applies to: 286-300

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/StableCoin.sol` around lines 261 - 275, The preview functions compute
outputs assuming the caller can burn tokens later, but they don't guard
impossible cases where protonIn or neutronIn exceed PROTON_TOKEN.totalSupply()
or NEUTRON_TOKEN.totalSupply(); add explicit checks in the preview paths (the
functions that mirror the state-changing flows which perform burns) to require
protonIn <= protonSupplyCached and neutronIn <= neutronSupplyCached as
appropriate, returning/reverting early with a clear error (e.g., "insufficient
total supply for burn") before price math (use the existing protonSupplyCached
and neutronSupplyCached variables and the same totalSupply() calls used
elsewhere); apply the same guard to the other preview variant mentioned (the
block around the alternate snippet at lines 286-300).

Comment thread test/mocks/MockPyth.sol
Comment on lines +10 to +11
function setPrice(int64 price_, int32 expo_, uint256 publishTime_) external {
currentPrice = Price({price: price_, conf: 0, expo: expo_, publishTime: publishTime_});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make the mock price feed ID-sensitive.

test/StableCoinPreview.t.sol Line 27-Line 35 wires a concrete PRICE_ID into StableCoinReactor, but every getter here ignores the requested id and returns the same price. These tests will still pass if the contract looks up the wrong feed.

🧪 Proposed fix
 contract MockPyth is IPyth {
-    Price internal currentPrice;
+    mapping(bytes32 => Price) internal prices;
     uint256 internal updateFee;

-    function setPrice(int64 price_, int32 expo_, uint256 publishTime_) external {
-        currentPrice = Price({price: price_, conf: 0, expo: expo_, publishTime: publishTime_});
+    function setPrice(bytes32 id, int64 price_, int32 expo_, uint256 publishTime_) external {
+        prices[id] = Price({price: price_, conf: 0, expo: expo_, publishTime: publishTime_});
     }
@@
-    function getPrice(bytes32) external view returns (Price memory price) {
-        return currentPrice;
+    function getPrice(bytes32 id) external view returns (Price memory price) {
+        return prices[id];
     }

-    function getEmaPrice(bytes32) external view returns (Price memory price) {
-        return currentPrice;
+    function getEmaPrice(bytes32 id) external view returns (Price memory price) {
+        return prices[id];
     }

-    function getPriceUnsafe(bytes32) external view returns (Price memory price) {
-        return currentPrice;
+    function getPriceUnsafe(bytes32 id) external view returns (Price memory price) {
+        return prices[id];
     }

-    function getPriceNoOlderThan(bytes32, uint256 age) external view returns (Price memory price) {
-        require(currentPrice.publishTime + age >= block.timestamp, "stale");
-        return currentPrice;
+    function getPriceNoOlderThan(bytes32 id, uint256 age) external view returns (Price memory price) {
+        price = prices[id];
+        require(price.publishTime + age >= block.timestamp, "stale");
+        return price;
     }

test/StableCoinPreview.t.sol Line 25 would then need to pass PRICE_ID into setPrice.

Also applies to: 22-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/mocks/MockPyth.sol` around lines 10 - 11, The mock currently stores a
single Price in currentPrice and ignores the requested feed id; modify the mock
to be id-sensitive by changing the storage to a mapping(bytes32 => Price) (or
similar) keyed by the feed id, update setPrice to accept an id parameter (e.g.
bytes32 id) and assign the Price into mapping[id], and update all getters (the
functions that currently read currentPrice) to lookup and return mapping[id]
instead of the single currentPrice; also update tests to call setPrice(PRICE_ID,
...) so the mock returns the correct feed-specific value.

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.

[FEATURE]: add preview functions for reactor operations

1 participant