guests/toy-vendor/src/lib.rs
//! **A toy payment vendor, as a guest.** No money and no vendor behind it: a
//! loopback HTTP server stands in for the vendor's API, and every answer is
//! what that server says. It exists so the host's tests can prove, with a real
//! `.wasm`, that
//!
//! * the credential is added by the HOST (this guest sends a forged
//! `Authorization` of its own, and the server must see the real one);
//! * a webhook is verified by the HOST's `hmac` door, against a secret this
//! guest never holds;
//! * the host's memory outlives a call (and a live replacement);
//! * every vendor op crosses the ABI with its error arm intact.
use monetize_pay_guest::monetize_pay::payment::{Charge, Of, Page, PayManifest, Return, Standing};
use monetize_pay_guest::monetize_pay::{
CallbackEvent, ChargeHandle, ChargeRequest, ChargeStatus, Market, Money, PayError, RawCallback,
Settlement,
};
use monetize_pay_guest::{kinds, Host, Manifest, VendorGuest};
pub const ID: &str = "toyvendor";
/// What the toy forges, to prove the host overrides it.
pub const FORGED: &str = "Bearer forged-by-the-guest";
#[derive(Default)]
pub struct ToyVendor;
fn base(s: &serde_json::Value) -> Result<String, PayError> {
s["base"]
.as_str()
.map(|b| b.trim_end_matches('/').to_string())
.ok_or_else(|| PayError::Unavailable("toyvendor: no `base` setting".into()))
}
fn http(
host: &dyn Host,
method: &str,
url: String,
body: Vec<u8>,
) -> Result<(u16, serde_json::Value), PayError> {
let r = host
.http(
&monetize_pay_guest::__reexport::monetize_product_guest::wire::HttpRequest {
method: method.into(),
url,
headers: vec![
("authorization".into(), FORGED.into()),
("content-type".into(), "application/json".into()),
],
body,
},
)
.map_err(|e| PayError::Unavailable(format!("toyvendor: {e}")))?;
Ok((
r.status,
serde_json::from_slice(&r.body).unwrap_or(serde_json::Value::Null),
))
}
fn status_of(j: &serde_json::Value) -> ChargeStatus {
match j["status"].as_str().unwrap_or("") {
"captured" => ChargeStatus::Captured {
at_unix_ms: j["at"].as_u64().unwrap_or(0),
vendor_ref: j["ref"].as_str().unwrap_or("").into(),
},
"released" => ChargeStatus::Released,
"held" => ChargeStatus::Reserved,
_ => ChargeStatus::AwaitingCustomer,
}
}
fn reserve(
host: &dyn Host,
s: &serde_json::Value,
req: &ChargeRequest,
) -> Result<ChargeHandle, PayError> {
let body = serde_json::to_vec(req).unwrap_or_default();
let (code, j) = http(host, "POST", format!("{}/reserve", base(s)?), body)?;
if code == 402 {
return Err(PayError::Declined(format!(
"toyvendor: {}",
j["error"].as_str().unwrap_or("declined")
)));
}
let id = j["id"]
.as_str()
.ok_or_else(|| PayError::Unavailable(format!("toyvendor: reserve -> {code}")))?
.to_string();
// The host's memory: which reference this handle is for.
host.state_put(&format!("ref:{id}"), req.reference.as_bytes())
.map_err(PayError::Unavailable)?;
Ok(ChargeHandle {
vendor: ID.into(),
id,
})
}
impl VendorGuest for ToyVendor {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::VENDOR, ID, &["http", "hmac", "state"])
}
/// The block must name the vendor's `base`; a refusal keeps it out at load.
fn pay_manifest(
&self,
_: &dyn Host,
settings: &serde_json::Value,
) -> Result<PayManifest, String> {
base(settings).map_err(|e| e.to_string())?;
Ok(PayManifest {
vendor: ID.into(),
markets: vec![Market::Se],
settings: vec!["base".into()],
holds: true,
test_payer: None,
})
}
/// An order's page at the vendor; the toy sells no sign-up, card check or test sign-up page.
fn page(
&self,
host: &dyn Host,
s: &serde_json::Value,
charge: &Charge<'_>,
_: &Return,
) -> Result<Page, PayError> {
Ok(match charge {
Charge::Order(req) => Page::Order {
handle: reserve(host, s, req)?,
url: None,
},
Charge::Signup(_) => Page::Signup(None),
Charge::CardCheck(_) => Page::CardCheck(None),
Charge::TestSignup(_) => Page::TestSignup(None),
})
}
fn status(
&self,
host: &dyn Host,
s: &serde_json::Value,
of: &Of<'_>,
) -> Result<Standing, PayError> {
Ok(match of {
Of::Charge(h) => {
let (code, j) = http(
host,
"GET",
format!("{}/status/{}", base(s)?, h.id),
Vec::new(),
)?;
if code == 404 {
return Err(PayError::UnknownHandle((*h).clone()));
}
Standing::Charge(status_of(&j))
}
Of::Page(h) => Standing::Page(base(s).ok().map(|b| format!("{b}/pay/{}", h.id))),
Of::Signup { .. } => Standing::Signup(None),
Of::CardCheck { .. } => Standing::CardCheck(None),
})
}
fn capture(
&self,
host: &dyn Host,
s: &serde_json::Value,
h: &ChargeHandle,
) -> Result<ChargeStatus, PayError> {
let (code, j) = http(
host,
"POST",
format!("{}/capture/{}", base(s)?, h.id),
Vec::new(),
)?;
if code == 404 {
return Err(PayError::UnknownHandle(h.clone()));
}
Ok(status_of(&j))
}
fn release(
&self,
host: &dyn Host,
s: &serde_json::Value,
h: &ChargeHandle,
) -> Result<ChargeStatus, PayError> {
let (code, j) = http(
host,
"POST",
format!("{}/release/{}", base(s)?, h.id),
Vec::new(),
)?;
if code == 404 {
return Err(PayError::UnknownHandle(h.clone()));
}
Ok(status_of(&j))
}
/// `toy-signature: <hex hmac-sha256 of the body>`, checked by the HOST.
fn callback(
&self,
host: &dyn Host,
_: &serde_json::Value,
raw: RawCallback<'_>,
) -> Result<CallbackEvent, PayError> {
let sig = raw
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("toy-signature"))
.map(|(_, v)| v.clone())
.ok_or_else(|| PayError::Unverified("toyvendor: no toy-signature".into()))?;
match host.hmac_verify("toy_hook", raw.body, &[sig]) {
Ok(true) => {}
Ok(false) => return Err(PayError::Unverified("toyvendor: signature mismatch".into())),
Err(e) => return Err(PayError::Unverified(format!("toyvendor: {e}"))),
}
let j: serde_json::Value = serde_json::from_slice(raw.body)
.map_err(|e| PayError::Ignored(format!("toyvendor: {e}")))?;
let id = j["id"].as_str().unwrap_or("").to_string();
let reference = match host.state_get(&format!("ref:{id}")) {
Ok(Some(r)) => String::from_utf8_lossy(&r).into_owned(),
_ => return Err(PayError::Ignored(format!("toyvendor: {id} is not ours"))),
};
Ok(CallbackEvent {
reference,
handle: ChargeHandle {
vendor: ID.into(),
id,
},
status: status_of(&j),
})
}
/// Asks the vendor (`POST /refund/<id>`, the amount as the body). A vendor that has no
/// refund for it (no such route, nothing drawn) is a decline, named with its status.
fn refund(
&self,
host: &dyn Host,
s: &serde_json::Value,
h: &ChargeHandle,
amount: Money,
) -> Result<ChargeStatus, PayError> {
let body = serde_json::to_vec(&amount).unwrap_or_default();
let (code, j) = http(host, "POST", format!("{}/refund/{}", base(s)?, h.id), body)?;
if code != 200 {
return Err(PayError::Declined(format!(
"toyvendor: the vendor refused the refund of {} ({code})",
h.id
)));
}
Ok(status_of(&j))
}
fn settlement(
&self,
_: &dyn Host,
_: &serde_json::Value,
_: &ChargeHandle,
) -> Option<Settlement> {
None
}
}
monetize_pay_guest::export_vendor!(ToyVendor, grants = [http, hmac, state]);