
icRamp Devlog #21 — Vault Refactor & ic-alloy EVM Reads
Continuation of Devlog #20 — Pay with Crypto (Settle & Verify).
There we finished the “pay with crypto” flow: multi-chain deposits, trustless verification on BTC/EVM/SOL/ICP, and order fills tied to real chain transactions.
In this final devlog for the milestone we clean up the infrastructure behind that feature:
Ship IcRamp v2 on Sepolia with a much simpler escrow model.
Remove all commit/uncommit on-chain state and move flow control fully into the ICP canisters.
Simplify gas tracking & transaction orchestration to only care about
CancelandRelease.Introduce
ic-alloyto call the vault contract’sgetDepositvia an on-chain view from inside an ICP canister.
This is the “plumbing” episode. After this, the grant milestone is structurally complete: Stripe, PayPal, Revolut, crypto deposits, partial fills, and now a clean, auditable vault layer that reflects the actual protocol design.
1. Why IcRamp v2? Removing on-chain commit/uncommit
In the original design, the EVM vault knew about order-level state:
commitDepositanduncommitDepositlived on the contract.- The canister would:
commitDeposit(offramper, token, amount)when locking an order.uncommitDeposit(...)when unlocking/cancelling.
This gave us a second state machine on L1 mirroring what we already maintain in ICP stable memory (orders, locks, fills, lock time, etc.).
That duplication had several problems:
- Complexity: every new asset / chain had to obey both ICP logic and L1 commit/uncommit semantics.
- Gas noise: we were paying extra gas for commit/uncommit that added no real security beyond what the escrow already gave us.
- Mental overhead: when debugging, you had to think “is this lock/unlock bug in the canister or in the EVM contract?”.
After implementing “pay with crypto” and fully wiring the ICP-side order state, it became obvious:
the only thing the EVM vault needs to track is deposit balances per offramper/token pair.
Order lifecycle belongs to ICP.
So IcRamp v2 does exactly that.
2. IcRamp v2: a minimal escrow surface
We split responsibilities very clearly:
IcRampis the externally-facing contract used by users and the ICP EVM canister.EscrowManageris an internal owned contract that just keeps balances:
// SPDX-License-Identifier: Unlicensed
pragma solidity ^0.8.20;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IEscrowManager} from "../model/Interfaces.sol";
import {Errors} from "../model/Errors.sol";
contract EscrowManager is Ownable, ReentrancyGuard, IEscrowManager {
// offramper => token => deposit amount
mapping(address => mapping(address => uint256)) private deposits;
constructor(address _owner) Ownable(_owner) {}
event FeeTracked(address indexed user, address indexed token, uint256 fees);
event Deposit(address indexed user, address indexed token, uint256 amount);
event Withdraw(address indexed user, address indexed token, uint256 amount);
event DepositConsumed(
address indexed user,
address indexed token,
uint256 amount
);
function getDeposit(
address _offramper,
address _token
) external view returns (uint256) {
return deposits[_offramper][_token];
}
function deposit(
address _offramper,
address _token,
uint256 _amount
) external nonReentrant onlyOwner {
if (_offramper == address(0)) revert Errors.ZeroAddress();
if (_amount == 0) revert Errors.ZeroAmount();
deposits[_offramper][_token] += _amount;
emit Deposit(_offramper, _token, _amount);
}
function withdraw(
address _offramper,
address _token,
uint256 _amount
) external nonReentrant onlyOwner {
if (_offramper == address(0)) revert Errors.ZeroAddress();
if (_amount == 0) revert Errors.ZeroAmount();
if (deposits[_offramper][_token] < _amount)
revert Errors.InsufficientFunds();
deposits[_offramper][_token] -= _amount;
emit Withdraw(_offramper, _token, _amount);
}
function consumeDeposit(
address _offramper,
address _token,
uint256 _amount
) external nonReentrant onlyOwner {
if (_offramper == address(0)) revert Errors.ZeroAddress();
if (_amount == 0) revert Errors.ZeroAmount();
if (deposits[_offramper][_token] < _amount) {
revert Errors.InsufficientFunds();
}
deposits[_offramper][_token] -= _amount;
emit DepositConsumed(_offramper, _token, _amount);
}
function trackFees(
address _receiver,
address _token,
uint256 _fees
) external nonReentrant onlyOwner {
deposits[_receiver][_token] += _fees;
emit FeeTracked(_receiver, _token, _fees);
}
}That’s it: no commit/uncommit at the escrow layer.
IcRamp itself becomes a thin, privileged façade that:
- Handles token transfers in/out.
- Delegates balance mutations to
EscrowManager. - Applies fees and gives special treatment to the ICP EVM canister (no fees on internal rebalancing).
Key pieces:
// SPDX-License-Identifier: Unlicensed
pragma solidity ^0.8.20;
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 {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IEscrowManager, ITokenManager, IRamp} from "./model/Interfaces.sol";
import {TokenManager} from "./managers/TokenManager.sol";
import {EscrowManager} from "./managers/EscrowManager.sol";
import {Errors} from "./model/Errors.sol";
contract IcRamp is Ownable, ReentrancyGuard, IRamp {
using SafeERC20 for IERC20;
IEscrowManager public immutable escrowManager;
ITokenManager public immutable tokenManager;
address public icpEvmCanister;
constructor(address _owner) Ownable(_owner) {
escrowManager = new EscrowManager(address(this));
tokenManager = new TokenManager(address(this));
icpEvmCanister = _owner;
}
modifier onlyIcpEvmCanister() {
if (msg.sender != icpEvmCanister) revert Errors.Unauthorized();
_;
}
function setIcpEvmCanister(address _icpEvmCanister) external onlyOwner {
icpEvmCanister = _icpEvmCanister;
}
// --- VIEW ---
function getDeposit(
address _user,
address _token
) external view returns (uint256) {
return escrowManager.getDeposit(_user, _token);
}
function isValidToken(address _token) external view returns (bool) {
return tokenManager.isValidToken(_token);
}
function getValidTokens() external view returns (address[] memory) {
return tokenManager.getValidTokens();
}
// --- OFFRAMPER ---
function depositToken(
address _token,
uint256 _amount
) external nonReentrant {
if (_token == address(0)) revert Errors.ZeroAddress();
if (!tokenManager.isValidToken(_token))
revert Errors.TokenNotAccepted();
escrowManager.deposit(msg.sender, _token, _amount);
IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
}
function depositBaseCurrency() external payable nonReentrant {
escrowManager.deposit(msg.sender, address(0), msg.value);
}
function withdrawToken(
address _offramper,
address _token,
uint256 _amount,
uint256 _fees
) external nonReentrant onlyIcpEvmCanister {
if (_offramper == address(0)) revert Errors.ZeroAddress();
if (_token == address(0)) revert Errors.ZeroAddress();
if (!tokenManager.isValidToken(_token))
revert Errors.TokenNotAccepted();
escrowManager.withdraw(_offramper, _token, _amount);
if (_offramper == icpEvmCanister) {
IERC20(_token).safeTransfer(_offramper, _amount);
} else {
IERC20(_token).safeTransfer(_offramper, _amount - _fees);
escrowManager.trackFees(icpEvmCanister, _token, _fees);
}
}
function withdrawBaseCurrency(
address _offramper,
uint256 _amount,
uint256 _fees
) external nonReentrant onlyIcpEvmCanister {
if (_offramper == address(0)) revert Errors.ZeroAddress();
escrowManager.withdraw(_offramper, address(0), _amount);
if (_offramper == icpEvmCanister) {
payable(_offramper).transfer(_amount);
} else {
payable(_offramper).transfer(_amount - _fees);
escrowManager.trackFees(icpEvmCanister, address(0), _fees);
}
}
// --- ONRAMPER ---
function releaseToken(
address _offramper,
address _onramper,
address _token,
uint256 _amount,
uint256 _fees
) external nonReentrant onlyIcpEvmCanister {
if (_onramper == address(0)) revert Errors.ZeroAddress();
if (_token == address(0)) revert Errors.ZeroAddress();
escrowManager.consumeDeposit(_offramper, _token, _amount);
IERC20(_token).safeTransfer(_onramper, _amount - _fees);
escrowManager.trackFees(icpEvmCanister, _token, _fees);
}
function releaseBaseCurrency(
address _offramper,
address _onramper,
uint256 _amount,
uint256 _fees
) external nonReentrant onlyIcpEvmCanister {
if (_offramper == address(0)) revert Errors.ZeroAddress();
if (_onramper == address(0)) revert Errors.ZeroAddress();
escrowManager.consumeDeposit(_offramper, address(0), _amount);
payable(_onramper).transfer(_amount - _fees);
escrowManager.trackFees(icpEvmCanister, address(0), _fees);
}
// --- TOKENS ---
function addValidTokens(address[] memory _tokens) external onlyOwner {
tokenManager.addValidTokens(_tokens);
}
function removeValidTokens(address[] memory _tokens) external onlyOwner {
tokenManager.removeValidTokens(_tokens);
}
}The important protocol rules are now crystal clear:
- Only offramper → vault deposits are tracked.
- Cancels and releases are just balance mutations plus transfers.
- Fees are always booked as an escrow deposit for icpEvmCanister.
For this iteration I deployed IcRamp v2 to Sepolia at 0xCe35EF5779660B91112d747f0EC32AcA41C65C1A.
I also added the same old's few test ERC20s as valid tokens on that deployment to exercise the new code path.
3. Updating the README & deployment config
The repository README now has two generations of contracts documented:
- The legacy
Ic2P2Rampdeployments. - The newer
IcRampdeployments on Sepolia, Base Sepolia, Optimism Sepolia, Mantle Sepolia, and mainnets.
For the purpose of this milestone, the line that matters is:
| Network | Contract Name | Address |
| ------- | ------------- | ------------------------------------------ |
| Sepolia | IcRamp | 0xCe35EF5779660B91112d747f0EC32AcA41C65C1A |On the ICP side, I wired that into the icramp_backend canister deployment:
dfx deploy icramp_backend --argument "(
variant {
Reinstall = record {
canister_ids = record {
bitcoin_backend_id = \"zhuzm-wqaaa-aaaap-qpk2q-cai\";
solana_backend_id = \"u6s2n-gx777-77774-qaaba-cai\";
};
ecdsa_key_id = record {
name = \"dfx_test_key\";
curve = variant { secp256k1 };
};
chains = vec {
record {
chain_id = 11155111 : nat64;
vault_manager_address = \"${CONTRACT_SEPOLIA}\";
services = variant { EthSepolia = opt vec { variant { Alchemy } } };
currency_symbol = \"ETH\";
};
// ...
};
}
}
)"Where ${CONTRACT_SEPOLIA} is set to the new IcRamp address via environment.
On initialization the canister now “knows”:
- For
chain_id = 11155111, the vault manager isIcRampat0xCe35E.... - All EVM release/cancel flows must go through that contract.
4. Backend refactor: TransactionAction, gas tracking & unlock
Once the Solidity surface was simplified, the ICP backend could finally drop all Commit/Uncommit baggage.
4.1 TransactionAction: only Cancel, Release, Transfer
The TransactionAction enum is now:
#[derive(CandidType, Deserialize, Debug, Clone)]
pub enum TransactionVariant {
Native,
Token,
}
#[derive(CandidType, Deserialize, Debug, Clone)]
pub enum TransactionAction {
Cancel(TransactionVariant),
Release(TransactionVariant),
Transfer(TransactionVariant),
}And it maps directly to the IcRamp ABI:
impl TransactionAction {
pub fn abi(&self) -> &'static str {
match self {
TransactionAction::Cancel(TransactionVariant::Native) => CANCEL_NATIVE_ABI,
TransactionAction::Cancel(TransactionVariant::Token) => CANCEL_TOKEN_ABI,
TransactionAction::Release(TransactionVariant::Native) => RELEASE_NATIVE_ABI,
TransactionAction::Release(TransactionVariant::Token) => RELEASE_TOKEN_ABI,
TransactionAction::Transfer(TransactionVariant::Token) => TRANSFER_TOKEN_ABI,
_ => "",
}
}
pub fn function_name(&self) -> &'static str {
match self {
TransactionAction::Cancel(TransactionVariant::Native) => "withdrawBaseCurrency",
TransactionAction::Cancel(TransactionVariant::Token) => "withdrawToken",
TransactionAction::Release(TransactionVariant::Native) => "releaseBaseCurrency",
TransactionAction::Release(TransactionVariant::Token) => "releaseToken",
TransactionAction::Transfer(TransactionVariant::Token) => "transfer",
_ => "",
}
}
}The ABIs themselves exactly mirror the Solidity signatures we saw above:
const CANCEL_NATIVE_ABI: &str = r#"
[
{
"inputs": [
{"internalType": "address", "name": "_offramper", "type": "address"},
{"internalType": "uint256", "name": "_amount", "type": "uint256"},
{"internalType": "uint256", "name": "_fees", "type": "uint256"}
],
"name": "withdrawBaseCurrency",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
"#;
// ... CANCEL_TOKEN_ABI / RELEASE_NATIVE_ABI / RELEASE_TOKEN_ABI / TRANSFER_TOKEN_ABI ...So when the canister wants to:
- Cancel an order ⇒ call
withdraw*with(offramper, [token], amount, fees). - Release a fill ⇒ call
release*with(offramper, onramper, [token], amount, fees).
No commit/uncommit round-trips.
4.2 Gas tracking only for the relevant actions
Gas tracking now focuses on actions that actually matter for user experience and fees:
#[derive(Clone, Debug, Default, CandidType, Deserialize)]
pub struct ChainGasTracking {
pub cancel_token_gas: GasUsage,
pub cancel_native_gas: GasUsage,
pub release_token_gas: GasUsage,
pub release_native_gas: GasUsage,
}register_gas_usage simply routes by TransactionAction:
pub fn register_gas_usage(
chain_id: u64,
gas: u64,
gas_price: u128,
block_number: u128,
action_type: &TransactionAction,
) -> Result<()> {
mutate_state(|state| {
let chain_state = state
.chains
.get_mut(&chain_id)
.ok_or(BlockchainError::ChainIdNotFound(chain_id))?;
match action_type {
TransactionAction::Release(TransactionVariant::Token) => {
chain_state
.gas_tracking
.release_token_gas
.record_gas_usage(gas, gas_price, block_number);
}
TransactionAction::Release(TransactionVariant::Native) => {
chain_state
.gas_tracking
.release_native_gas
.record_gas_usage(gas, gas_price, block_number);
}
TransactionAction::Cancel(TransactionVariant::Native) => {
chain_state
.gas_tracking
.cancel_native_gas
.record_gas_usage(gas, gas_price, block_number);
}
TransactionAction::Cancel(TransactionVariant::Token) => {
chain_state
.gas_tracking
.cancel_token_gas
.record_gas_usage(gas, gas_price, block_number);
}
_ => (),
};
Ok(())
})
}And get_average_gas uses that to feed into the transaction builder for withdraw_* and release_*.
4.3 Release & withdraw paths
Given this shape, the EVM release is extremely direct now:
pub fn release_inputs(
offramper: String,
onramper: String,
token: Option<String>,
amount: u128,
fee: u128,
) -> Result<(Vec<Token>, TransactionAction)> {
let mut inputs: Vec<Token> = vec![
Token::Address(helpers::parse_address(offramper)?),
Token::Address(helpers::parse_address(onramper)?),
Token::Uint(U256::from(amount)),
Token::Uint(U256::from(fee)),
];
let mut transaction_variant = TransactionVariant::Native;
if let Some(token) = token {
inputs.insert(2, Token::Address(helpers::parse_address(token)?));
transaction_variant = TransactionVariant::Token;
}
Ok((inputs, TransactionAction::Release(transaction_variant)))
}And cancellation is symmetric:
pub fn withdraw_inputs(
offramper: String,
amount: u128,
fees: u128,
token: Option<String>,
) -> Result<(Vec<Token>, TransactionAction)> {
let mut inputs: Vec<Token> = vec![
Token::Address(helpers::parse_address(offramper)?),
Token::Uint(U256::from(amount)),
Token::Uint(U256::from(fees)),
];
let mut transaction_variant = TransactionVariant::Native;
if let Some(token) = token {
inputs.insert(1, Token::Address(helpers::parse_address(token)?));
transaction_variant = TransactionVariant::Token;
}
Ok((inputs, TransactionAction::Cancel(transaction_variant)))
}Both go through the same get_vault_and_data helper:
pub fn get_vault_and_data(
chain_id: u64,
transaction_type: &TransactionAction,
inputs: &[Token],
) -> Result<(String, Vec<u8>)> {
Ok((
get_vault_manager_address(chain_id)?,
load_contract_data(
transaction_type.abi(),
transaction_type.function_name(),
inputs,
)?,
))
}So the vault call layer is now:
Order logic in ICP→TransactionAction + inputs→ABI encoding→IcRampfunction call.
No special-casing for commit/uncommit.
4.4 Unlock order: ICP-only state
Unlocking an order is now purely an ICP state transition (plus side-chains for BTC/Solana):
pub async fn unlock_order(order_id: u64) -> Result<()> {
let order = memory::stable::orders::get_order(&order_id)?.locked()?;
if order.payment_done {
return Err(OrderError::PaymentDone)?;
}
if order.uncommited {
return Err(OrderError::OrderUncommitted)?;
}
if order.is_inside_lock_time() {
return Err(OrderError::OrderInLockTime)?;
}
let user = memory::stable::users::get_user(&order.onramper.user_id)?;
user.validate_onramper()?;
match order.base.crypto.asset {
BlockchainAsset::EVM { .. } | BlockchainAsset::ICP { .. } => {
memory::stable::orders::unlock_order(order.base.id)?;
Ok(())
}
BlockchainAsset::Bitcoin { rune_id } => {
memory::stable::orders::unlock_order(order.base.id)?;
bitcoin_backend_unlock_funds(
order.base.offramper_address.address,
order.onramper.address.address,
order.base.crypto.amount as u64,
rune_id,
)
.await?;
Ok(())
}
BlockchainAsset::Solana { spl_token } => {
memory::stable::orders::unlock_order(order.base.id)?;
solana_backend_unlock_funds(
order.base.offramper_address.address,
order.onramper.address.address,
order.base.crypto.amount as u64,
spl_token,
)
.await?;
Ok(())
}
}
}For EVM, the vault never tracks “lock vs unlock” at the order level anymore. The canister alone decides whether an order is locked, expired, or free to be cancelled.
5. Broadcast & listeners: Commit/Uncommit paths removed
broadcast_transaction no longer has to worry about Commit or Uncommit branches. Only:
Cancel(...)→ spawn a cancel listener that marks the order as cancelled on success.Release(...)→ spawn a release listener that:- Finalizes pending fills (marking the crypto payment on-chain as done).
- Marks the order as completed if fully filled.
Transfer(...)→ fire-and-forget (just logs as confirmed).
The listeners are correspondingly simpler:
pub fn spawn_cancel_listener(
order_id: u64,
chain_id: u64,
cancel_variant: TransactionVariant,
tx_hash: &str,
sign_request: SignRequest,
) {
transaction::spawn_transaction_checker(
0,
tx_hash.to_string(),
chain_id,
order_id,
sign_request,
move |receipt| {
register_gas_usage(
chain_id,
&receipt,
&TransactionAction::Cancel(cancel_variant.clone()),
);
match memory::stable::orders::cancel_order(order_id) {
Ok(()) => ic_cdk::println!("[withdraw] order {:?} is cancelled!", order_id),
Err(e) => ic_cdk::println!(
"[withdraw] failed to cancel order #{:?}, error: {:?}",
order_id,
e
),
}
},
on_fail_callback(order_id),
);
}And:
pub fn spawn_release_listener(
order_id: u64,
chain_id: u64,
release_variant: TransactionVariant,
tx_hash: &str,
sign_request: SignRequest,
) {
transaction::spawn_transaction_checker(
0,
tx_hash.to_string(),
chain_id,
order_id,
sign_request,
move |receipt| {
register_gas_usage(
chain_id,
&receipt,
&TransactionAction::Release(release_variant.clone()),
);
let _ = finalize_pending_fill(order_id, Some(receipt.transactionHash.to_string()));
match set_order_completed(order_id) {
Ok(()) => ic_cdk::println!("[release_funds] order lock {} filled", order_id),
Err(e) => ic_cdk::println!(
"[release_funds] could not complete order: {}, error: {:?}",
order_id,
e
),
}
},
super::on_fail_callback(order_id),
);
}These match precisely what “cancel” and “release” mean in the business logic — without involving any L1 commit state.
6. ic-alloy: calling getDeposit from an ICP canister
To round off the milestone, I also wired in ic-alloy to read the vault state directly from ICP.
The goal: from inside the icramp_backend canister, call:
function getDeposit(address _offramper, address _token) external view returns (uint256);on the EVM contract, via eth_call.
6.1 Defining the interface with sol!
Using ic-alloy:
use alloy::primitives::Address;
use alloy::sol;
use alloy::sol_types::SolCall;
// ...
sol! {
#[sol(rpc)]
contract IcRamp {
function getDeposit(address _offramper, address _token) external view returns (uint256);
}
}This generates a strongly-typed IcRamp::getDepositCall struct.
Encoding the call:
pub fn encode_get_deposit(offramper: &str, token: &str) -> Result<Vec<u8>> {
let off = offramper
.parse::<Address>()
.map_err(|e| BlockchainError::EthersAbiError(format!("offramper parse: {:?}", e)))?;
let tok = token
.parse::<Address>()
.map_err(|e| BlockchainError::EthersAbiError(format!("token parse: {:?}", e)))?;
Ok(IcRamp::getDepositCall {
_offramper: off,
_token: tok,
}
.abi_encode())
}6.2 Resolving RPC URLs via chain config
The canister already has a RpcServices enum describing where to send EVM JSON-RPC.
I added a small helper to convert that to a (url, headers) pair:
fn resolve_rpc_url_for_chain(chain_id: u64) -> Result<(String, Vec<HttpHeaderEvm>)> {
let services = get_rpc_providers(chain_id)?;
match services {
RpcServices::Custom { services, .. } => {
let api: &RpcApi = services
.first()
.ok_or(BlockchainError::RpcProviderNotFound)?;
Ok((api.url.clone(), api.headers.clone().unwrap_or_default()))
}
RpcServices::EthSepolia(Some(list)) => {
for svc in list {
let url = match svc {
EthSepoliaService::Sepolia => Some("https://rpc.sepolia.org".to_string()),
EthSepoliaService::PublicNode => {
Some("https://ethereum-sepolia-rpc.publicnode.com".to_string())
}
EthSepoliaService::BlockPi => {
Some("https://ethereum-sepolia.blockpi.network/v1/rpc/public".to_string())
}
EthSepoliaService::Ankr => {
Some("https://rpc.ankr.com/eth_sepolia".to_string())
}
EthSepoliaService::Alchemy => None, // handled via Custom elsewhere
};
if let Some(url) = url {
return Ok((url, Vec::new()));
}
}
Err(BlockchainError::RpcProviderNotFound.into())
}
_ => Err(BlockchainError::RpcProviderNotFound.into()),
}
}And a tiny helper to split URLs for the HTTP proxy we use on ICP:
fn split_url_for_proxy(full_url: &str) -> (String, String) {
let without_proto = full_url
.trim_start_matches("https://")
.trim_start_matches("http://");
if let Some((host, path)) = without_proto.split_once('/') {
(host.to_string(), format!("/{}", path))
} else {
(without_proto.to_string(), "/".to_string())
}
}6.3 Making the eth_call via http_request
Putting it all together:
use ic_cdk::api::management_canister::http_request::{
CanisterHttpRequestArgument, HttpHeader, HttpMethod, http_request,
};
pub async fn get_deposit_via_ic_alloy(
chain_id: u64,
icramp_address: &str,
offramper: &str,
token: &str,
) -> Result<u128> {
let proxy_url = crate::model::memory::heap::read_state(|s| s.proxy_url.clone());
let (rpc_url, _) = resolve_rpc_url_for_chain(chain_id)?;
let (base_url, endpoint) = split_url_for_proxy(&rpc_url);
let data = encode_get_deposit(offramper, token)?;
let data_hex = format!("0x{}", hex::encode(data));
let params = json!([{
"to": icramp_address,
"data": data_hex,
}, "latest"]);
let request = CanisterHttpRequestArgument {
url: format!("{}/{}", proxy_url, endpoint),
method: HttpMethod::POST,
body: Some(
json!({
"jsonrpc": "2.0",
"method": "eth_call",
"params": params,
"id": 1
})
.to_string()
.into_bytes(),
),
max_response_bytes: Some(2048),
transform: None,
headers: vec![
HttpHeader {
name: "Content-Type".to_string(),
value: "application/json".to_string(),
},
HttpHeader {
name: "x-forwarded-host".to_string(),
value: base_url.to_string(),
},
],
};
let cycles = 10_000_000_000;
let (response,) = http_request(request, cycles)
.await
.map_err(|(code, msg)| SystemError::HttpRequestError(code as u64, msg.to_string()))?;
if response.status.ne(&candid::Nat::from(200u32)) {
return Err(BlockchainError::EvmExecutionReverted(
0,
"HTTP error in getDeposit".to_string(),
)
.into());
}
let str_body = std::str::from_utf8(&response.body)
.map_err(|_| BlockchainError::EvmExecutionReverted(0, "utf8".to_string()))?;
let json_response: serde_json::Value = serde_json::from_str(str_body)
.map_err(|e| BlockchainError::EvmExecutionReverted(0, e.to_string()))?;
if let Some(result) = json_response.get("result").and_then(|r| r.as_str()) {
let clean = result.trim_start_matches("0x");
let value = u128::from_str_radix(clean, 16)
.map_err(|_| BlockchainError::EvmExecutionReverted(0, "parse u128".to_string()))?;
Ok(value)
} else {
Err(BlockchainError::EvmExecutionReverted(0, "missing result".to_string()).into())
}
}For testing, I exposed a small debug query:
#[ic_cdk::query]
async fn debug_get_evm_deposit(
chain_id: u64,
icramp_address: String,
offramper: String,
token: String,
) -> Result<u128> {
ic_alloy_icramp::get_deposit_via_ic_alloy(chain_id, &icramp_address, &offramper, &token).await
}This lets me check, from the ICP side, that IcRamp v2’s internal deposits[offramper][token] matches what the UI and orderbook believe.
In the future, this can evolve into:
- A sanity checker for vault invariants.
- A reconciliation tool for bug-hunting (“did we mis-credit this offramper?”).
- A foundation for simple proofs-of-reserve for the vault canister.
7. Milestone recap & next steps
This devlog is the last one for this grant milestone, so a quick recap is in order.
Over the last 20+ devlogs we’ve shipped:
- Multi-chain vault architecture across BTC, EVM, SOL, ICP.
- Stripe Connect with card → Connect payouts, email providers and per-order redirects.
- PayPal and Revolut integrations.
- Partial fills (“liquid orders”) with lock amounts, remaining checks, and aggregate fills.
- “Pay with crypto” flows:
- Onramper chooses a crypto provider whose asset matches the offramper’s order.
- Funds move wallet ↔ wallet trustlessly on-chain.
- Backend verifies chain-specific txs and logs fills with correct
payment_id.
- IcRamp v2:
- Minimal EVM vault interface: deposit, withdraw, release, track fees.
- No on-chain commit/uncommit; ICP owns the business state.
- Clean gas tracking, simpler transaction orchestration.
ic-alloy-basedgetDepositcalls from ICP.
From here, the obvious “post-milestone” directions are:
- Extend IcRamp v2 deployment to more networks (Base, Optimism, Arbitrum) with the same pattern.
- Build reconciliation/analytics tooling on top of
getDeposit_via_ic_alloy. - Keep polishing UX on the frontend now that the underlying vault layer is stable.
But as far as this milestone is concerned, the plumbing is now in the right shape. Time to record the video, show the full Stripe + PayPal + Revolut + Crypto + Vault story end-to-end, and then move on to the next phase.
Stay Updated
Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.
You might also like

icRamp Devlog #17 — Liquid Orders: Partial Fills
We add partial fills: the onramper can lock only a fraction of the order, pay, and get a proportional crypto payout while the rest stays open. Single lock path, pro-rata fees, idempotent fill records, and listener-safe completion.

icRamp Devlog #20 — Pay with Crypto (Settlement & Verification)
We finish the pay-with-crypto flow: from Locked orders to on-chain payments, matching provider assets, and verifying EVM/Solana txs on the backend.

icRamp Devlog #19 — Pay with Crypto (Frontend UX & Provider Flows)
Frontend wiring for the experimental 'pay with crypto' path: crypto providers in the user profile, filtered provider selection in Create Order, and a compact order card UX for onrampers choosing how to pay.