Comply Security

Independent review · closed 3 October 2026, 16:10 Asia/Seoul

Control review of the myXRP bridge on Flare

Comply Security read one deployed runtime and the public pages that name it. The file under review is the verified source of MyXRPBridge. The question for this letter is narrow: which calls move official FXRP, which calls mint or burn the receipt named myXRP, and whether any remaining call can retarget the asset, lift the ceiling, or send the cash to an account that did not just burn the receipt.

The receipt at this address is a 1:1 claim on FXRP held by the contract. It is not native XRP. It does not rebase. name and symbol are the constant myXRP. Decimals are 6, the same width as the FXRP unit, so one base unit of the receipt is one base unit of the asset the contract pulls.

Close. No critical, high, medium, or low finding is open. The observations at the end of this letter are already satisfied by the source. None of them asks for a code change. The reading is the bytecode at 16:10 Asia/Seoul on 3 October 2026.

What was on the desk

RecordValue at this reading
RuntimeMyXRPBridge, 0x14b9DD90edaAff6527c1b168550B3FAA15b9F6AF, Flare C-Chain
CreationBlock 70,992,363. Transaction 0xee361ab34728ed5a817ff9db70b67573ff02f029ae47a8b7e0242e9bec4485e5, 30 September 2026, 15:00:04 UTC, which is 1 October 2026, 00:00:04 Asia/Seoul. The bytecode did not exist before that transaction. The sender was 0x998718199D8aF65ed3a1cb03cfEc2faF7c301eFb. After the constructor returns, that address has no reserved call.
Constructor(fxrp, maxAssets_). fxrp is official FXRP 0xAd552A648C74D49E10027AB8a618A3ad4901c5bE. maxAssets_ is 10000000000000, which is 10,000,000.000000 FXRP at 6 decimals. A zero asset reverts ZeroAddress. A zero ceiling reverts ZeroAmount.
Buildsolc v0.8.28+commit.7893614a, optimizer enabled, 200 runs, EVM paris. The file pragma is ^0.8.20; the verified bytecode is the 0.8.28 build. Checked arithmetic is on. Explorer status: fully verified. The address is not marked as a proxy.
Source clockThe source file was already on the Flare explorer at 30 September 2026, 22:11 UTC, which is 1 October 2026, 07:11 Asia/Seoul. This letter diffs that file.
Public pagesmyxrp.fi, the deposit panel, add-to-wallet, and the explorer sheet. They bind this address.

Outside this letter: the cabinet ledger that credits XRP from a public snapshot of a linked wallet, the keys that hold a user’s account, and the internal economics of the FAsset system. The FXRP implementation at the immutable address is not re-reviewed here. The letter reviews the bridge that calls that address, and it states the assumption that call relies on.

What is fixed, and what can still change

Storage that moves after deployment is totalSupply, balanceOf, allowance, and a private word locked. The word is 0 or 1. It is not a balance.

maxAssets() at this reading is 10,000,000.000000 FXRP. That number is the ceiling on future mints. It is not a count of myXRP already minted. Minted supply is totalSupply(). deposit refuses a mint that would pass the ceiling.

Who can move value

The source has no owner, no role list, and no pause switch. Authorization is the ordinary holder check and the ordinary spender check. The dispatch that changes state is the list below.

CallerCallWhat moves
Any account, once block.timestamp has reached OPENS_AT, with an FXRP allowance to this contractdepositFXRP is pulled from the caller. The same amount of myXRP is minted to the caller.
An account that holds myXRPwithdrawThat account’s myXRP is burned. The same amount of FXRP is pushed back to that account. The open-instant constant is not read.
An account that holds myXRPtransfer, approveThe receipt moves, or a spender is written. FXRP stays where it is.
A spender with enough allowancetransferFromThe receipt moves from the holder to the chosen recipient. The spender cannot burn it through withdraw.
The address that sent the creation transactionnone reservedThe constructor stored the asset and the ceiling. It stored no admin.

Every custom revert

The file defines nine errors. Each one is thrown from a known line. A call that is not in the source reverts because the function is absent, not because a flag is off.

ErrorThrown from
ZeroAddressConstructor, if the asset is the zero address. _transfer, if the recipient is the zero address.
ZeroAmountConstructor, if the ceiling is zero. deposit and withdraw, if the amount is zero.
NotOpendeposit only, when block.timestamp is still below OPENS_AT.
CapExceededdeposit, when totalSupply + amount would pass maxAssets.
TransferFaileddeposit, if transferFrom returns false. withdraw, if transfer returns false.
InsufficientBalancewithdraw and _transfer, when the holder’s receipt balance is short.
InsufficientLiquiditywithdraw, when the contract’s FXRP balance is short of the amount. This check runs before the burn is stored.
InsufficientAllowancetransferFrom only.
ReenteredThe nonReentrant modifier, when locked is already 1.

How a mint is earned

deposit(uint256 amount) is nonReentrant. The external call happens before the credit. The function is one straight sequence:

  1. locked is set to 1. A nested deposit or withdraw reverts Reentered.
  2. A timestamp below OPENS_AT reverts NotOpen.
  3. A zero amount reverts ZeroAmount.
  4. totalSupply + amount > maxAssets reverts CapExceeded. The addition is checked. A sum that overflows the word reverts before a wrapped comparison can succeed.
  5. underlying.transferFrom(msg.sender, address(this), amount) must return true. A false return reverts TransferFailed, and the mint is never reached. A revert inside the token reverts the whole frame.
  6. Only then does the contract add amount to totalSupply and to balanceOf[msg.sender], emit Transfer from the zero address, and emit Deposit.
  7. locked is cleared on the way out.

There is no recipient argument. The receipt is minted to the account that paid the FXRP. The source contains one site that increases totalSupply. There is no second mint, and no mint aimed at an arbitrary account.

The 1:1 claim assumes the asset at the immutable address moves the full amount or reverts. A token that returned true and kept a fee would let the mint record amount while the contract received less. The address under review is official FXRP, fixed in the constructor. This letter treats that address as moving the full amount. See the last observation.

How a redemption is paid

withdraw(uint256 amount) is nonReentrant. It does not read OPENS_AT. A holder can redeem after the open instant, and the same function was already callable in the four seconds between the constant and the creation transaction’s timestamp, had the bytecode existed then. It did not. Redemption is not on a second clock that an admin can close, because no admin exists.

  1. A zero amount reverts ZeroAmount.
  2. balanceOf[msg.sender] must cover amount, or the call reverts InsufficientBalance. The function does not read allowance.
  3. underlying.balanceOf(address(this)) must cover amount, or the call reverts InsufficientLiquidity. The receipt balance is still the old value if this check fails.
  4. The contract then writes the caller’s balance down and totalSupply down, and emits Transfer to the zero address.
  5. underlying.transfer(msg.sender, amount) must return true. A false return reverts TransferFailed. The revert unwinds the frame, so the burn in the previous step is not kept. A revert inside the token unwinds the frame the same way.
  6. A successful push emits Withdraw. locked clears on the way out.

There is no to argument. FXRP is paid to the account that burned the receipt. The source contains one instruction that sends underlying, and that instruction sits inside withdraw.

The receipt can move while the cash stays

transfer and transferFrom move the claim. They do not move FXRP, and they do not take the reentrancy word. They also do not call out. A transfer of myXRP cannot by itself re-enter deposit or withdraw, because the receipt has no receiver hook. The external calls in this runtime are the FXRP transferFrom inside deposit and the FXRP transfer inside withdraw. Both of those sit under locked.

_transfer reverts ZeroAddress when the recipient is the zero address. A holder cannot burn the receipt by sending it to zero and leave the FXRP sitting in the contract. Burning happens only in withdraw.

approve overwrites the spender’s allowance and emits Approval. It does not move FXRP. transferFrom spends the allowance, except when the stored value is type(uint256).max: that value is left unchanged and is treated as unlimited. The ordinary ERC-20 race, where a new approve replaces an old one, applies to the receipt. It does not mint myXRP and it does not move FXRP by itself.

Inside _transfer the balance update is unchecked, and only after a checked test that balanceOf[from] covers amount. The subtraction cannot underflow. The addition to the recipient could wrap only if two balances summed past 2256−1. They cannot. Every balance is a slice of totalSupply, and deposit refuses to let totalSupply pass maxAssets, which is 1013 base units. The unchecked add is safe under that cap.

The contract does not implement permit, votes, or a transfer hook. An integrator that assumes 18 decimals will mis-scale the receipt. The bridge and official FXRP both use 6.

Headroom, cash, and a plain transfer of FXRP

room() returns maxAssets - totalSupply, or zero when supply is already at the ceiling. It is the amount still allowed to be minted. It is not underlying.balanceOf(address(this)).

A plain ERC-20 transfer of FXRP to the bridge raises the cash balance. It does not raise totalSupply, so it does not shrink room(), and it does not write anyone’s receipt. Because each mint records the amount just pulled, extra cash is not an input to a share price. There is no assets * supply / totalAssets division in the file, so a donation cannot inflate a first caller’s claim. The extra coins stay in the contract. Nothing in the source can forward them to a recovery address, because nothing in the source has an admin. Holders are not diluted. The surplus is simply unclaimed.

The relation this letter signs, under the FXRP assumption in the last observation, is: after every successful deposit or withdraw, the contract’s FXRP balance is at least totalSupply. A donation makes cash strictly greater than supply. A shortfall makes withdraw revert InsufficientLiquidity for the amount the cash cannot cover, and it leaves the receipt in place.

What the public desk binds

The desk opened myxrp.fi, the deposit panel, add-to-wallet, and the explorer sheet. Those surfaces name this bridge address and the name myXRP. The room line follows room() on this contract: headroom under the ceiling, in myXRP units, not the raw FXRP cash balance. A cabinet credit is a snapshot of a linked wallet. That credit is not a mint in this bytecode, and this bytecode does not read the cabinet.

How the file was read

The review is a manual reading of the verified source against the live runtime, plus the caller table and the revert table above. The Flare explorer reports the source fully verified for this bytecode, with the compiler and optimizer in the record table, and does not mark the address as a proxy. The desk searched that source for a second minter, an owner, a pause, a supply setter, delegatecall, selfdestruct, initialize, a check of tx.origin, and any send of underlying to an account other than the withdrawing caller. None are present.

This letter is not a machine-checked proof. It is not a review of the FXRP implementation at 0xAd552A648C74D49E10027AB8a618A3ad4901c5bE. A pause or an upgrade of that asset sits with FAssets, outside this runtime.

Observations already closed

Five observations. Each one is informational. Each one is closed by the source as it stands. None of them is a request for a patch.

No upgrade slot in this runtime

The source has no proxy dispatcher, no delegatecall, no selfdestruct, and no initialize. The explorer does not mark the address as a proxy, and the verified runtime matches the published source. The source file was on the explorer at 07:11 Asia/Seoul on 1 October 2026. No patch.

A failed push does not keep the burn

In withdraw, the caller’s balance and totalSupply are written down before underlying.transfer. If that call returns false, TransferFailed reverts the transaction, so the burn is not kept. If the token reverts, the frame reverts the same way. The cash check also sits before the burn, so a short FXRP balance never stores a reduced receipt. The order is the control. No patch.

An allowance moves the receipt and does not redeem it

withdraw debits balanceOf[msg.sender]. It never reads allowance. An approved spender can move the receipt with transferFrom. After that transfer, the new holder is the account that can redeem. A router that treats an allowance as a right to call withdraw will not find that behaviour here. Integrators redeem from the holding account. No patch.

FXRP that arrives outside deposit stays put

The only send of FXRP is inside withdraw, and it pays msg.sender the amount just burned. A plain transfer of FXRP to the bridge increases cash and does not mint a receipt. The runtime has no recovery call for that surplus. That is the same fact as the absence of an admin. Holders are not diluted, because the extra cash is not a share-price input. No patch.

The asset this contract calls is outside this file

deposit and withdraw trust underlying to move the full amount or to revert. That address is immutable and is official FXRP. This letter does not re-review that token. A pause, a fee, or an upgrade of FXRP would sit in the FAsset system, not in a setter on this bridge. No patch on this runtime.

Close, restated

No critical, high, medium, or low finding is open on this bridge. The close covers the bytecode and the public binding of this address. It is not a promise about yield, about the keys of a custodian, or about a payout in cash.

Write [email protected] if you need this letter on paper.