diff --git a/src/wrapper/Cargo.toml b/src/wrapper/Cargo.toml new file mode 100644 index 0000000..3527aae --- /dev/null +++ b/src/wrapper/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ic-papi-wrapper" +version = "0.0.1" + +[dependencies] +lazy_static = { workspace = true } +candid = { workspace = true } +ic-cdk = { workspace = true } +ic-papi-api = { workspace = true } +ic-papi-guard = { workspace = true } diff --git a/src/wrapper/src/api/call.rs b/src/wrapper/src/api/call.rs new file mode 100644 index 0000000..724224f --- /dev/null +++ b/src/wrapper/src/api/call.rs @@ -0,0 +1,117 @@ +//! Public, stateless API for payment-wrapped proxy calls. +//! +//! You choose *how* to pass arguments: +//! - `call0` : target takes no args → we send `()` +//! - `call_blob`: you provide a Candid-encoded arg blob +//! - `call_text`: you provide Candid text like `("(42, \"hi\")")` +//! +//! In all cases you also pass: +//! - `fee_amount` (u128, in the unit implied by your `PaymentType` variant) +//! - `payment` (which variant determines cycles vs token, patron vs caller) +//! - optional `cycles_to_forward` (for targets that expect cycles) + +use candid::{Encode, Principal}; +use ic_cdk::update; +use ic_papi_api::PaymentType; + +use crate::domain::BridgeError; +use crate::payments::PAYMENT_GUARD; +use crate::util::cycles::forward_raw; + +fn map_guard_err(e: E) -> String { + BridgeError::GuardError(format!("{e:?}")).to_string() +} + +/// Proxies a call to a target method that takes **no arguments** (`()`), after charging a fee. +/// +/// # Arguments +/// - `target`: Target canister principal. +/// - `method`: Exact method name on the target. +/// - `fee_amount`: Amount to charge via `PAYMENT_GUARD`. +/// - `payment`: Payment type to use. If `None`, defaults to `AttachedCycles`. +/// - `cycles_to_forward`: Optional cycles to attach to the target call. +/// +/// # Returns +/// Raw Candid reply blob from the target (decode on the client). +#[update] +pub async fn call0( + target: Principal, + method: String, + fee_amount: u128, + payment: Option, + cycles_to_forward: Option, +) -> Result, String> { + let p = payment.unwrap_or(PaymentType::AttachedCycles); + PAYMENT_GUARD.deduct(p, fee_amount).await.map_err(map_guard_err)?; + + let args = Encode!().map_err(|e| BridgeError::Candid(e.to_string()).to_string())?; + + let cycles = cycles_to_forward.unwrap_or(0); + + forward_raw(target, &method, args, cycles) + .await + .map_err(|e| BridgeError::TargetRejected(e).to_string()) +} + +/// Proxies a call using a **Candid-encoded argument blob**, after charging a fee. +/// +/// Use this when your client already encoded args with `IDL.encode` (agent-js) +/// or `candid::Encode!` (Rust). +/// +/// # Arguments +/// - `args_blob`: The exact Candid byte payload to forward to the target. +#[update] +pub async fn call_blob( + target: Principal, + method: String, + args_blob: Vec, + fee_amount: u128, + payment: Option, + cycles_to_forward: Option, +) -> Result, String> { + // 1) charge fee + let p = payment.unwrap_or(PaymentType::AttachedCycles); + PAYMENT_GUARD.deduct(p, fee_amount).await.map_err(map_guard_err)?; + + // 2) forward as-is + let cycles = cycles_to_forward.unwrap_or(0); + forward_raw(target, &method, args_blob, cycles) + .await + .map_err(|e| BridgeError::TargetRejected(e).to_string()) +} + +/// Proxies a call using **Candid text** (e.g., `"(\"hello\", 42, opt null)"`), after charging a fee. +/// +/// This is convenient when you want to pass args “like in a .did example” without +/// writing encoding code on the client. +/// +/// # Arguments +/// - `args_text`: A string containing Candid values for the target method’s parameter list. +#[update] +pub async fn call_text( + target: Principal, + method: String, + args_text: String, + fee_amount: u128, + payment: Option, + cycles_to_forward: Option, +) -> Result, String> { + use candid::parser::value::IDLArgs; + + // 1) charge fee + let p = payment.unwrap_or(PaymentType::AttachedCycles); + PAYMENT_GUARD.deduct(p, fee_amount).await.map_err(map_guard_err)?; + + // 2) parse candid text -> blob + let idl_args = IDLArgs::from_str(&args_text) + .map_err(|e| BridgeError::Candid(e.to_string()).to_string())?; + let args_blob = idl_args + .to_bytes() + .map_err(|e| BridgeError::Candid(e.to_string()).to_string())?; + + // 3) forward + let cycles = cycles_to_forward.unwrap_or(0); + forward_raw(target, &method, args_blob, cycles) + .await + .map_err(|e| BridgeError::TargetRejected(e).to_string()) +} diff --git a/src/wrapper/src/api/mod.rs b/src/wrapper/src/api/mod.rs new file mode 100644 index 0000000..8b6c1d4 --- /dev/null +++ b/src/wrapper/src/api/mod.rs @@ -0,0 +1 @@ +pub mod call; diff --git a/src/wrapper/src/domain/errors.rs b/src/wrapper/src/domain/errors.rs new file mode 100644 index 0000000..61e6edb --- /dev/null +++ b/src/wrapper/src/domain/errors.rs @@ -0,0 +1,24 @@ + +/// Errors returned by the bridge canister. +/// +/// Kept small because the canister is stateless; pricing/governance are pushed to the caller. +#[derive(Debug)] +pub enum BridgeError { + /// Candid encoding/decoding failed. + Candid(String), + /// Target canister rejected the proxied call. + TargetRejected(String), + /// Fee deduction failed (insufficient cycles/allowance/etc.). + GuardError(String), +} + +impl ToString for BridgeError { + fn to_string(&self) -> String { + use BridgeError::*; + match self { + Candid(e) => format!("Candid: {e}"), + TargetRejected(e) => format!("TargetRejected: {e}"), + GuardError(e) => format!("GuardError: {e}"), + } + } +} diff --git a/src/wrapper/src/domain/mod.rs b/src/wrapper/src/domain/mod.rs new file mode 100644 index 0000000..f186a90 --- /dev/null +++ b/src/wrapper/src/domain/mod.rs @@ -0,0 +1,2 @@ +pub mod errors; + diff --git a/src/wrapper/src/domain/types.rs b/src/wrapper/src/domain/types.rs new file mode 100644 index 0000000..a0df445 --- /dev/null +++ b/src/wrapper/src/domain/types.rs @@ -0,0 +1,27 @@ +use candid::Principal; +use ic_papi_guard::guards::any::VendorPaymentConfig; + +#[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] +pub enum FeeDenom { + Cycles, + Icrc2 { ledger: Principal }, +} + +#[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] +pub struct FeeSpec { + pub amount: u128, + pub denom: FeeDenom, +} + +#[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] +pub struct MethodConfig { + pub fee: FeeSpec, + pub supported: Vec, + pub forward_cycles: Option, +} + +#[derive(Debug, CandidType, Deserialize, Clone, Eq, PartialEq)] +pub struct MethodKey { + pub target: Principal, + pub method: String, +} diff --git a/src/wrapper/src/lib.rs b/src/wrapper/src/lib.rs new file mode 100644 index 0000000..e2efb29 --- /dev/null +++ b/src/wrapper/src/lib.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod domain; +pub mod payments; +mod util; diff --git a/src/wrapper/src/payments/guard_config.rs b/src/wrapper/src/payments/guard_config.rs new file mode 100644 index 0000000..783f50b --- /dev/null +++ b/src/wrapper/src/payments/guard_config.rs @@ -0,0 +1,26 @@ +use candid::Principal; +use ic_papi_guard::guards::any::{PaymentGuard, VendorPaymentConfig}; +use lazy_static::lazy_static; + +/// Return the ICRC-2 ledger principal used when the *payment type* is token-based. +/// +/// Replace this with your real ledger principal. +fn payment_ledger() -> Principal { + Principal::from_text("aaaaa-aa").unwrap() // TODO: set real ledger +} + +/// Shared guard accepting multiple payment modes. +/// +/// The *caller* selects the mode by passing an appropriate `PaymentType` variant. +/// The fee *amount* is provided per call. +lazy_static! { + pub static ref PAYMENT_GUARD: PaymentGuard<5> = PaymentGuard { + supported: [ + VendorPaymentConfig::AttachedCycles, + VendorPaymentConfig::CallerPaysIcrc2Cycles, + VendorPaymentConfig::PatronPaysIcrc2Cycles, + VendorPaymentConfig::CallerPaysIcrc2Tokens { ledger: payment_ledger() }, + VendorPaymentConfig::PatronPaysIcrc2Tokens { ledger: payment_ledger() }, + ], + }; +} diff --git a/src/wrapper/src/payments/mod.rs b/src/wrapper/src/payments/mod.rs new file mode 100644 index 0000000..1dd52e7 --- /dev/null +++ b/src/wrapper/src/payments/mod.rs @@ -0,0 +1,2 @@ +pub mod guard_config; + diff --git a/src/wrapper/src/util/cycles.rs b/src/wrapper/src/util/cycles.rs new file mode 100644 index 0000000..11aafb0 --- /dev/null +++ b/src/wrapper/src/util/cycles.rs @@ -0,0 +1,23 @@ +use candid::Principal; +use ic_cdk::api::call::{call_raw, call_raw128}; + +/// Forwards a raw candid call to the target canister, optionally attaching cycles. +/// +/// # Errors +/// Returns a string with reject code/message if the target rejects. +pub async fn forward_raw( + target: Principal, + method: &str, + args: Vec, + cycles: u128, +) -> Result, String> { + if cycles > 0 { + call_raw128(target, method, args, cycles) + .await + .map_err(|(code, msg)| format!("{code:?} {msg}")) + } else { + call_raw(target, method, args, 0) + .await + .map_err(|(code, msg)| format!("{code:?} {msg}")) + } +}