Skip to content

Conversation

@Luisfc68
Copy link
Collaborator

What

  • Add pegin contract
  • Add pegin contract documentation
  • Add unit tests for all the pegin contract functions

Why

To continue with the LBC split, the remaining contracts are CollateralManagement and FlyoverDiscovery

Task

https://rsklabs.atlassian.net/browse/GBI-2807

Important

The punisher reward mechanism was moved to CollateralManagement instead of the PegInContract. The reason of this is because the tokens used to reward the punisher (whomever calls the registerPegIn to penalize a liquidity provider) are taken from the provider's collateral, so it makes more sense to have that mechanism in CollateralManagement. The notion page will be updated with this change.

Copy link

@github-advanced-security github-advanced-security bot left a comment

Choose a reason for hiding this comment

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

Slither found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@github-actions
Copy link

github-actions bot commented Aug 21, 2025

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails

Scanned Files

Copy link

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

This PR adds a new PegIn contract to the LBC split architecture, including comprehensive unit tests and documentation. The implementation moves the punisher reward mechanism from the PegIn contract to the CollateralManagement contract, where rewards are calculated from penalties taken from provider collaterals.

  • Add complete PegIn contract implementation with deposit, withdraw, callForUser, and registerPegIn functionality
  • Integrate punisher reward system into CollateralManagement contract
  • Add comprehensive test suite covering all PegIn contract functions and edge cases

Reviewed Changes

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

Show a summary per file
File Description
test/utils/quotes.ts Add reward calculation utility and update PegIn quote type definition
test/utils/fixtures.ts Move collateral management deployment to shared fixtures
test/utils/constants.ts Add PegIn-specific constants and state enums
test/pegout/*.test.ts Update tests to use new reward calculation and collateral management interface
test/pegin/*.test.ts Add comprehensive test suite for all PegIn contract functions
contracts/test-contracts/*.sol Add mock contracts for testing withdrawal, reentrancy, and bridge scenarios
contracts/split/*.sol Update CollateralManagement to include punisher reward mechanism
contracts/libraries/Flyover.sol Add common error definitions used across contracts
contracts/interfaces/*.sol Add IPegIn interface and update ICollateralManagement with reward parameters
contracts/PegInContract.sol Add complete PegIn contract implementation
contracts/PegOutContract.sol Update to use shared error definitions and reward system

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

@Luisfc68 Luisfc68 marked this pull request as ready for review August 21, 2025 01:03
Comment on lines +123 to +134
function withdraw(uint256 amount) external nonReentrant override {
uint256 balance = _balances[msg.sender];
if (balance < amount) {
revert Flyover.NoBalance(amount, balance);
}
_decreaseBalance(msg.sender, amount);
emit Withdrawal(msg.sender, amount);
(bool success, bytes memory reason) = msg.sender.call{value: amount}("");
if (!success) {
revert Flyover.PaymentFailed(msg.sender, amount, reason);
}
}

Check warning

Code scanning / Slither

Low-level calls Warning

Comment on lines +137 to +185
function callForUser(
Quotes.PegInQuote calldata quote
) external payable nonReentrant override returns (bool) {
if(!_collateralManagement.isRegistered(_PEG_TYPE, msg.sender)) {
revert Flyover.ProviderNotRegistered(msg.sender);
}
if (quote.liquidityProviderRskAddress != msg.sender) {
revert Flyover.InvalidSender(quote.liquidityProviderRskAddress, msg.sender);
}
uint256 newBalance = _balances[quote.liquidityProviderRskAddress] + msg.value;
if (newBalance < quote.value) {
revert Flyover.InsufficientAmount(newBalance, quote.value);
}

bytes32 quoteHash = _hashPegInQuote(quote);
if (_processedQuotes[quoteHash] != PegInStates.UNPROCESSED_QUOTE) {
revert QuoteAlreadyProcessed(quoteHash);
}

_increaseBalance(quote.liquidityProviderRskAddress, msg.value);

// This check ensures that the call cannot be performed with less gas than the agreed amount
if (gasleft() < quote.gasLimit + _MAX_CALL_GAS_COST) {
revert InsufficientGas(gasleft(), quote.gasLimit + _MAX_CALL_GAS_COST);
}
_callRegistry[quoteHash].timestamp = block.timestamp;
_processedQuotes[quoteHash] = PegInStates.CALL_DONE;

(bool success,) = quote.contractAddress.call{
gas: quote.gasLimit,
value: quote.value
}(quote.data);

if (success) {
_callRegistry[quoteHash].success = true;
_decreaseBalance(quote.liquidityProviderRskAddress, quote.value);
}

emit CallForUser(
msg.sender,
quote.contractAddress,
quoteHash,
quote.gasLimit,
quote.value,
quote.data,
success
);
return success;
}

Check failure

Code scanning / Slither

Reentrancy vulnerabilities High

Copy link
Collaborator

@alexjavabraz alexjavabraz Aug 22, 2025

Choose a reason for hiding this comment

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

Please evaluate the usage of Checks-Effects-Interactions pattern here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hakob made a similar comment related to the reentrancy alert, I explained the rationale of doing it in this way in my response, please take a look a let me know your thoughts about the need of keeping track of the result of the call.

The response -> #354 (comment)

Comment on lines +137 to +185
function callForUser(
Quotes.PegInQuote calldata quote
) external payable nonReentrant override returns (bool) {
if(!_collateralManagement.isRegistered(_PEG_TYPE, msg.sender)) {
revert Flyover.ProviderNotRegistered(msg.sender);
}
if (quote.liquidityProviderRskAddress != msg.sender) {
revert Flyover.InvalidSender(quote.liquidityProviderRskAddress, msg.sender);
}
uint256 newBalance = _balances[quote.liquidityProviderRskAddress] + msg.value;
if (newBalance < quote.value) {
revert Flyover.InsufficientAmount(newBalance, quote.value);
}

bytes32 quoteHash = _hashPegInQuote(quote);
if (_processedQuotes[quoteHash] != PegInStates.UNPROCESSED_QUOTE) {
revert QuoteAlreadyProcessed(quoteHash);
}

_increaseBalance(quote.liquidityProviderRskAddress, msg.value);

// This check ensures that the call cannot be performed with less gas than the agreed amount
if (gasleft() < quote.gasLimit + _MAX_CALL_GAS_COST) {
revert InsufficientGas(gasleft(), quote.gasLimit + _MAX_CALL_GAS_COST);
}
_callRegistry[quoteHash].timestamp = block.timestamp;
_processedQuotes[quoteHash] = PegInStates.CALL_DONE;

(bool success,) = quote.contractAddress.call{
gas: quote.gasLimit,
value: quote.value
}(quote.data);

if (success) {
_callRegistry[quoteHash].success = true;
_decreaseBalance(quote.liquidityProviderRskAddress, quote.value);
}

emit CallForUser(
msg.sender,
quote.contractAddress,
quoteHash,
quote.gasLimit,
quote.value,
quote.data,
success
);
return success;
}

Check notice

Code scanning / Slither

Return Bomb Low

Comment on lines +137 to +185
function callForUser(
Quotes.PegInQuote calldata quote
) external payable nonReentrant override returns (bool) {
if(!_collateralManagement.isRegistered(_PEG_TYPE, msg.sender)) {
revert Flyover.ProviderNotRegistered(msg.sender);
}
if (quote.liquidityProviderRskAddress != msg.sender) {
revert Flyover.InvalidSender(quote.liquidityProviderRskAddress, msg.sender);
}
uint256 newBalance = _balances[quote.liquidityProviderRskAddress] + msg.value;
if (newBalance < quote.value) {
revert Flyover.InsufficientAmount(newBalance, quote.value);
}

bytes32 quoteHash = _hashPegInQuote(quote);
if (_processedQuotes[quoteHash] != PegInStates.UNPROCESSED_QUOTE) {
revert QuoteAlreadyProcessed(quoteHash);
}

_increaseBalance(quote.liquidityProviderRskAddress, msg.value);

// This check ensures that the call cannot be performed with less gas than the agreed amount
if (gasleft() < quote.gasLimit + _MAX_CALL_GAS_COST) {
revert InsufficientGas(gasleft(), quote.gasLimit + _MAX_CALL_GAS_COST);
}
_callRegistry[quoteHash].timestamp = block.timestamp;
_processedQuotes[quoteHash] = PegInStates.CALL_DONE;

(bool success,) = quote.contractAddress.call{
gas: quote.gasLimit,
value: quote.value
}(quote.data);

if (success) {
_callRegistry[quoteHash].success = true;
_decreaseBalance(quote.liquidityProviderRskAddress, quote.value);
}

emit CallForUser(
msg.sender,
quote.contractAddress,
quoteHash,
quote.gasLimit,
quote.value,
quote.data,
success
);
return success;
}
Comment on lines +188 to +231
function registerPegIn(
Quotes.PegInQuote calldata quote,
bytes calldata signature,
bytes calldata btcRawTransaction,
bytes calldata partialMerkleTree,
uint256 height
) external nonReentrant override returns (int256) {
bytes32 quoteHash = _hashPegInQuote(quote);
_validateRegisterParams(quote, quoteHash, height, signature);
int256 registerResult = _registerBridge(quote, btcRawTransaction, partialMerkleTree, height, quoteHash);

bool btcRefunded = registerResult == _BRIDGE_REFUNDED_USER_ERROR_CODE ||
registerResult == _BRIDGE_REFUNDED_LP_ERROR_CODE;
if (registerResult == _BRIDGE_UNPROCESSABLE_TX_VALIDATIONS_ERROR) {
revert NotEnoughConfirmations();
} else if (!btcRefunded && registerResult < 1) {
revert UnexpectedBridgeError(registerResult);
}

Registry memory callRegistry = _callRegistry[quoteHash];
delete _callRegistry[quoteHash];
if (_shouldPenalize(quote, registerResult, callRegistry.timestamp, height)) {
_collateralManagement.slashPegInCollateral(msg.sender, quote, quoteHash);
}
if (btcRefunded) {
_processedQuotes[quoteHash] = PegInStates.PROCESSED_QUOTE;
emit BridgeCapExceeded(quoteHash, registerResult);
return registerResult;
}

// the amount is safely assumed positive because it's already been validated there's
// no (negative) error code being returned by the bridge.
uint256 transferredAmount = uint256(registerResult);
Quotes.checkAgreedAmount(quote, transferredAmount);

_processedQuotes[quoteHash] = PegInStates.PROCESSED_QUOTE;
emit PegInRegistered(quoteHash, transferredAmount);
if (callRegistry.timestamp > 0) {
_registerCallDone(quote, quoteHash, callRegistry.success, transferredAmount);
} else {
_registerCallNotDone(quote, quoteHash, transferredAmount);
}
return registerResult;
}
Copy link
Collaborator

@alexjavabraz alexjavabraz left a comment

Choose a reason for hiding this comment

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

Evaluate the usage of Checks-Effects-Interactions pattern

Copy link

@hakobRsk hakobRsk left a comment

Choose a reason for hiding this comment

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

Is there a reason, the getMinPegin is not implemented(it is mentioned in the docs but not present in the legacy contract)?
And I assume getRewardPercentage and rewardP is not implemented here as they are the part of the punishing mechanism, right?

Otherwise, LGTM

_callRegistry[quoteHash].timestamp = block.timestamp;
_processedQuotes[quoteHash] = PegInStates.CALL_DONE;

(bool success,) = quote.contractAddress.call{

Choose a reason for hiding this comment

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

  1. Currently we don't have a behavior to revert if the call(in any way) returns false . May be we should handle that case inside the contract as well?
  2. This part is also flagged as reentrancy vulnerability. If we agree to add a revert mechanism as mentioned in the first point, then maybe we could do smth like this?
_callRegistry[quoteHash].success = true;
_decreaseBalance(quote.liquidityProviderRskAddress, quote.value);
 
  
 (bool success,) = quote.contractAddress.call{
    gas: quote.gasLimit,
    value: quote.value
}(quote.data);
        
if(!success) revert();
 
 

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Both questions are related, the context here is that we need to keep track of the result of the call to the contractAddress, as it affects the flow in the following ways:

  1. If the LP did make the call, but the call wasn't successful, then they aren't applicable for penalization (assuming timing was ok) and they are still refunded with the gasFee and callFee, but not the value as the call reverted
  2. If the LP did make the call and was successful, then they aren't applicable for penalization (assuming timing was ok) and they are refunded with gasFee, callFee and value amounts
  3. If the LP didn't make the call then they are applicable for penalization (assuming timing was ok) but in that case the callForUser never executes

The point is that if we don't keep track of the result of the call in the state of the contract then we won't be to determine most of the scenarios inside the registerPegIn. This only happens with the CallForUser event, so if you see something similar in other part of the contract then we should change that but in this particular case I think we are pretty much forced to maintain the modification of the state after the call. Anyway if you have any proposal to rewrite the function but maintaining the overall logic we can try with that, perhaps we could add

_callRegistry[quoteHash].success = true;
_decreaseBalance(quote.liquidityProviderRskAddress, quote.value);

optimistically before the external call, but we'd still need to emit the event with the result of the call and rollback that modification in case the call fails, so we would be in a similar situation.

Regarding the reentrancy alert, yes, even though I included some tests specifically to ensure this shouldn't be affected by that vulnerability, slither seems not to be detecting the nonReentrant modifier. I already raised this topic with security team and I'm waiting for their response.

Choose a reason for hiding this comment

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

Okay, thanks for the clarification. I think we can keep it this way then, especially if there is an explicit reentrancy guard. We could still try to follow the DEI pattern, but that would unnecessarily add the gas cost for the caller.

@Luisfc68
Copy link
Collaborator Author

Luisfc68 commented Aug 25, 2025

Is there a reason, the getMinPegin is not implemented(it is mentioned in the docs but not present in the legacy contract)? And I assume getRewardPercentage and rewardP is not implemented here as they are the part of the punishing mechanism, right?

Otherwise, LGTM

@hakobRsk There is no reason, I forgot to add that getter, thanks for noticing.

Regarding getRewardPercentage correct, as explained in the PR description that mechanism is being moved to Collateral Management so those variables are going with it

Copy link
Collaborator

@alexjavabraz alexjavabraz left a comment

Choose a reason for hiding this comment

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

LGTM

@alexjavabraz alexjavabraz merged commit dfe64fb into lbc-split Aug 25, 2025
4 of 5 checks passed
@alexjavabraz alexjavabraz deleted the feature/GBI-2807 branch August 25, 2025 17:49
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