Skip to content
Draft
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
10 changes: 10 additions & 0 deletions docs/ai/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,13 @@ OISY connects to external dApps over WalletConnect (Reown WalletKit). When a dAp
While a BTC send initiated through the wallet is unconfirmed, its UTXOs are reserved on the backend so the next send flow cannot pick the same UTXOs and build a conflicting transaction. Reservations are kept per user (the caller's principal) and auto-expire one hour after they are recorded, on the assumption that a still-unconfirmed transaction at that point has failed and the inputs are free again.

The Bitcoin address scoped to a reservation is always **derived from the authenticated principal** (P2WPKH from the threshold-ECDSA-derived public key). The caller cannot specify which address's pending transactions are read, added, or pruned — there is no API surface for that, and there is no support for a single user owning multiple addresses. The reservation system is a self-affecting UX guard; double-spend itself is prevented by Bitcoin consensus.

---

## Buy (OnRamper)

Users can buy crypto with fiat through an embedded OnRamper widget. OnRamper requires the destination wallet addresses in the widget URL to be HMAC-signed so they cannot be tampered with in transit; OISY holds the signing secret in the backend canister (it never reaches the browser) and the frontend asks the backend to sign the URL before loading the widget.

The signed destination addresses are **always the authenticated caller's own**, derived server-side from their principal (BTC, ETH, ICP, and SOL). The frontend does **not** send wallet addresses to the signing endpoint, and there is no API surface through which a caller could have the backend sign an address they do not own. A signed OISY widget URL can therefore only ever route a purchase to the signed-in user's own wallet — never to an attacker-chosen address. The addresses are derived via threshold public-key reads (the same keys the wallet shows the user), not by trusting client input.

If the signing secret is not configured, or none of the caller's addresses can be derived, the widget is shown as unavailable rather than loaded with an unsigned or partial URL.
46 changes: 19 additions & 27 deletions src/backend/backend.did
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type ActiveUserTransaction = record {
// Learned-mid-flow named references, e.g.
// `{ key: "tx_hash", value: "0x…" }`. See [`ActiveUserTransactionRef`]
// for the field layout exposed on the wire and in TS bindings.
external_refs : vec OnramperSignedEntry;
external_refs : vec ActiveUserTransactionRef;
// Opaque to the backend; the FE writes a flow-specific step name here.
progress_step : opt text;
data : ActiveUserTransactionData;
Expand Down Expand Up @@ -778,10 +778,6 @@ type OneSecIcpToEvmData = record {
amount : nat;
dest_token : TokenId
};
// A `(key, value)` entry of an `OnRamper` signed parameter — e.g. `(btc, <address>)` inside
// `wallets`, or `(ethereum, <address>)` inside `networkWallets`. The canister normalizes the
// `key` to lowercase before signing.
type OnramperSignedEntry = record { key : text; value : text };
// Outpoint.
type Outpoint = record {
// Transaction ID (TxID).
Expand Down Expand Up @@ -850,25 +846,19 @@ type Settings = record {
};
// Errors returned by `sign_onramper_widget_url`.
type SignOnramperWidgetUrlError = variant {
// The caller exceeded the per-principal rate limit for signing requests. The endpoint signs
// arbitrary caller-supplied parameters with a shared secret, so the limit bounds its use as a
// signing oracle.
// The caller exceeded the per-principal rate limit for signing requests. The limit bounds how
// often a principal can trigger the address derivation (management-canister public-key reads)
// behind this endpoint.
RateLimited : RateLimitError;
// None of the caller's wallet addresses could be derived (e.g. the signer public-key reads
// all failed). The frontend treats this as a hard "widget unavailable" failure: there is
// nothing safe to sign without at least one of the caller's own addresses.
AddressDerivationFailed;
// Controllers have not yet provisioned the `OnRamper` signing secret via `set_api_keys`. The
// frontend should treat this the same as a hard failure: the widget cannot be opened until
// the secret is configured.
SecretNotConfigured
};
// Request body for `sign_onramper_widget_url`. Each field maps directly to one of `OnRamper`'s
// signed query parameters. Empty fields are omitted from the canonicalized sign-content.
type SignOnramperWidgetUrlRequest = record {
// `<networkId>:<address>` pairs that map to the `networkWallets=` query parameter.
network_wallets : vec OnramperSignedEntry;
// `<cryptoId>:<address>` pairs that map to the `wallets=` query parameter.
wallets : vec OnramperSignedEntry;
// `<cryptoId>:<tag>` pairs that map to the `walletAddressTags=` query parameter.
wallet_address_tags : vec OnramperSignedEntry
};
// Successful response of `sign_onramper_widget_url`. Returns both the signature and the exact
// canonical query fragment that was signed, so the frontend appends the latter verbatim instead of
// re-deriving it (which risks diverging from what was HMAC'd and silently breaking the signature).
Expand Down Expand Up @@ -1042,7 +1032,7 @@ type TransformArgs = record {
type UpdateActiveUserTransactionRequest = record {
id : text;
status : opt ActiveUserTransactionStatus;
external_refs : opt vec OnramperSignedEntry;
external_refs : opt vec ActiveUserTransactionRef;
progress_step : opt text;
error : opt text
};
Expand Down Expand Up @@ -1459,17 +1449,19 @@ service : (Arg) -> {
set_user_show_testnets : (SetShowTestnetsRequest) -> (
SetUserShowTestnetsResult
);
// Sign the three sensitive `OnRamper` widget parameters with the controller-managed HMAC secret.
// Sign the caller's own wallet addresses into an `OnRamper` widget URL with the controller-managed
// HMAC secret.
//
// Returns the hex-encoded HMAC-SHA256 the frontend appends to the widget URL as `&signature=…`.
// Authenticated callers only: anonymous principals cannot extract signatures.
// Takes no arguments: the signed `networkWallets` are the caller's BTC/ETH/ICP/SOL receiving
// addresses, derived server-side from `msg_caller()`. A caller can therefore only ever obtain a
// signature over addresses they own — the HMAC binds the URL to the authenticated user rather than
// to arbitrary client input. Authenticated callers only: anonymous principals cannot extract
// signatures.
//
// This is an `update` (not a `query`) so the per-caller [`SIGN_ONRAMPER_WIDGET_URL_RATE_LIMITER`]
// can persist its sliding window — a query would discard the recorded call. The frontend already
// invokes it as a certified (replicated) call, so there is no added latency.
sign_onramper_widget_url : (SignOnramperWidgetUrlRequest) -> (
SignOnramperWidgetUrlResult
);
// can persist its sliding window, and because address derivation makes inter-canister
// (management-canister public-key) calls. The frontend already invokes it as a certified call.
sign_onramper_widget_url : () -> (SignOnramperWidgetUrlResult);
// Gets statistics about the canister.
//
// Note: This is a private method, restricted to authorized users, as some stats may not be
Expand Down
23 changes: 13 additions & 10 deletions src/backend/src/api/onramper.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use ic_cdk::update;
use ic_cdk::{api::msg_caller, update};
use shared::types::{
onramper::{SignOnramperWidgetUrlError, SignOnramperWidgetUrlRequest},
result_types::SignOnramperWidgetUrlResult,
onramper::SignOnramperWidgetUrlError, result_types::SignOnramperWidgetUrlResult,
};

use crate::{
Expand All @@ -12,23 +11,27 @@ use crate::{
},
};

/// Sign the three sensitive `OnRamper` widget parameters with the controller-managed HMAC secret.
/// Sign the caller's own wallet addresses into an `OnRamper` widget URL with the controller-managed
/// HMAC secret.
///
/// Returns the hex-encoded HMAC-SHA256 the frontend appends to the widget URL as `&signature=…`.
/// Authenticated callers only: anonymous principals cannot extract signatures.
/// Takes no arguments: the signed `networkWallets` are the caller's BTC/ETH/ICP/SOL receiving
/// addresses, derived server-side from `msg_caller()`. A caller can therefore only ever obtain a
/// signature over addresses they own — the HMAC binds the URL to the authenticated user rather than
/// to arbitrary client input. Authenticated callers only: anonymous principals cannot extract
/// signatures.
///
/// This is an `update` (not a `query`) so the per-caller [`SIGN_ONRAMPER_WIDGET_URL_RATE_LIMITER`]
/// can persist its sliding window — a query would discard the recorded call. The frontend already
/// invokes it as a certified (replicated) call, so there is no added latency.
/// can persist its sliding window, and because address derivation makes inter-canister
/// (management-canister public-key) calls. The frontend already invokes it as a certified call.
#[update(guard = "caller_is_not_anonymous")]
pub fn sign_onramper_widget_url(req: SignOnramperWidgetUrlRequest) -> SignOnramperWidgetUrlResult {
pub async fn sign_onramper_widget_url() -> SignOnramperWidgetUrlResult {
if let Err(e) =
SIGN_ONRAMPER_WIDGET_URL_RATE_LIMITER.with(rate_limiter::RateLimiter::check_caller)
{
return SignOnramperWidgetUrlResult::Err(SignOnramperWidgetUrlError::RateLimited(e));
}

service::sign_onramper_widget_url(req).into()
service::sign_onramper_widget_url(msg_caller()).await.into()
}

/// Sets or clears the `OnRamper` signing secret used by [`sign_onramper_widget_url`].
Expand Down
1 change: 0 additions & 1 deletion src/backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ use shared::{
experimental_feature::UpdateExperimentalFeaturesSettingsRequest,
network::{SaveNetworksSettingsRequest, SetShowTestnetsRequest},
notification::AddDismissedNotificationRequest,
onramper::SignOnramperWidgetUrlRequest,
result_types::{
ActiveUserTransactionResult, AddUserDismissedNotificationResult,
AddUserHiddenDappIdResult, AllowSigningResult, BtcAddPendingTransactionResult,
Expand Down
101 changes: 78 additions & 23 deletions src/backend/src/onramper/service.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,93 @@
//! State-aware wiring for `OnRamper` widget URL signing. Reads the secret from the controller-
//! managed `ApiKeys` cell and delegates the actual canonicalization + MAC to [`super::model`].
//! managed `ApiKeys` cell, derives the caller's own receiving addresses from their principal, and
//! delegates the canonicalization + MAC to [`super::model`].
//!
//! The addresses are derived server-side (never accepted from the client) so a caller can only ever
//! obtain a signature over *their own* wallet addresses. This is what makes `OnRamper`'s HMAC a
//! real integrity guarantee rather than an open signing oracle.

use candid::Principal;
use shared::types::onramper::{
SignOnramperWidgetUrlError, SignOnramperWidgetUrlRequest, SignOnramperWidgetUrlResponse,
OnramperSignedEntry, SignOnramperWidgetUrlError, SignOnramperWidgetUrlResponse,
};

use super::model::sign_widget_url;
use crate::state::{mutate_api_keys, with_api_keys};
use crate::{
signer,
state::{mutate_api_keys, with_api_keys},
};

// `OnRamper` network ids for the `networkWallets` parameter — must match the frontend's
// `network.buy.onramperId` values (`src/frontend/src/env/networks/networks.*.env.ts`).
const ONRAMPER_NETWORK_BITCOIN: &str = "bitcoin";
const ONRAMPER_NETWORK_ETHEREUM: &str = "ethereum";
const ONRAMPER_NETWORK_ICP: &str = "icp";
const ONRAMPER_NETWORK_SOLANA: &str = "solana";

/// Build and sign the `OnRamper` widget signed-content. Returns both the hex-encoded HMAC-SHA256
/// and the exact canonical query fragment that was signed, so the frontend appends the latter
/// verbatim instead of re-deriving it.
/// Derives `principal`'s own receiving addresses and signs them as `OnRamper`'s `networkWallets`.
/// Returns the hex HMAC-SHA256 and the exact canonical query fragment that was signed, so the
/// frontend appends the latter verbatim instead of re-deriving it.
///
/// Errors with [`SignOnramperWidgetUrlError::SecretNotConfigured`] when controllers have not yet
/// set `onramper_signing_secret` on `ApiKeys`. The frontend surfaces this as a "widget
/// unavailable" notice rather than loading an unsigned URL (which `OnRamper` would reject anyway).
pub fn sign_onramper_widget_url(
req: SignOnramperWidgetUrlRequest,
/// Errors:
/// - [`SignOnramperWidgetUrlError::SecretNotConfigured`] — controllers have not set
/// `onramper_signing_secret`. The frontend surfaces a "widget unavailable" notice.
/// - [`SignOnramperWidgetUrlError::AddressDerivationFailed`] — not a single address could be
/// derived, so there is nothing safe to sign.
pub async fn sign_onramper_widget_url(
principal: Principal,
) -> Result<SignOnramperWidgetUrlResponse, SignOnramperWidgetUrlError> {
let secret = with_api_keys(|keys| keys.onramper_signing_secret.clone())
.ok_or(SignOnramperWidgetUrlError::SecretNotConfigured)?;

let SignOnramperWidgetUrlRequest {
wallets,
network_wallets,
wallet_address_tags,
} = req;

Ok(sign_widget_url(
&secret,
&wallets,
&network_wallets,
&wallet_address_tags,
))
let network_wallets = derive_caller_network_wallets(&principal).await;
if network_wallets.is_empty() {
return Err(SignOnramperWidgetUrlError::AddressDerivationFailed);
}

Ok(sign_widget_url(&secret, &[], &network_wallets, &[]))
}

/// Derives the caller's own addresses for each supported `OnRamper` network. A network is included
/// only when its address derives successfully — mirroring the frontend's "include a network only
/// when its address is present" rule — so a transient single-chain signer hiccup degrades to fewer
/// networks rather than failing the whole widget.
async fn derive_caller_network_wallets(principal: &Principal) -> Vec<OnramperSignedEntry> {
let mut entries = Vec::with_capacity(4);

// ICP account identifier: pure local computation, never fails.
entries.push(entry(
ONRAMPER_NETWORK_ICP,
hex::encode(signer::principal2account(principal)),
));

match signer::btc_principal_to_p2wpkh_address(
ic_cdk::bitcoin_canister::Network::Mainnet,
principal,
)
.await
{
Ok(address) => entries.push(entry(ONRAMPER_NETWORK_BITCOIN, address)),
Err(err) => ic_cdk::eprintln!("OnRamper: BTC address derivation failed: {err}"),
}

match signer::eth_principal_to_address(principal).await {
Ok(address) => entries.push(entry(ONRAMPER_NETWORK_ETHEREUM, address)),
Err(err) => ic_cdk::eprintln!("OnRamper: ETH address derivation failed: {err}"),
}

match signer::sol_principal_to_address(principal).await {
Ok(address) => entries.push(entry(ONRAMPER_NETWORK_SOLANA, address)),
Err(err) => ic_cdk::eprintln!("OnRamper: SOL address derivation failed: {err}"),
}

entries
}

fn entry(key: &str, value: String) -> OnramperSignedEntry {
OnramperSignedEntry {
key: key.to_string(),
value,
}
}

/// Sets (or clears, with `None`) the `OnRamper` signing secret without touching the other API keys.
Expand Down
5 changes: 3 additions & 2 deletions src/backend/src/signer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod canister_ids;
mod service;

pub(crate) use service::{
allow_signing, approve_signing, btc_principal_to_p2wpkh_address, get_allowed_cycles,
has_sufficient_allowance, top_up_cycles_ledger,
allow_signing, approve_signing, btc_principal_to_p2wpkh_address, eth_principal_to_address,
get_allowed_cycles, has_sufficient_allowance, principal2account, sol_principal_to_address,
top_up_cycles_ledger,
};
Loading
Loading