Skip to content
This repository was archived by the owner on Jul 10, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions contracts/escrow/interfaces/ICCTPV2MessageTransmitter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

/// @title ICCTPV2MessageTransmitter
/// @notice Minimal interface for Circle CCTP V2 MessageTransmitter
interface ICCTPV2MessageTransmitter {
/// @notice Receive a message from CCTP V2
/// @param message The CCTP message bytes
/// @param attestation The attestation bytes
/// @return success True if the message was successfully received
function receiveMessage(bytes calldata message, bytes calldata attestation) external returns (bool success);
}
12 changes: 12 additions & 0 deletions contracts/escrow/interfaces/IConfidentialEscrow.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

/// @title IConfidentialEscrow
/// @notice Minimal interface for confidential escrow contracts
interface IConfidentialEscrow {
/// @notice Fund an escrow from a specific address using confidential tokens
/// @param escrowId The ID of the escrow to fund
/// @param amount The amount of confidential tokens to fund
/// @param from The address tokens are transferred from
function fundFrom(uint256 escrowId, uint64 amount, address from) external;
}
14 changes: 14 additions & 0 deletions contracts/escrow/interfaces/IConfidentialUSDCWrapper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

/// @title IConfidentialUSDCWrapper
/// @notice Interface for wrapping/unwrapping plain USDC into confidential USDC
interface IConfidentialUSDCWrapper {
/// @notice Wrap plain USDC into confidential USDC
/// @param amount Amount of plain USDC to wrap
function wrap(uint64 amount) external;

/// @notice Unwrap confidential USDC back into plain USDC
/// @param amount Amount of confidential USDC to unwrap
function unwrap(uint64 amount) external;
}
11 changes: 11 additions & 0 deletions contracts/escrow/interfaces/IEscrow.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

/// @title IEscrow
/// @notice Minimal interface for escrow contracts that accept ERC20 funding
interface IEscrow {
/// @notice Fund an escrow with a given amount of tokens
/// @param escrowId The ID of the escrow to fund
/// @param amount The amount of tokens to fund
function fund(uint256 escrowId, uint256 amount) external;
}
72 changes: 72 additions & 0 deletions contracts/escrow/receivers/CCTPV2ConfidentialEscrowReceiver.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

import {ICCTPV2MessageTransmitter} from "../interfaces/ICCTPV2MessageTransmitter.sol";
import {IConfidentialEscrow} from "../interfaces/IConfidentialEscrow.sol";
import {IConfidentialUSDCWrapper} from "../interfaces/IConfidentialUSDCWrapper.sol";

contract CCTPV2ConfidentialEscrowReceiver is Ownable {
using SafeERC20 for IERC20;

ICCTPV2MessageTransmitter public immutable cctpV2Transmitter;
IConfidentialEscrow public immutable escrow;
IERC20 public immutable usdc;
IConfidentialUSDCWrapper public immutable confidentialUsdc;

event MessageReceived(bytes32 indexed nonceHash, uint256 amount, uint256 escrowId);
event Recovered(IERC20 indexed token, uint256 amount, address indexed to);
event ConfidentialUsdcRecovered(uint64 amount, address indexed to);

constructor(
address cctpV2Transmitter_,
address escrow_,
address usdc_,
address confidentialUsdc_,
address initialOwner
) Ownable(initialOwner) {
cctpV2Transmitter = ICCTPV2MessageTransmitter(cctpV2Transmitter_);
escrow = IConfidentialEscrow(escrow_);
usdc = IERC20(usdc_);
confidentialUsdc = IConfidentialUSDCWrapper(confidentialUsdc_);
}

function receiveMessage(bytes calldata message, bytes calldata attestation, uint256 escrowId) external {
bool success = cctpV2Transmitter.receiveMessage(message, attestation);
require(success, "CCTP receive failed");

uint256 balance = usdc.balanceOf(address(this));
require(balance > 0, "No USDC received");
uint64 balance64 = uint64(balance);

usdc.forceApprove(address(confidentialUsdc), balance);
confidentialUsdc.wrap(balance64);
escrow.fundFrom(escrowId, balance64, address(this));

emit MessageReceived(keccak256(message), balance, escrowId);
}

function recover(IERC20 token, uint256 amount, address to) external onlyOwner {
require(to != address(0), "Invalid recipient");
require(amount > 0, "Zero amount");
uint256 balance = token.balanceOf(address(this));
require(amount <= balance, "Insufficient balance");
token.safeTransfer(to, amount);
emit Recovered(token, amount, to);
}

function recoverConfidentialUsdc(uint64 amount, address to) external onlyOwner {
require(to != address(0), "Invalid recipient");
require(amount > 0, "Zero amount");
confidentialUsdc.unwrap(amount);
uint256 plainAmount = uint256(amount);
uint256 balance = usdc.balanceOf(address(this));
require(plainAmount <= balance, "Insufficient plain balance after unwrap");
usdc.safeTransfer(to, plainAmount);
emit ConfidentialUsdcRecovered(amount, to);
emit Recovered(usdc, plainAmount, to);
}
}
87 changes: 87 additions & 0 deletions contracts/escrow/receivers/CCTPV2EscrowReceiver.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

import {ICCTPV2MessageTransmitter} from "../interfaces/ICCTPV2MessageTransmitter.sol";
import {IEscrow} from "../interfaces/IEscrow.sol";

/// @title CCTPV2EscrowReceiver
/// @notice Receives USDC via Circle CCTP V2 and forwards it to an escrow contract.
/// @dev Defense-in-depth: if the downstream escrow.fund call reverts after CCTP nonce
/// consumption, inbound USDC is trapped in this contract. Owner-only recover()
/// allows retrieval of stuck funds.
contract CCTPV2EscrowReceiver is Ownable {
using SafeERC20 for IERC20;

/// @notice The CCTP V2 MessageTransmitter contract
ICCTPV2MessageTransmitter public immutable cctpV2Transmitter;

/// @notice The escrow contract to forward funds to
IEscrow public immutable escrow;

/// @notice The USDC token contract
IERC20 public immutable usdc;

/// @notice Emitted when a CCTP message is successfully received and forwarded
/// @param nonceHash Keccak256 hash of the CCTP message
/// @param amount Amount of USDC forwarded
/// @param escrowId The escrow ID funded
event MessageReceived(bytes32 indexed nonceHash, uint256 amount, uint256 escrowId);

/// @notice Emitted when owner recovers stuck tokens
/// @param token The token recovered
/// @param amount Amount recovered
/// @param to Recipient address
event Recovered(IERC20 indexed token, uint256 amount, address indexed to);

/// @param cctpV2Transmitter_ Address of the CCTP V2 MessageTransmitter
/// @param escrow_ Address of the escrow contract
/// @param usdc_ Address of the USDC token
/// @param initialOwner Address of the initial owner
constructor(address cctpV2Transmitter_, address escrow_, address usdc_, address initialOwner)
Ownable(initialOwner)
{
cctpV2Transmitter = ICCTPV2MessageTransmitter(cctpV2Transmitter_);
escrow = IEscrow(escrow_);
usdc = IERC20(usdc_);
}

/// @notice Receive a CCTP V2 message, extract USDC, and forward to escrow
/// @param message The CCTP message bytes
/// @param attestation The CCTP attestation bytes
/// @param escrowId The ID of the escrow to fund
/// @dev CCTP nonce is consumed on first receipt. If escrow.fund reverts,
/// funds remain in this contract and can only be recovered via recover().
function receiveMessage(bytes calldata message, bytes calldata attestation, uint256 escrowId) external {
bool success = cctpV2Transmitter.receiveMessage(message, attestation);
require(success, "CCTP receive failed");

uint256 balance = usdc.balanceOf(address(this));
require(balance > 0, "No USDC received");

// Forward to escrow. If this reverts, funds remain in this contract.
// CCTP nonce is already consumed, so a retry will fail.
usdc.safeTransfer(address(escrow), balance);
escrow.fund(escrowId, balance);

emit MessageReceived(keccak256(message), balance, escrowId);
}

/// @notice Owner-only recovery of stuck tokens
/// @param token The ERC20 token to recover
/// @param amount Amount to recover
/// @param to Recipient address
/// @dev Defense-in-depth for ESC-MN-01: funds stuck when downstream call reverts
/// after cctpV2Transmitter.receiveMessage succeeds.
function recover(IERC20 token, uint256 amount, address to) external onlyOwner {
require(to != address(0), "Invalid recipient");
require(amount > 0, "Zero amount");
uint256 balance = token.balanceOf(address(this));
require(amount <= balance, "Insufficient balance");
token.safeTransfer(to, amount);
emit Recovered(token, amount, to);
}
}
57 changes: 57 additions & 0 deletions docs/audits/audit-readiness-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Audit Readiness Checklist

> Tracks disposition of every PRVD-33 finding per acceptance criteria in DEV-126.

## Legend

- ✅ Fixed — code merged, tests passing
- 🟡 Accepted — explicitly accepted with documented rationale
- ⏳ Pending — awaiting design discussion or auditor input
- 🔲 Not yet evaluated

---

## Phase 3 — Escrow

### Highs

| ID | Finding | Disposition | Rationale / PR |
|---|---|---|---|
| ESC-MN-01 | CCTP receivers leave funds stuck if downstream call reverts after `receiveMessage` succeeds | ✅ Fixed | Added `recover()` defense-in-depth to `CCTPV2EscrowReceiver` and `CCTPV2ConfidentialEscrowReceiver`; `recoverConfidentialUsdc()` for encrypted variant. 16 test scenarios pass. |

### Mediums

| ID | Finding | Disposition | Rationale / PR |
|---|---|---|---|
| ESC-MN-02 | Resolver = unbounded trusted code | ⏳ Pending | Architectural decision deferred to external auditor input (plugin governance: whitelist vs permissionless vs timelock). |
| ESC-MN-03 | `CCTPV2ReceiverLib.HOOK_DATA_OFFSET = 376` not independently verified | ⏳ Pending | Will be addressed in Phase 5 orchestration refactor; constant drift risk noted. |
| ESC-MN-04 | Fee-recipient revert DoSes redemption | ⏳ Pending | Design decision needed: pull-payment vs per-iteration try/catch. Insurance manager is privileged so blast radius is bounded. |
| ESC-MN-05 | `setConfidentialUsdc` does not revoke `setOperator` on previous wrapper | ⏳ Pending | Trivial fix; batch into pre-mainnet hardening PR once Medium design decisions are finalized. |
| ESC-MN-06 | `EscrowRedeemed` / `EscrowBatchRedeemed` emit unconditionally | ⏳ Pending | Rename to `RedeemAttempted` or emit only after decrypted success ack — design decision. |
| ESC-MN-07 | Owner does not receive `FHE.allow` on escrow creation | ⏳ Pending | Platform-mediated model vs `grantOwnerAccess(escrowId)` opt-in — architectural decision. |
| ESC-MN-08 | Insurance manager lacks retroactive `FHE.allow` | ⏳ Pending | Enforce manager-set-before-creation in deploy script, or add admin migration — ops decision. |

### Lows / Infos

| ID | Finding | Disposition | Rationale / PR |
|---|---|---|---|
| ESC-MN-12 | `EscrowBatchRedeemed` emits input array including skipped IDs | 🟡 Accepted | Minor indexer inconvenience; filtered list would increase gas. Documented in event specs. |
| ESC-MN-13 | `paidAmount` updates re-grant only `FHE.allowThis` | 🟡 Accepted | Fresh ciphertext per fund event is acceptable for privacy model; documented. |
| ESC-MN-15 | Gas profiling for `redeemMultiple` at MAX_BATCH_SIZE=20 | ⏳ Pending | Profiling scheduled before mainnet; tighten to 10 if Arbitrum block limit exceeded. |
| ESC-MN-17, 18 | Test coverage gaps (smoke-only, missing happy-path) | 🔲 Not yet evaluated | Punted to PRVD-33a backlog. |
| ESC-MN-19 | `Escrow.create` accepts `amount_ = 0` | 🟡 Accepted | Trivial fix; will batch into hardening PR opportunistically. |
| ESC-MN-20 | `EscrowFunded` event lacks amount field | 🟡 Accepted | Trivial fix; will batch into hardening PR opportunistically. |

---

## Phase 4 — Insurance

(Not yet populated.)

## Phase 5 — Orchestration

(Not yet populated.)

## Phase 6 — Tokens

(Not yet populated.)
33 changes: 33 additions & 0 deletions docs/audits/pre-audit-2026-05.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Pre-Audit Report 2026-05

## Phase 3 — Escrow Findings

### §4.3.M Escrow Medium/High Findings

#### ESC-MN-01 — CCTP receivers leave funds stuck if downstream call reverts (HIGH)

**Severity:** High
**Status:** Remediated
**Files:** `packages/escrow/contracts/receivers/CCTPV2EscrowReceiver.sol`, `packages/escrow/contracts/receivers/CCTPV2ConfidentialEscrowReceiver.sol`

**Description:**
CCTP V2 receivers call `cctpV2Transmitter.receiveMessage` to mint inbound USDC, then forward to `escrow.fund` (plain) or `confidentialUsdc.wrap` → `escrow.fundFrom` (confidential). The CCTP V2 nonce is consumed on first successful receipt. If any downstream call reverts after `receiveMessage` succeeds, the inbound USDC is trapped in the receiver contract with no retry path.

**Mitigation Applied:**
- Added owner-only `recover(IERC20 token, uint256 amount, address to)` to both `CCTPV2EscrowReceiver` and `CCTPV2ConfidentialEscrowReceiver`.
- Added owner-only `recoverConfidentialUsdc(uint64 amount, address to)` to `CCTPV2ConfidentialEscrowReceiver` for encrypted-balance recovery.
- Verified via Foundry tests covering success path, only-owner access, balance limits, and end-to-end stuck-fund recovery for both plain and confidential variants.

---

### §4.4 Follow-ups

(Reserved for Phase 4 — Insurance findings.)

### §4.5 Follow-ups

(Reserved for Phase 5 — Orchestration findings.)

### §4.6 Follow-ups

(Reserved for Phase 6 — Tokens findings.)
Loading
Loading