Skip to content

feat: add CreditMessagingRecovery for emergency credit recovery#532

Open
clauBv23 wants to merge 34 commits intomainfrom
feat/min-burn-credits
Open

feat: add CreditMessagingRecovery for emergency credit recovery#532
clauBv23 wants to merge 34 commits intomainfrom
feat/min-burn-credits

Conversation

@clauBv23
Copy link
Copy Markdown
Contributor

@clauBv23 clauBv23 commented Apr 1, 2026

Problem

Credits in Stargate V2 act as a capacity gate — they must be held on a chain before tokens can be redeemed or sent cross-chain. When a chain is deprecated without properly returning its credits to origin chains, those credits become permanently lost. This leaves pools with real token balances but depleted path credits, blocking all user exit paths.

Example: Sei USDT pool holds $27.7k but only $4.2k in usable credits — $23.5k is effectively locked with no way out.

Credits are not a security primitive (they do not custody funds — every actual fund movement requires independent authorization via LP tokens or a valid cross-chain token message). They are a liveness primitive.

Solution

CreditMessagingRecovery extends CreditMessaging with two owner-only admin functions. Both operate locally only — no LayerZero message is ever sent, no cross-chain coordination is required, and no LZ fees are incurred.

mintCredits(CreditBatch[], reason) — increases credits on the current chain by calling ICreditMessagingHandler.receiveCredits(0, credits) directly, bypassing the normal credit validation. Used to restore lost credits on affected chains.

burnCredits(TargetCreditBatch[], reason) — decreases credits on the current chain by calling ICreditMessagingHandler.sendCredits(0, targets) locally, stopping before _lzSend. Uses minAmount = amount (all-or-nothing), reverting if the path has insufficient credits. Used to correct over-minted credits.

Both functions require a non-empty plain-text reason string that is emitted on-chain as a permanent audit trail, as per the team discussion.

Safety

  • Credits cannot move funds on their own. Over-injecting local credits beyond pool balance causes failed transactions — the pool physically cannot send what it does not hold. Over-burning locks funds in the pool (same situation as the bug being fixed).
  • mintCredits and burnCredits are onlyOwner (multisig), not planner-accessible.
  • sendCredits remains planner-accessible and unchanged.
  • Treasury fees and recoverToken are unaffected — they operate on poolBalanceSD and treasuryFee, which credits never touch.
  • No LZ message means no DVN dependency, no peer configuration needed, and no cross-chain reorg risk.

Changes

  • New contractsrc/messaging/CreditMessagingRecovery.sol
    Extends CreditMessaging with mintCredits and burnCredits.

  • New interfacesrc/interfaces/ICreditMessagingRecovery.sol
    Exposes the admin functions, the CreditMessagingRecovery_EmptyReason error, and the CreditsMinted / CreditsBurned events.

  • Deploy scriptdeploy/003-deploy-message.ts
    Non-alt chains now deploy CreditMessagingRecovery under the existing CreditMessaging deployment name. Alt chains continue deploying CreditMessagingAlt unchanged.

  • Unit teststest/unitest/messaging/Messaging.CreditRecovery.t.sol
    Extends CreditMessagingTest to prove backward compatibility — all parent tests run against CreditMessagingRecovery. Recovery-specific coverage includes: access control, empty reason revert, unavailable asset revert, local-only operation (no PacketSent event), multiple batches with multiple credits per batch, partial burns (handler returns less than requested), and empty credits arrays.

  • Existing credit messaging teststest/unitest/messaging/Messaging.Credit.t.sol
    Added tests asserting that mintCredits and burnCredits produce an empty revert (function does not exist) on the base CreditMessaging contract.

TBD

  • Should we create a similar contract for alt chains? For now CreditMessagingAlt is unchanged — the mesh can be balanced using a single CreditMessagingRecovery deployment.
  • There are invariant tests that check that total credits equal pool balance.
    Mint/burn intentionally breaks this invariant — but so did the original bug: disconnecting a chain without returning credits already left the mesh in a state where total credits no longer matched pool balances. We decided against adjusting the invariant tests since these are emergency-only functions and the fuzzer is better spent on the normal credit flow. Gathering team thoughts on this.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 1, 2026

⚠️ No Changeset found

Latest commit: 4543ff1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an emergency-only credit recovery mechanism to Stargate V2 EVM messaging by introducing an owner-controlled MintBurnCreditMessaging contract that can mint credits cross-chain without local deduction and burn local credits without sending an LZ message, plus deployment + unit test updates.

Changes:

  • Introduces MintBurnCreditMessaging with mintCredits, burnCredits, and quoteMintCredits.
  • Adds IMintBurnCreditMessaging interface for the new admin API surface.
  • Updates deployment to use MintBurnCreditMessaging (non-ALT) while keeping the deployment name CreditMessaging, and adds/updates unit tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/stg-evm-v2/test/unitest/messaging/Messaging.MintBurnCredit.t.sol New unit tests for mint/burn credit emergency flows and access control.
packages/stg-evm-v2/test/unitest/messaging/Messaging.Credit.t.sol Adds assertions that base CreditMessaging does not expose mint/burn functions (but encoding needs fixing).
packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol New contract extending CreditMessaging with owner-only mint/burn credit operations and fee quoting.
packages/stg-evm-v2/src/interfaces/IMintBurnCreditMessaging.sol New interface for emergency credit management API (parameter locations/events need alignment with intent).
packages/stg-evm-v2/deploy/003-deploy-message.ts Deploys MintBurnCreditMessaging under the CreditMessaging deployment name for non-ALT networks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.Credit.t.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.Credit.t.sol Outdated
Comment thread packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol Outdated
Comment thread packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol Outdated
Comment thread packages/stg-evm-v2/src/interfaces/IMintBurnCreditMessaging.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.MintBurnCredit.t.sol Outdated
Comment thread packages/stg-evm-v2/src/interfaces/ICreditMessagingRecovery.sol Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.MintBurnCredit.t.sol Outdated
Comment on lines +19 to +29
/// @dev Credits are sent to the destination chain without being deducted on the current chain.
function mintCredits(
uint32 _dstEid,
CreditBatch[] calldata _batches,
string calldata _reason
) external payable onlyOwner {
if (bytes(_reason).length == 0) revert MintBurnCreditMessaging_EmptyReason();
(bytes memory message, bytes memory options) = _buildMessagePayload(_dstEid, _batches);
_lzSend(_dstEid, message, options, MessagingFee(msg.value, 0), msg.sender);
emit CreditsMinted(_dstEid, _batches, _reason);
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

mintCredits / quoteMintCredits can be called with _batches that contain zero total credits (empty array or batches with empty credits). In that case totalCreditNum becomes 0 and _buildOptions(_dstEid, 0) produces an options payload with a 0 gas value, then mintCredits still calls _lzSend with a no-op message. Consider adding a guard to revert (or no-op without sending) when totalCreditNum == 0, mirroring CreditMessaging.sendCredits which skips _lzSend when there are no credits to send.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

intentionally omitted. mintCredits and burnCredits are emergency owner-only operations that require careful review by signers and engineers before execution, including a mandatory non-empty reason. The inputs are expected to be validated off-chain before the multisig signs. This check for dummies seems not needed imo

Copy link
Copy Markdown
Collaborator

@ravinagill15 ravinagill15 left a comment

Choose a reason for hiding this comment

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

Nice 🚀 let's add a changeset as well

Comment thread packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.MintBurnCredit.t.sol Outdated
Comment thread packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol Outdated
Comment thread packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol Outdated
if (bytes(_reason).length == 0) revert MintBurnCreditMessaging_EmptyReason();
(bytes memory message, bytes memory options) = _buildMessagePayload(_dstEid, _batches);
_lzSend(_dstEid, message, options, MessagingFee(msg.value, 0), msg.sender);
emit CreditsMinted(_dstEid, _batches, _reason);
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.

Unrelated to the code, but have we thought about how the planner/indexer will handle the new event topology?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

you mean when new credits were created?

I'm not sure we have an indexer here. I think the planner just queries all the information on-chain and gets the real credits in the mesh. Will double check

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.

I don't know how the new events will affect any backend system that we already have, since CreditsSent and CreditsReceived are emitted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just confirmed the indexer does not index credits moving

import { IMintBurnCreditMessaging } from "../interfaces/IMintBurnCreditMessaging.sol";
import { ICreditMessagingHandler, Credit } from "../interfaces/ICreditMessagingHandler.sol";

contract MintBurnCreditMessaging is CreditMessaging, IMintBurnCreditMessaging {
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.

I'd consider renaming to CreditMessagingRecoverable, CreditMessagingAdjustable, or CreditMessagingMintableBurnable to keep the consistency with Stargate contract names:

  1. Suffixes over prefixes. This feels like something we should respect.
  2. Use-cases over actions (StargatePoolMigratable instead of StargatePoolBurnable). This is more subjective and I don't have a strong opinion.

We could also consider renaming mintCredits and burnCredits to mintCreditsForRecovery and burnCreditsForRecovery. No strong opinion here either, just sharing my thoughts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree, however, I renamed to CreditMessagingMintableBurnable, I agree with "Use-cases over actions" but I don't like CreditMessagingRecoverable or CreditMessagingAdjustable and I cannot find a better name that describes the use case 🤔

Copy link
Copy Markdown
Contributor Author

@clauBv23 clauBv23 Apr 6, 2026

Choose a reason for hiding this comment

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

Renamed to CreditMessagingRecovery it's a credit messaging contract that performs recovery.
658e380

Kept the function names as mintCredits and burnCredits for now, I don't like adding the for recovery, it seems too verbose.

I have been thinking about restoreCredits / trimCredits, first one implies bringing something back to what it should be, and second implies removing excess, both suggest the recovery intent. But not sure if it would make it harder to understand if the credits are increasing or decreasing. wdyt @tinom9 ?

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.

restoreCredits and trimCredits seem less verbose, what do they really mean? I prefer mint-burn.

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.

Let's update the PR title?

Copy link
Copy Markdown
Contributor Author

@clauBv23 clauBv23 Apr 6, 2026

Choose a reason for hiding this comment

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

updated PR title and description. Keeping mint burn for now

Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.Credit.t.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.Credit.t.sol Outdated
Comment thread packages/stg-evm-v2/src/messaging/MintBurnCreditMessaging.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.MintBurnCredit.t.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.MintBurnCredit.t.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.CreditRecovery.t.sol Outdated
@clauBv23 clauBv23 requested review from ravinagill15 and tinom9 April 6, 2026 16:31
Comment thread packages/stg-evm-v2/src/messaging/CreditMessagingRecovery.sol Outdated
Comment thread packages/stg-evm-v2/src/messaging/CreditMessagingRecovery.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.CreditRecovery.t.sol Outdated
Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.CreditRecovery.t.sol Outdated
Comment on lines +298 to +304
function _mockStargateReceiveCredits(Credit[] memory _credits) internal {
_mockStargateReceiveCredits(STARGATE_IMPL, _credits);
}

function _mockStargateReceiveCredits(address _stargate, Credit[] memory _credits) internal {
vm.mockCall(_stargate, abi.encodeCall(ICreditMessagingHandler.receiveCredits, (0, _credits)), "");
}
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.

I don't love that we're mocking this.

It's a fundamentally important integration of this contract.

That said, this is consistent with the codebase, so I think it's okay to keep this file as is.

Can we have some integration tests though?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

agree we should add integratio test for this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added f6cd53a

Comment thread packages/stg-evm-v2/test/unitest/messaging/Messaging.Credit.t.sol Outdated
error CreditMessagingRecovery_EmptyReason();

/// @notice Emitted when credits are minted locally without a corresponding debit on a source chain.
event CreditsMinted(uint16 assetId, Credit[] credits, string reason);
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.

Let's index assetId?

Suggested change
event CreditsMinted(uint16 assetId, Credit[] credits, string reason);
event CreditsMinted(uint16 indexed assetId, Credit[] credits, string reason);

Same for CreditsBurned.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

asset id is removed now, it would be on each CreditBatch item

Comment thread packages/stg-evm-v2/src/messaging/CreditMessagingRecovery.sol
@clauBv23 clauBv23 changed the title feat: add MintBurnCreditMessaging for emergency credit recovery feat: add CreditMessagingRecovery for emergency credit recovery Apr 6, 2026
@clauBv23 clauBv23 requested a review from tinom9 April 6, 2026 22:04
StargateBaseTestC internal pool;

function setUp() public {
pool = new StargateBaseTestC();
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.

What about testing behaviour with both StargatePool and StargateOFT, instead of StargateBase mocks?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added 6b56b75

@clauBv23 clauBv23 requested a review from tinom9 April 6, 2026 23:29
ravinagill15
ravinagill15 previously approved these changes Apr 7, 2026
Copy link
Copy Markdown
Contributor

@tinom9 tinom9 left a comment

Choose a reason for hiding this comment

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

Contracts look good to me!

import { ICreditMessagingRecovery } from "../interfaces/ICreditMessagingRecovery.sol";
import { ICreditMessagingHandler, Credit } from "../interfaces/ICreditMessagingHandler.sol";

contract CreditMessagingRecovery is CreditMessaging, ICreditMessagingRecovery {
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.

Only comment I would make is I dont like the name CreditMessagingRecovery, really should be something like CreditMessagingV2 imo.

Further to that, I think to simplify the system and risk of deploying on new chains, that perhaps we deploy this on arbitrum only, and not new chains moving forward. We dont need to know that before sending to audit, and also regarding the contract name, not a huge deal to change mid audit as well. The rest of impl makes sense.

Another thing to note is that currently if the planner has the contract paused, then we cant mint or burn credits. So this creates a bit of an issue if it MUST remain paused during the mint.burn recovery or migration / chain deprecation. However I think this is fine becaause worst case the owner can 1: transfer 'planner' to the owner, 2: unpause 3: mint/burncredits, 4: pause, 5: transfer planner back to the planner. This creates an atomic situation albeit, quite complicated.

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.

My intuition is that we only want to deploy once.

if so, I don't love CreditMessagingV2 naming, since it implies the next version (V2) of the same thing (credit messaging). I'd stick to use-case.

  • StargatePool -> StargatePoolMigratable.
  • CreditMessaging -> CreditMessagingRecovery / CreditMessagingRecoverable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I also think this should be deployed on a single chain. @tironiigor’s plan was to deploy the new version from now on, but I agree we could deploy it on one of the main chains and continue using the current one elsewhere.

Regarding the name, I don’t have a strong opinion. I’m not a big fan of CreditMessagingRecovery either, but I don’t have a better suggestion. That said, I agree with @tino that if we go with a single-chain deployment, calling it CreditMessagingV2 could be misleading since we’d deploy V1 to new chains.

On the planner pausing point,good observation. The owner can ultimately take over the role and mint/burn if needed. It’s not ideal, but it’s possible, and everything can be done atomically. So I’d keep it in mind, but I don’t see it as an issue

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.

Yeah, fair if only 1 deployment shouldnt be v2, but we can brainstorm a better name. Igor agrees on doing it only one chain, as long as the migration (vs. just a new version on expansion) isnt too chaotic. Looking into that now

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.

5 participants