Skip to content
Merged
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
87 changes: 57 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,18 @@ The wrapper exposes a small set of generic proxy methods:
| `call0` | Proxy a call that takes **no arguments** |
| `call_blob` | Proxy a call with a **Candid-encoded argument blob** |

Every proxy method follows the same two-step internal logic:
Every proxy method follows the same internal logic:

1. **Charge the fee** – the payment guard deducts the requested amount from the caller (or from a designated payer) using any supported payment type (attached cycles, ICRC-2 approve, patron pays, …).
2. **Forward the call** – once the fee is settled, the wrapper performs a raw inter-canister call to the target canister and method, passing the arguments and any optional cycles through transparently.
1. **Look up the price** – the wrapper reads the operator-configured `MethodConfig` for the `(target, method)` pair. The fee and the cycles to forward are set by the wrapper operator, **not** by the caller. If no configuration exists the call is rejected.
2. **Charge the fee** – the payment guard deducts the configured fee from the caller (or from a designated payer) using the supported payment type the caller selects (attached cycles, ICRC-2 approve, patron pays, …).
3. **Forward the call** – once the fee is settled, the wrapper performs a raw inter-canister call to the target canister and method, attaching the configured number of cycles.

The caller only chooses **which payment method** to use; it can never set the fee or the forwarded-cycle amount. This is what prevents a caller from paying a trivial fee while forwarding a large amount (which would drain the wrapper's cycle balance). When a method forwards cycles, the operator configuration must denominate the fee in cycles and set it to at least the forwarded amount; the wrapper enforces this at configuration time.

If the fee deduction fails the call is rejected immediately and the target canister is never reached. Self-calls (calling the wrapper itself) are blocked.

Configuration is controller-only, via `set_method_config` / `remove_method_config`, and is inspectable via the `get_method_config` / `list_method_configs` queries. It is persisted across canister upgrades.

### Flow diagram

```mermaid
Expand All @@ -157,8 +162,9 @@ sequenceDiagram
Note over Payer,Ledger: Optional – only needed for patron-pays or ICRC-2 flows
Payer->>Ledger: icrc2_approve(spender=Wrapper, amount=fee)

Caller->>Wrapper: call_blob / call0(target, method, args, fee, payment?)
Caller->>Wrapper: call_blob / call0(target, method, args, payment?)
activate Wrapper
Note over Wrapper: Look up operator-configured fee & forward cycles
Wrapper->>Ledger: icrc2_transfer_from (or deduct attached cycles)
Ledger-->>Wrapper: ok
Wrapper->>Target: raw canister call (method, args)
Expand All @@ -175,18 +181,33 @@ sequenceDiagram

You can deploy the wrapper yourself from `src/wrapper`, or use the shared instance already published to the IC mainnet (see `canister_ids.json`).

#### 2. Call a payable endpoint
#### 2. Configure the price (operator / controller only)

Before a `(target, method)` can be proxied, a controller of the wrapper registers its price. `forward_cycles` is optional; when set, the fee must be denominated in cycles and cover it.

```bash
dfx canister call "$WRAPPER_ID" set_method_config '(
record { target = principal "'$TARGET_CANISTER_ID'"; method = "my_method" },
record {
fee = record { amount = 1_000_000 : nat; denom = variant { Cycles } };
supported = vec { variant { AttachedCycles }; variant { CallerPaysIcrc2Cycles } };
forward_cycles = opt (1_000_000 : nat);
}
)'
```

#### 3. Call a payable endpoint

The caller supplies only the `(target, method)`, the arguments, and which payment type to use — never a fee or cycle amount.

**Paying with attached cycles (simplest):**

```bash
dfx canister call "$WRAPPER_ID" call0 \
'(record {
target = principal "'$TARGET_CANISTER_ID'";
method = "my_method";
fee_amount = 1_000_000 : nat;
payment = null; # defaults to AttachedCycles
cycles_to_forward = null;
target = principal "'$TARGET_CANISTER_ID'";
method = "my_method";
payment = null; # defaults to AttachedCycles
})' \
--with-cycles 1000000
```
Expand All @@ -202,12 +223,10 @@ dfx canister call $LEDGER icrc2_approve '(record {

# 2. Call through the wrapper
dfx canister call "$WRAPPER_ID" call_blob '(record {
target = principal "'$TARGET_CANISTER_ID'";
method = "store_data";
args_blob = blob "\44\49\44\4c\00\00";
fee_amount = 5_000_000 : nat;
payment = opt variant { CallerPaysIcrc2Cycles };
cycles_to_forward = null;
target = principal "'$TARGET_CANISTER_ID'";
method = "store_data";
args_blob = blob "\44\49\44\4c\00\00";
payment = opt variant { CallerPaysIcrc2Tokens = record { ledger = principal "'$LEDGER'" } };
})'
```

Expand All @@ -223,28 +242,36 @@ dfx canister call $LEDGER icrc2_approve '(record {

# Caller references the payer when calling through the wrapper
dfx canister call "$WRAPPER_ID" call0 '(record {
target = principal "'$TARGET_CANISTER_ID'";
method = "my_method";
fee_amount = 1_000_000 : nat;
payment = opt variant {
target = principal "'$TARGET_CANISTER_ID'";
method = "my_method";
payment = opt variant {
PatronPaysIcrc2Cycles = record { owner = principal "'$PAYER_ID'" }
};
cycles_to_forward = null;
})'
```

#### Key parameters

| Parameter | Type | Description |
| ------------------- | ----------------- | ---------------------------------------------------------- |
| `target` | `Principal` | The canister to forward the call to |
| `method` | `Text` | The method name on the target canister |
| `fee_amount` | `Nat` | Fee charged by the wrapper before forwarding |
| `payment` | `opt PaymentType` | Payment mechanism; defaults to `AttachedCycles` if omitted |
| `args_blob` | `Blob` | Candid-encoded arguments (for `call_blob`) |
| `cycles_to_forward` | `opt Nat` | Additional cycles to pass to the target alongside the call |
Call parameters (per proxy call):

| Parameter | Type | Description |
| ----------- | ----------------- | ---------------------------------------------------------- |
| `target` | `Principal` | The canister to forward the call to |
| `method` | `Text` | The method name on the target canister |
| `payment` | `opt PaymentType` | Payment mechanism; defaults to `AttachedCycles` if omitted |
| `args_blob` | `Blob` | Candid-encoded arguments (for `call_blob`) |

Configuration parameters (per `(target, method)`, set by the operator via `set_method_config`):

| Field | Type | Description |
| ---------------- | -------------------------- | --------------------------------------------------------------- |
| `fee` | `record { amount; denom }` | The fee charged before forwarding, and its denomination |
| `supported` | `vec VendorPaymentConfig` | Informational only for now — see note below |
| `forward_cycles` | `opt Nat` | Cycles attached to the forwarded call; must be covered by `fee` |

> **Note on `supported`:** this field is not yet enforced per method. `bridge_call` currently validates the caller's payment type against the wrapper's global `PAYMENT_GUARD`, not against `MethodConfig.supported`. Until per-method enforcement is wired in, any payment type the global guard accepts is accepted for every configured method, regardless of what `supported` lists.

The response is returned as `Result<blob, text>`: the raw Candid-encoded response bytes on success, or an error string describing what went wrong (guard failure or target rejection).
The response is returned as `Result<blob, text>`: the raw Candid-encoded response bytes on success, or an error string describing what went wrong (method not configured, guard failure, or target rejection).

---

Expand Down
48 changes: 36 additions & 12 deletions src/wrapper/ic_papi_wrapper.did
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
type Account = record { owner : principal; subaccount : opt blob };
// Arguments for the `call0` function.
//
// Note: the fee and the cycles to forward are **not** caller-supplied; they are
// looked up from the operator-configured [`MethodConfig`] for `(target, method)`.
// The caller only chooses which supported payment type to pay with.
type Call0Args = record {
// The name of the method to call.
method : text;
// Optional cycles to forward to the target canister.
cycles_to_forward : opt nat;
// The principal of the canister to call.
target : principal;
// The amount of fee to charge.
fee_amount : nat;
// Optional payment configuration (defaults to `AttachedCycles`).
payment : opt PaymentType;
};
Expand All @@ -18,12 +18,8 @@ type CallBlobArgs = record {
method : text;
// The Candid-encoded arguments as a byte buffer.
args_blob : blob;
// Optional cycles to forward to the target canister.
cycles_to_forward : opt nat;
// The principal of the canister to call.
target : principal;
// The amount of fee to charge.
fee_amount : nat;
// Optional payment configuration (defaults to `AttachedCycles`).
payment : opt PaymentType;
};
Expand All @@ -33,16 +29,20 @@ type CallTextArgs = record {
method : text;
// The Candid text representation of the arguments.
args_text : text;
// Optional cycles to forward to the target canister.
cycles_to_forward : opt nat;
// The principal of the canister to call.
target : principal;
// The amount of fee to charge.
fee_amount : nat;
// Optional payment configuration (defaults to `AttachedCycles`).
payment : opt PaymentType;
};
type CallerPaysIcrc2Tokens = record { ledger : principal };
type FeeDenom = variant { Icrc2 : CallerPaysIcrc2Tokens; Cycles };
type FeeSpec = record { amount : nat; denom : FeeDenom };
type MethodConfig = record {
fee : FeeSpec;
forward_cycles : opt nat;
supported : vec VendorPaymentConfig;
};
type MethodKey = record { method : text; target : principal };
type PatronPaysIcrc2Tokens = record { ledger : principal; patron : Account };
// How a caller states that they will pay.
type PaymentType = variant {
Expand All @@ -62,11 +62,35 @@ type PaymentType = variant {
PatronPaysIcrc2Cycles : Account;
};
type Result = variant { Ok : blob; Err : text };
type Result_1 = variant { Ok : opt MethodConfig; Err : text };
type Result_2 = variant { Ok; Err : text };
// Vendor payment configuration, including details that may not necessarily be shared with the customer.
type VendorPaymentConfig = variant {
// A patron pays tokens to a subaccount belonging to the vendor on the chosen ledger.
// - The vendor needs to move the tokens to their main account.
PatronPaysIcrc2Tokens : CallerPaysIcrc2Tokens;
// Cycles are received by the vendor canister.
AttachedCycles;
// Cycles are received by the vendor canister.
CallerPaysIcrc2Cycles;
// The caller pays tokens to the vendor's main account on the chosen ledger.
CallerPaysIcrc2Tokens : CallerPaysIcrc2Tokens;
// Cycles are received by the vendor canister.
PatronPaysIcrc2Cycles;
};
service : {
// Proxies a call to a target method that takes **no arguments**.
call0 : (Call0Args) -> (Result);
// Proxies a call using a **Candid-encoded argument blob**.
call_blob : (CallBlobArgs) -> (Result);
// Proxies a call using **Candid text** (currently disabled).
call_text : (CallTextArgs) -> (Result);
// Read the price configured for a `(target, method)` pair.
get_method_config : (MethodKey) -> (opt MethodConfig) query;
// List every configured `(target, method)` price.
list_method_configs : () -> (vec record { MethodKey; MethodConfig }) query;
// Remove the price for a `(target, method)` pair, returning any prior value.
remove_method_config : (MethodKey) -> (Result_1);
// Register or replace the price for a `(target, method)` pair.
set_method_config : (MethodKey, MethodConfig) -> (Result_2);
}
53 changes: 47 additions & 6 deletions src/wrapper/src/api/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ use candid::Principal;
use ic_papi_api::PaymentType;

use crate::domain::errors::BridgeError;
use crate::domain::types::BridgeCallArgs;
use crate::domain::types::{BridgeCallArgs, MethodKey};
use crate::payments::guard_config::PAYMENT_GUARD;
use crate::state;
use crate::util::cycles::forward_raw;

/// The IC management canister principal (`aaaaa-aa`).
Expand All @@ -27,7 +28,13 @@ fn map_guard_err<E: core::fmt::Debug>(e: E) -> String {
BridgeError::GuardError(format!("{e:?}")).to_string()
}

/// Internal helper to unify the bridge logic: charge fee -> forward call.
/// Internal helper to unify the bridge logic: look up price -> charge fee -> forward call.
///
/// The fee and the number of cycles to forward are taken from the
/// operator-configured [`crate::domain::types::MethodConfig`] for the
/// `(target, method)` pair — never from the caller. This prevents a caller from
/// naming a trivial fee while forwarding a large amount, which would drain the
/// wrapper's own cycle balance.
// TODO: The caller may have to provide more type information than they are used to. Normally dfx will use the target canister's candid file to convert to the correct types; without that information it will guess more simply and won't always get this conversion right.
pub async fn bridge_call(args: BridgeCallArgs) -> Result<Vec<u8>, String> {
if args.target == ic_cdk::api::canister_self() {
Expand All @@ -43,16 +50,50 @@ pub async fn bridge_call(args: BridgeCallArgs) -> Result<Vec<u8>, String> {
.to_string());
}

// 1) Charge fee using the payment guard
// Look up the operator-configured price for this `(target, method)`.
let key = MethodKey {
target: args.target,
method: args.method.clone(),
};
Comment thread
AntonioVentilii marked this conversation as resolved.
let config = state::get_config(&key).ok_or_else(|| {
BridgeError::MethodNotConfigured {
target: args.target.to_string(),
method: args.method.clone(),
}
.to_string()
})?;

let p = args.payment.unwrap_or(PaymentType::AttachedCycles);
let cycles = config.forward_cycles.unwrap_or(0);

// If this method forwards cycles, the caller must pay in cycles so that the
// fee actually credits the wrapper's cycle balance. `set_method_config`
// already guarantees `fee.denom == Cycles` and `fee.amount >= forward_cycles`
// for such methods; here we additionally ensure the *caller's chosen* payment
// type is cycle-denominated (token payments credit a token account, not cycles).
if cycles > 0 && !is_cycle_payment(&p) {
return Err(BridgeError::ForwardRequiresCyclePayment.to_string());
}

// 1) Charge the operator-set fee.
PAYMENT_GUARD
.deduct(p, args.fee_amount)
.deduct(p, config.fee.amount)
.await
.map_err(map_guard_err)?;

// 2) Forward the call to target
let cycles = args.cycles_to_forward.unwrap_or(0);
// 2) Forward the call with the operator-set cycles.
forward_raw(args.target, &args.method, args.args, cycles)
.await
.map_err(|e| BridgeError::TargetRejected(e).to_string())
}

/// Whether a payment type credits this canister's *cycle* balance (as opposed to
/// a token ledger account), and can therefore fund forwarded cycles.
fn is_cycle_payment(payment: &PaymentType) -> bool {
matches!(
payment,
PaymentType::AttachedCycles
| PaymentType::CallerPaysIcrc2Cycles
| PaymentType::PatronPaysIcrc2Cycles(_)
)
}
19 changes: 17 additions & 2 deletions src/wrapper/src/domain/errors.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
/// Errors returned by the bridge canister.
///
/// Kept small because the canister is stateless; pricing/governance are pushed to the caller.
use std::fmt;

#[derive(Debug)]
Expand All @@ -11,6 +9,12 @@ pub enum BridgeError {
TargetRejected(String),
/// Fee deduction failed (insufficient cycles/allowance/etc.).
GuardError(String),
/// No operator-configured price exists for the requested `(target, method)`.
MethodNotConfigured { target: String, method: String },
/// The configured method forwards cycles, but the chosen payment type is not
/// cycle-denominated, so the forwarded cycles would come out of the wrapper's
/// own balance rather than being funded by the payment.
ForwardRequiresCyclePayment,
/// The requested target canister may not be reached through the bridge.
ForbiddenTarget(String),
}
Expand All @@ -21,6 +25,17 @@ impl fmt::Display for BridgeError {
BridgeError::Candid(e) => write!(f, "Candid error: {e}"),
BridgeError::TargetRejected(e) => write!(f, "Target canister rejected call: {e}"),
BridgeError::GuardError(e) => write!(f, "Payment guard error: {e}"),
BridgeError::MethodNotConfigured { target, method } => write!(
f,
"No price is configured for method `{method}` on canister `{target}`. \
The wrapper operator must register it first."
),
BridgeError::ForwardRequiresCyclePayment => write!(
f,
"This method forwards cycles, which requires a cycle-denominated payment type \
(AttachedCycles, CallerPaysIcrc2Cycles or PatronPaysIcrc2Cycles); \
token payments do not credit the wrapper's cycle balance."
),
BridgeError::ForbiddenTarget(e) => write!(f, "Forbidden target: {e}"),
}
}
Expand Down
Loading
Loading