Contract audit · 컨트랙트 감사
claude-opus-5-thinking-high
3 October 2026 · MyXRPBridge on Flare
Contract: MyXRPBridge Network: Flare Mainnet, C-chain (EVM chain id 14, confirmed via net_version) Address: 0x14b9DD90edaAff6527c1b168550B3FAA15b9F6AF Underlying asset: FXRP 0xAd552A648C74D49E10027AB8a618A3ad4901c5bE Compiler (per verified metadata): v0.8.28+commit.7893614a, optimizer enabled, 200 runs, EVM target paris, metadata hash ipfs Runtime bytecode size: 2,917 bytes State read at: block 71,252,013 (0x43f382d), 3 October 2026, ~21:37 UTC RPC used: https://flare-api.flare.network/ext/C/rpc Explorer used: https://flare-explorer.flare.network (Blockscout instance for Flare mainnet)
This is a single-contract review of a sealed, non-custodial 1:1 wrapper. The deliverable is a public technical opinion on whether the deployed code does what its source says, what powers exist over user funds, and what residual risk a holder of myXRP carries.
The review was conducted against the deployed bytecode and live chain state, not only against a source listing. Every claim below that concerns the live contract was produced by an eth_call, eth_getCode, eth_getStorageAt, log query, or explorer verification record executed during the engagement. Where a fact could not be established on chain, this report says so explicitly rather than inferring it.
No legal, regulatory, operational, or corporate matter is in scope. This report makes no statement about any licence, registration, company number, office, or counterparty, because none of those can be verified from chain data. No statement is made about the economics of holding myXRP, the value of FXRP, or any off-chain arrangement.
Scope boundary on identity. This audit covers the single address 0x14b9DD90edaAff6527c1b168550B3FAA15b9F6AF and nothing else. It is not an opinion on any other deployment, any front end, any other contract that may share the name myXRP, or on which contract any particular website or interface points at. A reader who wants to rely on this report must check that the address they are interacting with is byte-for-byte the address above.
In scope
| Item | Detail |
|---|---|
| Source under review | Single file, MyXRPBridge.sol, 124 lines as verified on chain |
| Deployed runtime | 2,917 bytes at the address above |
| Creation transaction | 0xee361ab34728ed5a817ff9db70b67573ff02f029ae47a8b7e0242e9bec4485e5 |
| Constructor arguments | fxrp = 0xAd552A648C74D49E10027AB8a618A3ad4901c5bE, maxAssets_ = 10000000000000 |
| External dependency | FXRP token contract, reviewed only to the depth needed to state the trust assumption it imposes |
Out of scope
eth_getCode at latest returned 2,917 bytes; keccak256 of the runtime is 0x56ab269123202ad1d7915d7ed85ac8c6bd2fcd67146cf7bef775342b7d7c4d19.deployed_bytecode for this address was fetched and compared byte-for-byte against the live eth_getCode result. They are identical. The explorer reports is_verified: true, is_fully_verified: true, is_partially_verified: false, is_changed_bytecode: false, proxy_type: null.0.8.28). The dispatch table was enumerated by walking PUSH-aware, and every PUSH4 immediate was resolved.PUSH4 x; PUSH1 0xe3; SHL, i.e. x << 227. 0x1e9acf17 << 3 = 0xf4d678b8 (InsufficientBalance), 0x12171d83 << 3 = 0x90b8ec18 (TransferFailed), 0x1bb5f5b3 << 3 = 0xddafad98 (NotOpen). All nine declared errors are present, at the expected multiplicity (two InsufficientBalance sites, two TransferFailed, two ZeroAmount, two Reentered, one each of the rest). No undeclared revert path exists other than the standard Panic(uint256) handler.eth_call: seventeen probes against invalid input, absent privileged functions, empty calldata, and an unknown selector, each matched against the expected revert selector.latest, to prove the exit path is open and funded.Method limitation, stated plainly. A local solc 0.8.28 recompilation from source to compare artefacts apart from the explorer was not performed; no compiler was available in the review environment and the review did not install one. Source-to-bytecode equivalence therefore rests on (a) the explorer's full-match verification record and (b) the disassembly in step 4–5, which confirmed that the deployed dispatch table, immutables, storage layout, external-call sites, event topics, and complete error catalogue correspond one-to-one with the reviewed source and contain nothing additional. These two lines of evidence agree. A reader who requires a third should recompile with the published settings (0.8.28, optimizer on, 200 runs, EVM paris, metadata ipfs).
All values read at block 71,252,013 unless noted.
| Property | Value read | Notes |
|---|---|---|
name() | "myXRP" | ABI-encoded dynamic string, offset 32, length 5 |
symbol() | "myXRP" | Identical to name() |
decimals() | 6 | Matches FXRP's 6 |
underlying() | 0xAd552A648C74D49E10027AB8a618A3ad4901c5bE | Equals the stated official FXRP address |
maxAssets() | 10000000000000 | 10,000,000.000000 FXRP |
totalSupply() | 25000000 | 25.000000 myXRP |
room() | 9999975000000 | 9,999,975.000000 |
FXRP balanceOf(bridge) | 25000000 | 25.000000 FXRP |
OPENS_AT() | 1790780400 | 2026-09-30 15:00:00 UTC = 2026-10-01 00:00:00 Asia/Seoul |
| Native FLR balance | 0 | Contract is non-payable |
| Verified source | Yes, full match | solc 0.8.28, single file, no libraries, no additional sources |
owner() / admin() / implementation() / proxiableUUID() / paused() / fee() / oracle() | All revert | No such functions; selectors absent from the runtime |
| EIP-1967 implementation / admin / beacon slots | 0x0 | All three empty |
EIP-1822 proxiableUUID slot | 0x0 | Empty |
| Storage slot 0 | 25000000 | totalSupply |
| Storage slots 1, 2 | 0x0 | Mapping base slots, correctly empty |
| Storage slot 3 | 0x0 | locked — reentrancy flag is at rest, not stuck |
| Storage slot 4 | 0x0 | Unused; no hidden state |
Immutables. underlying and maxAssets are immutable, so they live in code, not storage. This is confirmed in the runtime: the FXRP address and 0x09184e72a000 (10e12) appear as code constants spliced at the eight immutable reference offsets recorded in the creation bytecode. OPENS_AT likewise appears as the literal PUSH4 0x6abd23f0 (= 1,790,780,400) at both its read sites. None of these three values can ever change.
Deployment. Created in block 70,992,363 at Unix time 1790780404, i.e. 2026-09-30 15:00:04 UTC — four seconds after OPENS_AT. Deployer: EOA 0x998718199D8aF65ed3a1cb03cfEc2faF7c301eFb, nonce 0, gas used 685,201, status success. That account's transaction list on this explorer contains exactly one transaction: this deployment. The deployer holds no role in the contract, because the contract has no roles.
Complete history. Three state-changing user transactions exist, all on 2026-09-30:
| Time (UTC) | Action | Amount | Transaction |
|---|---|---|---|
| 20:06:06 | deposit → mint | 25.000000 | 0x90e45c9ebe46e5e7e5f4f9b5e49ad39cf2df47e6736574d5e941e1022e3b669e |
| 20:20:10 | withdraw → burn | 25.000000 | 0x77389a139de45485535256f2bc1c41c05ccce8a3ac7fe68dfea929911fa698f1 |
| 20:21:01 | deposit → mint | 25.000000 | 0xfaac95e3812ec784df113609f0ce9b5c1de7c4bf7365d75c6f288b767f48036b |
Each is matched by an FXRP transfer in the same transaction, in the same direction, for the identical amount. Six logs total (three Transfer, one Deposit, one Withdraw, one Deposit). One holder, 0x38A22D748582a98695489400fc46734848b37CAC, holding the whole 25.000000 supply. The round trip in the middle of that sequence is useful evidence: it demonstrates that redemption worked in production, not only in simulation.
Live redemption simulation. withdraw(25000000) called from the holder at latest returns successfully (0x). withdraw(25000001) reverts InsufficientBalance(). The exit is open and exactly funded.
The contract is a sealed 1:1 wrapper, not a vault, not a yield instrument, and not a cross-chain messaging bridge. There is no bridge in the message-passing sense: no relayer, no validator set, no signature verification, no light client. The word "bridge" here describes a token-for-token wrapper on a single chain.
Mechanically:
deposit(amount) pulls amount units of FXRP from the caller and credits the caller amount units of myXRP, one for one, no fee, no scaling. decimals is 6 on both sides, so a unit is a unit.withdraw(amount) burns amount units of the caller's myXRP and returns amount units of FXRP.myXRP itself is a minimal ERC-20 that can be transferred and approved like any other token.maxAssets caps outstanding supply at 10,000,000.000000.OPENS_AT blocks deposit before 2026-10-01 00:00 Asia/Seoul.There is no interest, no index, no rebase, no share price, no exchange rate, no oracle, no price feed, no FTSO read, no fee, no treasury, no operator withdrawal, no allowlist, no blocklist. The redemption rate is fixed at 1:1 by construction, in code, permanently.
This is the most important section for a holder, so it is stated without hedging.
There is no privileged party. This was tested, not assumed:
owner(), admin(), transferOwnership, renounceOwnership, upgradeTo, upgradeToAndCall, initialize, pause, unpause, setFee, setOracle, mint, burn(address,uint256), operatorWithdraw, sweep, or rescue selector. Each was searched for by selector in the full runtime hex: all absent, count zero. Each was also called live: all revert with no return data, i.e. they fall through to the dispatcher's terminal revert.DELEGATECALL 0, CALLCODE 0, CREATE 0, CREATE2 0, SELFDESTRUCT 0, EXTCODEHASH 0. There is no mechanism by which this contract can execute foreign code in its own context, deploy a helper, or destroy itself.proxy_type: null, IsProxy: false.totalSupply, slots 1 and 2 are mapping bases, slot 3 is the reentrancy flag, slot 4 and beyond are zero. No slot holds an address. There is nowhere for a hidden administrator to live.0xdeadbeef) reverts. There is no fallback and no receive function; sending value reverts (one CALLVALUE check in the dispatcher prologue).Conclusion: no one — including the deployer — can mint myXRP without depositing FXRP, move another holder's balance, extract FXRP from the contract, pause it, upgrade it, or change its parameters. The contract is immutable and ownerless. This is a strong property and it is the main thing this audit confirms.
The wrapper's solvency is only as good as the asset it wraps. FXRP is not immutable:
0xAd552A648C74D49E10027AB8a618A3ad4901c5bE is a 177-byte EIP-1967 proxy (FAssetProxy), currently pointing at implementation 0x53cfb685d773cfaa657ff07b70602d3cf27525d3 (FAsset, solc 0.8.27, verified). Its ABI exposes upgradeTo, upgradeToAndCall, proxiableUUID — it is UUPS-upgradeable.FAsset exposes mint(address,uint256) and burn(address,uint256), both onlyAssetManager. The asset manager is 0x2a3fe068cd92178554cabcf7c95adf49b4b0b6a8. As written, burn takes an arbitrary _owner. The asset manager can therefore reduce the FXRP balance of any holder, including this bridge.FAsset transfers are gated by require(IAssetManager(assetManager).emergencyPauseLevel() < EmergencyPause.Level.FULL_AND_TRANSFER, EmergencyPauseOfTransfersActive()). At the time of reading, emergencyPauseLevel() returns 0 — no pause active. If that level is ever raised to transfer-pause, withdraw on this bridge reverts for the duration, because the FXRP leg fails.FAsset implementation contains no transfer fee (a source search for transferFee over all 64 verified source files returns zero matches) and no blocklist. A future implementation could introduce either.These are properties of the Flare FAssets system under its own governance. They are out of audit scope, but they are in the trust model, and the bridge has no defence against them: it cannot pause, cannot migrate, cannot be upgraded, and holds no reserve. A holder of myXRP is a holder of FXRP with one extra immutable contract in between.
constructor(address fxrp, uint256 maxAssets_)Rejects a zero token address (ZeroAddress) and a zero cap (ZeroAmount), then writes both to immutables. No owner is recorded. No tokens are minted. The constructor is the only moment at which any parameter of this system was settable, and it has passed. Constructor arguments decode to the official FXRP address and 10000000000000; the explorer's decoded arguments agree.
room() → uint256maxAssets - totalSupply, floored at zero by an explicit >= guard rather than relying on checked arithmetic. Returns 9999975000000. Pure view, no external call.
deposit(uint256 amount) — nonReentrantOrdered: time gate → zero gate → cap gate → external pull → mint.
block.timestamp < OPENS_AT reverts NotOpen. Confirmed present in bytecode as PUSH4 0x6abd23f0; TIMESTAMP; LT; ISZERO; JUMPI. The gate has been satisfied since four seconds before deployment, so it has never fired on chain and could not be exercised live; its presence and correctness were established by disassembly.amount == 0 reverts ZeroAmount. Confirmed live.totalSupply + amount > maxAssets reverts CapExceeded. Confirmed live at cap + 1. The boundary is correct: a deposit of exactly room() passes this check and proceeds to the FXRP leg.underlying.transferFrom(msg.sender, address(this), amount); a false return reverts TransferFailed. Confirmed live that an unapproved deposit reverts with FXRP's own ERC20: insufficient allowance string, i.e. the underlying reverts before any bool is returned — which is the correct, fail-closed outcome either way.Transfer(0x0 → user) for mint semantics and Deposit(user, amount).The external call precedes the state write, so this function is not strict checks-effects-interactions. It is nonetheless safe here, for two separate reasons, both verified: the function is reentrancy-locked, and totalSupply += amount / balanceOf[msg.sender] += amount compile to an SLOAD at that point rather than reuse of a stale cached value, so no lost update is possible even if a hypothetical callback mutated those slots. See I-03.
withdraw(uint256 amount) — nonReentrantOrdered: zero gate → balance gate → liquidity gate → burn → external push.
amount == 0 reverts ZeroAmount; balanceOf[msg.sender] < amount reverts InsufficientBalance. Both confirmed live, including at 2^256 - 1, which reverts InsufficientBalance rather than overflowing.underlying.balanceOf(address(this)) < amount reverts InsufficientLiquidity. This is the explicit solvency check and it is the single STATICCALL in the contract.transfer, so this leg is strict checks-effects-interactions.Transfer(user → 0x0) is emitted before the external call; Withdraw(user, amount) after it. If the FXRP transfer fails or returns false, the entire transaction reverts and both events are discarded along with the state change. There is no window in which supply is burned but FXRP is not delivered.approve(address spender, uint256 amount)Unconditional absolute set, emits Approval, returns true. approve(address(0), 1) is permitted and returns true — harmless, since the zero address cannot transact, but noted as I-09.
transfer(address to, uint256 amount)Delegates to _transfer(msg.sender, to, amount), returns true. No external call, therefore no reentrancy surface, therefore correctly not locked.
transferFrom(address from, address to, uint256 amount)Reads the allowance, reverts InsufficientAllowance if short (confirmed live), decrements unless the allowance is type(uint256).max. The infinite-allowance exemption is the conventional gas optimisation and is not a defect. Returns true.
_transfer(address from, address to, uint256 amount) — internalto == address(0) reverts ZeroAddress, confirmed live. Burning is therefore reachable only through withdraw, which means supply can never fall out of step with the FXRP backing by way of a stray transfer to zero. This is a deliberate and correct design choice; a naive ERC-20 that allows transfers to the zero address without decrementing totalSupply would silently corrupt the solvency ratio.bal < amount reverts InsufficientBalance, checked before the unchecked block.unchecked: the subtraction is already guarded; the addition cannot overflow because the sum of all balances is bounded by totalSupply ≤ maxAssets = 10^13, which is thirty-six orders of magnitude below 2^256.balanceOf[from] = bal - amount lands before the read in balanceOf[to] += amount, so for from == to the result is bal - amount + amount = bal. The cached-balance self-transfer inflation bug that has appeared in other hand-rolled ERC-20s is not present here. Confirmed live: transfer(self, 25.000000) from the holder succeeds and returns true, with no supply change.The contract is a correct but deliberately minimal ERC-20. The following are gaps, not bugs, and integrators should know them.
| Aspect | Status |
|---|---|
name, symbol, decimals | Present, as constants. ABI encoding verified live. |
totalSupply, balanceOf, transfer, transferFrom, approve, allowance | All present with correct signatures and bool returns. |
Transfer, Approval events | Present; topics verified in bytecode and in live logs. |
| Failure signalling | Reverts with custom errors instead of returning false. Permitted by ERC-20, but integrators that branch on a false return will see a reverted transaction instead. |
transfer to address(0) | Rejected. Many ERC-20s allow it. Intentional here. |
| Zero-value transfer | Permitted, emits Transfer, as the standard requires. |
EIP-2612 permit | Absent. No gasless approvals, no nonces, no DOMAIN_SEPARATOR. |
increaseAllowance / decreaseAllowance | Absent. |
ERC-165 supportsInterface | Absent. |
| ERC-4626 | Not implemented and should not be described as such. There is no asset(), convertToShares, previewDeposit, maxDeposit, or deposit(uint256,address). The accessor is underlying(). It is a 1:1 wrapper, not a tokenised vault. |
| Hooks / callbacks | None. No _beforeTokenTransfer, no ERC-777 behaviour, no receiver callback. This is a security positive. |
| Checkpointing / snapshots | Absent. Unlike FXRP itself, myXRP has no balanceOfAt. Governance or airdrop integrations relying on historical balances must index events. |
The ABI published on the explorer has 29 entries and matches this exactly: one constructor, nine errors, four events, fifteen functions. No additional entry exists.
Solidity 0.8.28 checked arithmetic throughout, with one audited unchecked block in _transfer whose preconditions are both established above. There is no division, no multiplication, no exponentiation, and no scaling anywhere in the contract. Because myXRP and FXRP both use 6 decimals and the rate is fixed at 1:1, there is no rounding, no dust, no precision loss, and no rounding-direction question. This eliminates an entire class of findings that dominate vault audits — share inflation, first-depositor attacks, donation-driven price manipulation, and rounding-in-favour-of-the-wrong-party — none of which apply to this contract, because there is no share price to manipulate.
One arithmetic observation, confirmed live: deposit(2^256 - 1) reverts with Panic(0x11) (arithmetic overflow) from totalSupply + amount, rather than with CapExceeded(). The outcome is safe — it fails closed — but the error surfaced is a compiler panic rather than the intended domain error. See I-04.
CALL ×2: the FXRP transferFrom and transfer) plus one STATICCALL (the FXRP balanceOf). Every one of them targets the single immutable underlying address. There is no call to an attacker-supplied address anywhere in the contract. This is the structurally decisive fact: an attacker cannot insert their own code into the bridge's execution.nonReentrant, and they share one lock (slot 3), so cross-function reentrancy between deposit and withdraw is also blocked, not just same-function reentrancy.transfer, transferFrom, and approve are unlocked, which is correct: they contain no external call.None exists, by design, and this was verified three ways (selector search, live calls, storage inspection) in §6.1. There is no role to misconfigure, no two-step ownership handover to get wrong, no timelock to bypass, and no multisig to compromise. The review found no access-control defect because the contract has no access control to defect.
None. No proxy pattern, no DELEGATECALL, no empty slot reserved for an implementation, no SELFDESTRUCT, no metamorphic-contract vector (CREATE2 count zero, and the contract was deployed by a plain CREATE from a nonce-0 EOA, not by a factory — contractFactory is empty in the explorer's creation record). The deployed code at this address is final. Consequently no bug found in future can ever be patched at this address; the only remedy would be a new deployment and a voluntary migration by holders.
None. Deposits cannot be halted, and neither can redemptions. The absence of a pause is a two-sided trade-off that should be stated honestly rather than sold as a pure positive: nobody can freeze your redemption, and equally nobody can stop deposits if FXRP itself is ever found to be compromised. Given that the contract holds no discretionary power over user funds and that redemption is the only exit, the absence of a pause is the defensible choice. See L-02.
None. No price feed, no FTSO consumption, no TWAP, no Chainlink or FDC reference, no block.number-based pricing. The exchange rate is the integer identity. Oracle manipulation is not applicable to this contract. The only consensus-dependent value read is block.timestamp, discussed in §9.9.
None. deposit and withdraw take no protocol fee, no mint fee, no exit fee, no spread. There is no fee recipient address and no function to set one. The amount deposited is the amount credited; the amount burned is the amount returned. Verified on chain: the 25.000000 FXRP deposit credited exactly 25.000000 myXRP, and the 25.000000 redemption returned exactly 25.000000 FXRP.
The invariant that matters is:
FXRP.balanceOf(bridge) ≥ totalSupply
Current state: 25000000 ≥ 25000000. Exactly met, with zero surplus and zero deficit.
The invariant is maintained by construction:
deposit increases totalSupply by amount and increases the FXRP balance by amount — provided the FXRP transfer moves exactly amount. This proviso is the subject of finding M-01.withdraw decreases totalSupply by amount and decreases the FXRP balance by amount, and additionally re-checks the invariant explicitly before paying out.transfer and transferFrom move myXRP between holders without touching either side of the invariant.withdraw, because _transfer rejects the zero address.myXRP burn.The invariant can be broken only from outside the contract, by the FXRP asset manager burning the bridge's FXRP, or by a future FXRP implementation introducing a transfer fee. In a deficit, withdraw reverts InsufficientLiquidity for anyone whose redemption exceeds the remaining balance, and redemption becomes first-come-first-served. The contract contains no pro-rata haircut mechanism — it is strictly first-come, and that should be understood. There is no way for the contract to socialise a shortfall, and no way for it to be topped up except by a voluntary FXRP donation from anyone.
maxAssets is an immutable 10000000000000 units (10,000,000.000000). The check is totalSupply + amount > maxAssets.
Two points integrators consistently get wrong, both verified against the live history:
room() returned to 9,999,975.000000 after the subsequent re-deposit, not 9,999,950.000000. Lifetime issuance through this contract is unbounded; concurrent issuance is capped.totalSupply, not the FXRP balance. Donated FXRP consumes no room.A boundary check was run live: a deposit of exactly room() passes the cap gate. A deposit of maxAssets + 1 reverts CapExceeded. The boundary is inclusive and correct, with no off-by-one.
OPENS_AT = 1790780400 is a compile-time constant, present twice in the runtime as a literal. It is not settable, not extendable, and not bypassable. It equals 2026-09-30 15:00:00 UTC, which is 2026-10-01 00:00:00 in Asia/Seoul (UTC+9).
Design notes: the gate is applied only to deposit. withdraw, transfer, transferFrom, and approve are ungated. This is correct and not an omission — before OPENS_AT no supply could exist, so gating the other functions would have had no effect. block.timestamp is validator-influenceable by a small margin, which in principle could have advanced the open by seconds; the contract was deployed four seconds after the gate had already passed, so the gate has never been load-bearing on chain and timestamp tolerance is moot.
Both are correct and both are permanent. underlying matches the official FXRP address given for review, character for character, as read from the live contract. maxAssets matches the decoded constructor argument. Because they are immutable rather than storage, they cannot be altered even by a storage-collision bug, and they cost no SLOAD to read. The zero-address and zero-cap guards in the constructor prevented the two deployment mistakes that would have bricked the contract.
Four events declared, four present in the bytecode (LOG3 ×4 for Transfer and Approval, LOG2 ×2 for Deposit and Withdraw), and all four observed in live logs.
Every state transition emits: mint emits Transfer(0 → user) and Deposit; burn emits Transfer(user → 0) and Withdraw; transfers emit Transfer; approvals emit Approval. The dual emission on mint and burn is deliberate and useful — standard ERC-20 indexers see the supply change, and purpose-built indexers get an unambiguous bridge event. Supply can therefore be reconstructed completely from logs alone. No silent state change exists anywhere in the contract.
Minor completeness notes, non-defects: the value field in Deposit/Withdraw is not indexed (correct — amounts should not be topics); there is no deployment event; and the allowance decrement in transferFrom emits no Approval, which is conventional ERC-20 behaviour.
All nine declared errors were resolved in the bytecode and reconciled against their source sites. Seven of nine were triggered live.
| Error | Selector | Sites | Live-triggered |
|---|---|---|---|
ZeroAddress() | 0xd92e233d | 1 runtime (+ constructor) | Yes — transfer(0x0, 1) |
ZeroAmount() | 0x1f2a2005 | 2 | Yes — deposit(0), withdraw(0) |
CapExceeded() | 0xa4875a49 | 1 | Yes — deposit(cap+1) |
NotOpen() | 0xddafad98 | 1 | No — gate already passed; confirmed by disassembly |
InsufficientBalance() | 0xf4d678b8 | 2 | Yes — withdraw and transfer with no balance |
InsufficientAllowance() | 0x13be252b | 1 | Yes — transferFrom with no allowance |
InsufficientLiquidity() | 0xbb55fd27 | 1 | No — contract is exactly solvent; confirmed by disassembly |
TransferFailed() | 0x90b8ec18 | 2 | No — FXRP reverts rather than returning false; confirmed by disassembly |
Reentered() | 0xb5dfd9e5 | 2 | No — requires a callback the underlying does not have |
Plus the standard Panic(uint256) handler (0x4e487b71), reached once, as described in §9.1. The runtime contains no Error(string) handler (0x08c379a0 absent), i.e. the contract itself never emits a string revert reason; any string reason a caller sees has bubbled up from FXRP.
| Function | Order | Assessment |
|---|---|---|
deposit | checks → interaction → effects → events | Not strict CEI. Safe: locked, single trusted callee, and the += operations re-read storage. See I-03. |
withdraw | checks → effects → interaction → event | Strict CEI. Correct. |
transfer / transferFrom / approve | no interaction | N/A |
Return values of both CALLs are checked. No low-level call is used, no raw assembly, no returndata juggling, no try/catch that could swallow a failure. The contract does not use SafeERC20; this is acceptable because underlying is a single, fixed, ABI-conformant token that returns bool, and because a non-conformant return would cause an ABI-decode revert — failing closed, never silently succeeding.
The contract credits the requested amount, not the observed balance delta. It therefore assumes the underlying is neither fee-on-transfer nor rebasing. As of this review that assumption holds and was verified both statically (no transferFee anywhere in the 64 verified FAsset source files; no blocklist) and empirically (25.000000 FXRP sent, 25.000000 received, zero loss). FAsset is a checkpointed but non-rebasing token: balances change only through mint, burn, and transfer.
The residual exposure is that FXRP is UUPS-upgradeable while this bridge is not. See M-01 and M-02.
Standard ERC-20 approval front-running applies in full: approve sets an absolute value, so a spender watching the mempool can spend the old allowance and then the new one. There is no permit, no increaseAllowance/decreaseAllowance, and no approve-must-be-zero-first guard. This is normal and well understood; it is listed because an audit of this shape should not omit it. Mitigation is behavioural: set to zero, then set the new value. See L-04.
Handled at every point it matters: the constructor rejects a zero underlying; _transfer rejects a zero destination, which is what protects the solvency invariant; deposit and withdraw operate on msg.sender and so cannot involve the zero address. approve(address(0), x) is permitted and inert (I-09). The zero address appears in logs only as the mint/burn counterparty, which is correct convention.
locked flaglocked is uint256 private at storage slot 3, confirmed by both the derived layout and the disassembly (PUSH1 0x01; PUSH1 0x03; SSTORE immediately before the NotOpen check in deposit). It is 0 at rest in live state, so the lock is not stuck and the contract is not bricked.
Observations:
SSTOREs per guarded call. The contract targets EVM paris, and the census confirms TLOAD/TSTORE counts of zero, so EIP-1153 transient storage is not used. On a post-Cancun chain this would be the cheaper and self-clearing idiom. This is a gas note, not a security issue. See I-05.uint256 rather than bool avoids the masking overhead of a packed slot and is the conventional choice.locked is a per-call reentrancy guard, not a pause. Nothing can set it persistently.Cannot:
myXRP without first transferring the matching FXRP.myXRP, or any FXRP backing it.underlying, maxAssets, OPENS_AT, decimals, name, or symbol.deposit or withdraw, in either direction, thanks to the shared lock.OPENS_AT, which has in any case passed.unchecked block.withdraw has no cap gate, no time gate, and no allowance requirement — it only needs the caller's own balance and sufficient contract liquidity.Can:
No loops, no dynamic arrays, no unbounded iteration, no storage growth per user beyond a single mapping slot, no external call in a loop, and no gas-dependent control flow. Gas cost per operation is constant. The contract cannot be gas-griefed and cannot grow into an unusable state.
Severity reflects impact on user funds combined with likelihood, following ordinary audit convention.
| ID | Severity | Title | Status |
|---|---|---|---|
| — | Critical | No critical finding. The review found none. | — |
| — | High | No high finding. The review found none. | — |
| M-01 | Medium | Deposit credits the requested amount, not the observed balance delta | Open — accepted by design, cannot be remediated at this address |
| M-02 | Medium | Solvency and liquidity depend entirely on an upgradeable external token with mint, burn, and transfer-pause powers | Open — inherent, acknowledged |
| L-01 | Low | No rescue path for donated or mistakenly sent tokens | Open — accepted by design |
| L-02 | Low | No pause or circuit breaker, and the contract is immutable | Open — accepted by design |
| L-03 | Low | The cap limits outstanding supply, not cumulative issuance, and may be misread | Open — documentation |
| L-04 | Low | Standard ERC-20 approval race; no permit or increaseAllowance | Open — accepted, standard |
| I-01 | Informational | No EIP-2612 permit, no increaseAllowance/decreaseAllowance | Acknowledged |
| I-02 | Informational | Failures revert with custom errors rather than returning false | Acknowledged |
| I-03 | Informational | deposit performs its external call before the state write | Acknowledged — safe as deployed |
| I-04 | Informational | deposit with an enormous amount reverts Panic(0x11) rather than CapExceeded | Acknowledged |
| I-05 | Informational | Reentrancy lock uses storage, not transient storage (EVM target paris) | Acknowledged |
| I-06 | Informational | name() and symbol() return the identical string | Acknowledged |
| I-07 | Informational | No ERC-165, no checkpointing, no snapshot support | Acknowledged |
| I-08 | Informational | Entire supply is held by a single address | Observation only |
| I-09 | Informational | approve(address(0), x) is permitted | Acknowledged — inert |
| I-10 | Informational | Not ERC-4626 and should not be described as a vault | Acknowledged |
Impact: Medium. Likelihood: Low today. Status: Open; unfixable at this address.
deposit writes balanceOf[msg.sender] += amount using the caller's requested amount rather than measuring FXRP.balanceOf(address(this)) before and after the pull. If the underlying ever takes a fee on transfer, the contract credits more myXRP than FXRP it actually received, and the solvency invariant moves into deficit by the fee. Because redemption is strictly first-come, the shortfall would land entirely on the last redeemers.
This does not affect the contract as deployed: the current FAsset implementation has no transfer fee, which was confirmed both by searching all 64 verified source files and by observing a real 25.000000 deposit arrive in full. The exposure is forward-looking, and it is material only because FXRP is upgradeable while this bridge is not.
Recommended remediation, for any future deployment: measure the balance delta — snapshot underlying.balanceOf(address(this)) before and after transferFrom, and credit the difference. This is the standard hardening and it costs one additional STATICCALL. It cannot be applied to this address.
Impact: Medium to High if realised. Likelihood: Low. Status: Open; inherent to the design.
Verified on chain: FXRP is an EIP-1967 proxy with upgradeTo/upgradeToAndCall; its FAsset implementation exposes mint(address,uint256) and burn(address,uint256) restricted to asset manager 0x2a3fe068cd92178554cabcf7c95adf49b4b0b6a8, with burn taking an arbitrary owner; and FAsset transfers revert when the asset manager's emergencyPauseLevel() reaches FULL_AND_TRANSFER. emergencyPauseLevel() currently returns 0.
Consequences for this bridge, which has no pause, no upgrade, no migration, and no reserve:
withdraw would revert InsufficientLiquidity above the remaining balance, and redemption would be first-come-first-served with no pro-rata mechanism.withdraw reverts atomically, no supply is burned in that case — holders are delayed, not expropriated. This is a direct consequence of the correct CEI ordering in withdraw.This is not a defect in the reviewed code. It is the trust assumption a holder accepts, and it is stated here so that it is not mistaken for an absence of risk. No remediation is available within the reviewed contract; the mitigation is disclosure.
FXRP sent to the contract other than through deposit, and any other ERC-20 sent to it at all, is permanently locked: there is no sweep, rescue, or operatorWithdraw, and FXRP can leave only against a myXRP burn. Native FLR cannot be sent at all, the contract being non-payable (live balance 0). This is the direct cost of having no privileged party, and it is the right trade: a rescue function is also a drain function. Surplus FXRP can only over-collateralise the outstanding supply, never under-collateralise it. Accepted by design.
Nothing can halt deposits — not the deployer, not anyone. If a defect were found in this contract, or if FXRP itself were compromised, the only response available is social: tell holders to redeem and stop depositing, and deploy a new contract. Conversely, nothing can halt redemptions either. For a contract with no discretionary power over user funds, this asymmetry favours the holder, and the review regards the choice as defensible. Accepted by design, recorded so that readers do not assume an emergency control exists.
maxAssets bounds concurrent totalSupply, not cumulative minting. The live history demonstrates this directly: 25.000000 minted, burned, and minted again, with room() returning to 9,999,975.000000 rather than 9,999,950.000000. Lifetime throughput is unbounded. Anyone quoting maxAssets should describe it as a ceiling on tokens outstanding at one time. Separately, maxAssets is not a statement about tokens already issued; at the time of reading, issuance stands at 25.000000 against a ceiling of 10,000,000.000000. Documentation only.
The standard ERC-20 front-running window on approve. No permit, no incremental allowance helpers. Mitigation is to zero an allowance before resetting it. Accepted, standard.
false is ERC-20-permitted but changes integrator error handling.deposit's external call precedes its state write. Verified safe as deployed: the function is locked, the callee is a single immutable trusted address, and the += operations re-read storage so no lost update is possible. Strict CEI would still be preferable in a future version.deposit(2^256-1) reverts Panic(0x11) rather than CapExceeded(). Confirmed live. Fails closed; only the error shape is suboptimal. An unchecked pre-check, or ordering the cap test as amount > maxAssets - totalSupply, would surface the domain error.SSTOREs per call; TSTORE/TLOAD would be cheaper on a Cancun-era target. Gas only.name() and symbol() both return "myXRP". Legal, slightly unusual; some interfaces display the two differently.balanceOfAt/totalSupplyAt; myXRP does not, so any snapshot-based integration must index Transfer logs.0x38A22D748582a98695489400fc46734848b37CAC (holder count 1). A concentration observation, not a vulnerability: no holder, however large, has any power over the contract or over anyone else's balance.approve(address(0), x) succeeds and returns true. Inert.Even on the finding that the contract itself is sound, a holder of myXRP retains the following risk, none of which this audit can remove:
myXRP is a claim on FXRP held by an immutable contract. Everything that can happen to FXRP — its upgradeability, its asset manager's mint and burn authority, its emergency pause, its collateralisation and agent model, its own redemption path to XRP — passes straight through. This is the dominant residual risk and it lives outside the reviewed code.name() and symbol() are not unique identifiers. Verify the address, not the ticker. This audit covers one address only.NotOpen, InsufficientLiquidity, TransferFailed, and Reentered have never fired on chain. Their correctness was established by disassembly rather than by live execution, which is a weaker form of evidence, though the paths are short and each was read instruction by instruction.MyXRPBridge at 0x14b9DD90edaAff6527c1b168550B3FAA15b9F6AF on Flare mainnet is a 2,917-byte, fully verified, non-upgradeable, ownerless 1:1 wrapper that holds official FXRP and issues an equal number of myXRP units at the same six decimals, and this review found no critical and no high-severity issue; it confirmed on chain that the contract has no owner, admin, pauser, upgrader, minter, oracle, or fee path, that no proxy or upgrade slot is set and no storage slot holds an address, that its runtime contains no DELEGATECALL, CREATE, CREATE2, or SELFDESTRUCT, that its only three external calls all target the one immutable FXRP address, that both mutating functions share a single reentrancy lock which is at rest, that the arithmetic contains no division or scaling and no inflation or self-transfer defect, that redemption is ungated by time or cap and simulates successfully for the entire outstanding supply at the current block, and that the solvency invariant FXRP.balanceOf(bridge) ≥ totalSupply currently holds exactly, at 25.000000 against 25.000000, within an immutable ceiling of 10,000,000.000000 and a deposit gate of 1 October 2026 00:00 Asia/Seoul that is fixed in bytecode; the two medium findings are not defects in this code but consequences of its deliberate minimalism — the contract credits the requested amount rather than the observed balance delta, and its solvency and liquidity depend wholly on FXRP, an upgradeable token whose asset manager can mint, burn, and pause transfers — so the correct summary for a reader is that holding myXRP is as safe as holding FXRP plus one immutable, ownerless, fee-free contract that cannot be changed by anyone, including its deployer, and that all remaining material risk lies in the underlying asset and in the permanence of a contract that can never be patched.
Prepared from live Flare mainnet state at block 71,252,013 on 3 October 2026. This report is a technical opinion on one contract's code and observable state. It is not a guarantee against loss, not an endorsement, not financial advice, and not a statement about any legal, regulatory, corporate, or operational matter — none of which can be verified from chain data and none of which were examined.