crates/monetize-product-wasm/src/vendor.rs
//! **A payment vendor served by a signed `.wasm`** — the host half of
//! `monetize-pay-guest`.
//!
//! ```text
//! [vendors.<kind>.<instance>] guest = "<kind>.wasm" ──▶ GuestVendor::build(&VendorGuestBuild)
//! guest_digest / guest_trust_roots ──▶ refused by name unless the bytes are vouched for
//! grants = ["http", "hmac", "state"] ──▶ exactly those doors, and no others a vendor may have
//! http_hosts / http_auth / hmac_secrets ▶ the host reaches, signs and verifies; the guest never holds a secret
//! ▼
//! HotVendor: impl Payment (A24, ten ops) and Payment over it, swapped in place while it serves
//! ```
//!
//! What the ledger and the transaction see is a `&dyn Payment`, as
//! they always did, named for the INSTANCE (`stripe/gunnar`): one module of a
//! kind serves several instances — one account per product — each with its
//! own block, secrets and memory. The guest itself only ever sees its kind;
//! [`monetize_pay::instance::Names`] translates every handle at the edge. Every method is ONE `plugin_call` in a fresh, bounded
//! store; the block's settings (never a secret) ride on every call.
//!
//! # Errors keep their meaning
//!
//! A failure on the HOST side — a trap, a bound, an undecodable answer, a door
//! that faulted — is [`PayError::Unavailable`]: nothing is known to have
//! happened at the vendor, and `Unavailable` is the one error the trait tells
//! a caller to retry after asking `status`. The guest's own answer keeps its
//! arm (`Declined`, `Unverified`, `Ignored`, `Signup`, `UnknownHandle`), because
//! the server answers a webhook 401, 200 or "a stranger paid" by the arm.
use std::sync::{Arc, RwLock};
use guest_host::module::{Instantiated, LoadedModule};
use guest_host::wasmtime::Linker;
use monetize_pay::signup::SignupAtVendor;
use monetize_pay::instance::Names;
use monetize_pay::payment::{Charge, Of, Page, PayManifest, Payment, Return, Standing};
use monetize_pay::{CallbackEvent, ChargeHandle, ChargeStatus, Market, Money, PaidAtVendor, PayError, RawCallback, Settlement};
use monetize_pay_guest::ops;
use monetize_pay_guest::wire::{Call, CallbackReq, PageReq, PaidSinceReq, PayAnswer, RefundReq, WireCallbackEvent, WireCharge, WireOf, WirePage, WirePayError, WireStanding};
use monetize_product::GuestSpec;
use monetize_product_guest::{grants, kinds, wire, Manifest};
use crate::doors::{self, CallState, DoorSpec, Doors};
use crate::load::{Block, LoadedGuest, PRODUCT_CALL_TIMEOUT};
/// **The doors a vendor may be granted.** A payment vendor talks HTTPS to its
/// own API, checks its own webhooks and remembers what it already did; it has
/// no product control plane to dial and no identity to sign with.
pub const VENDOR_GRANTS: &[&str] = &[grants::HTTP, grants::HMAC, grants::STATE];
/// What a vendor guest is built from: the `[vendors.<kind>.<instance>]` block.
pub struct VendorGuestBuild<'a> {
/// The instance id, `<kind>/<instance>` (`stripe/gunnar`).
pub name: &'a str,
pub spec: &'a GuestSpec,
/// The block's own keys the server does not read — the vendor's settings.
/// Never a secret: those are files the host reads.
pub settings: &'a serde_json::Value,
}
impl VendorGuestBuild<'_> {
pub fn err(&self, what: impl std::fmt::Display) -> String {
format!("{} {what}", monetize_pay::instance::block_of(self.name))
}
fn block<'b>(&'b self, dotted: &'b str) -> Block<'b> {
Block { section: "vendors", id: dotted, spec: Some(self.spec), kind: kinds::VENDOR }
}
}
/// One vendor guest, loaded.
pub struct GuestVendor {
module: LoadedModule,
doors: Arc<Doors>,
manifest: Manifest,
names: Names,
markets: &'static [Market],
/// The `manifest` op's `holds` and test payer, asked once at load.
holds: bool,
test_payer: Option<String>,
settings: serde_json::Value,
}
impl std::fmt::Debug for GuestVendor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GuestVendor")
.field("id", &self.names.id)
.field("module", &self.module.path())
.field("digest", &self.module.digest_hex())
.field("signer", &self.module.signer())
.field("grants", &self.manifest.grants)
.field("markets", &self.markets)
.finish()
}
}
impl GuestVendor {
/// The whole load order ([`crate::load`]), the vendor's doors, then `id` and
/// `manifest` (the block's settings admitted, the markets, `holds`, the test payer), once.
pub fn build(b: &VendorGuestBuild<'_>) -> Result<GuestVendor, String> {
GuestVendor::build_after(b, None)
}
/// [`GuestVendor::build`] for a REPLACEMENT of `previous`: every gate a
/// start passes, and the host's memory for the vendor shared with the one
/// it replaces (a capture remembered by a call still finishing on the old
/// guest is seen by the new one).
pub fn build_after(b: &VendorGuestBuild<'_>, previous: Option<&GuestVendor>) -> Result<GuestVendor, String> {
let names = Names::of(b.name).map_err(|e| format!("[vendors] {e}"))?;
for g in &b.spec.grants {
if !VENDOR_GRANTS.contains(&g.as_str()) {
return Err(b.err(format!(
"[vendor-grant] `grants` names `{g}`; a vendor guest may have only [{}] — it has no product to dial and no identity to sign with",
VENDOR_GRANTS.join(", ")
)));
}
}
let dotted = b.name.replace('/', ".");
let loaded = LoadedGuest::load_block_named(b.block(&dotted), names.kind, crate::load::PRODUCT_FUEL_CEILING, PRODUCT_CALL_TIMEOUT)?;
let call_timeout = loaded.module.limits().call_timeout.min(PRODUCT_CALL_TIMEOUT);
let mut doors = Doors::open_with(DoorSpec {
product: b.name,
endpoint: "",
bearer: None,
twin: None,
tls_ca: None,
tls_client: None,
grants: &loaded.spec.grants,
http_hosts: &loaded.spec.http_hosts,
grpc_hosts: &[],
call_timeout: call_timeout / 2,
http_auth: &loaded.spec.http_auth,
hmac_secrets: &loaded.spec.hmac_secrets,
secrets_dir: loaded.spec.secrets_dir.as_deref(),
http_resolve: &[],
})
.map_err(|e| b.err(e))?;
if let Some(old) = previous {
doors.state = Arc::clone(&old.doors.state);
}
let mut v = GuestVendor {
module: loaded.module,
doors: Arc::new(doors),
manifest: loaded.manifest,
names,
markets: &[],
holds: true,
test_payer: None,
settings: b.settings.clone(),
};
let id: String = v.host_call::<String, _>(ops::ID, &()).map_err(|e| b.err(e))?.map_err(|e| b.err(format!("{e:?}")))?;
if id != names.kind {
return Err(b.err(format!("the guest's `id` op answers `{id}`, and this block loads a `{}` guest", names.kind)));
}
let about: PayManifest = match v.host_call::<PayManifest, _>(ops::MANIFEST, &()).map_err(|e| b.err(e))? {
Ok(m) => m,
Err(WirePayError::Unavailable(why)) if why.contains(ops::UNKNOWN_OP_PHRASE) => {
return Err(b.err(format!(
"[vendor-old-table] the guest has no `manifest` op ({why}): it is built on the vendor op table before monetize v2 (twenty ops); rebuild it on monetize-pay-guest's ten"
)))
}
Err(e) => return Err(b.err(format!("[vendor-settings-refused] the guest refused this block's settings: {}", PayError::from(e)))),
};
let markets = about.markets;
if markets.is_empty() {
return Err(b.err("[vendor-no-market] the guest serves no market; it could never take an order"));
}
v.holds = about.holds;
v.test_payer = about.test_payer;
v.markets = crate::intern_markets(&markets);
Ok(v)
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn digest_hex(&self) -> &str {
self.module.digest_hex()
}
pub fn signer(&self) -> Option<&str> {
self.module.signer()
}
/// Exactly the doors defined on every linker this guest makes.
pub fn linker_defines(&self) -> &[&'static str] {
&self.doors.granted_imports
}
/// **What the guest's memory holds after one call** — the whole linear
/// memory of a fresh instance, read after `op` returned. For the proof that
/// a secret never reaches a guest; nothing on a serving path calls it.
pub fn memory_after<R: serde::Serialize>(&self, op: u32, req: &R) -> Result<Vec<u8>, String> {
let mut inst = self.instantiate()?;
let payload = wire::encode(&Call { settings: self.settings.clone(), req });
let _ = self.module.call_op(&mut inst, op, &payload);
self.doors.sweep_streams();
let mem = inst.instance.get_memory(&mut inst.store, "memory").ok_or("the guest exports no memory")?;
Ok(mem.data(&inst.store).to_vec())
}
fn instantiate(&self) -> Result<Instantiated<CallState>, String> {
let mut linker = Linker::new(self.module.engine());
doors::register(&mut linker, &self.doors.granted_imports)?;
let state = CallState::new(Arc::clone(&self.doors), self.module.limits().limiter());
self.module.instantiate(state, &linker).map_err(|e| e.to_string())
}
/// One op: `Call { settings, req }` in, the envelope out. The outer `Err`
/// is the HOST's failure, naming the module and the op.
fn host_call<T: serde::de::DeserializeOwned, R: serde::Serialize>(&self, op: u32, req: &R) -> Result<PayAnswer<T>, String> {
let mut inst = self.instantiate()?;
let payload = wire::encode(&Call { settings: self.settings.clone(), req });
let raw = self.module.call_op(&mut inst, op, &payload);
self.doors.sweep_streams();
let raw = raw.map_err(|e| format!("guest {}: op {} ({}): {e}", self.module.path().display(), op, ops::name(op).unwrap_or("?")))?;
wire::decode(&raw).map_err(|e| format!("guest {}: op {} ({}): the guest answered undecodable bytes: {e}", self.module.path().display(), op, ops::name(op).unwrap_or("?")))
}
/// [`GuestVendor::host_call`] with the host's failure as `Unavailable`.
fn verb<T: serde::de::DeserializeOwned, R: serde::Serialize>(&self, op: u32, req: &R) -> Result<T, PayError> {
match self.host_call::<T, R>(op, req) {
Ok(a) => a.map_err(PayError::from),
Err(e) => Err(PayError::Unavailable(format!("{} {e}", monetize_pay::instance::block_of(self.names.id)))),
}
}
/// For the two methods that may not fail: the host's failure is `None`,
/// said on the journal so it is not silent.
fn quiet<T: serde::de::DeserializeOwned, R: serde::Serialize>(&self, op: u32, req: &R) -> Option<T> {
match self.host_call::<Option<T>, R>(op, req) {
Ok(Ok(v)) => v,
Ok(Err(e)) => {
eprintln!("{} {} answered {:?}; treated as no answer", monetize_pay::instance::block_of(self.names.id), ops::name(op).unwrap_or("?"), e_kind(&e));
None
}
Err(e) => {
eprintln!("{} {e}; treated as no answer", monetize_pay::instance::block_of(self.names.id));
None
}
}
}
}
fn e_kind(e: &WirePayError) -> &'static str {
match e {
WirePayError::Declined(_) => "Declined",
WirePayError::Unavailable(_) => "Unavailable",
WirePayError::Unverified(_) => "Unverified",
WirePayError::Ignored(_) => "Ignored",
WirePayError::Signup(_) => "Signup",
WirePayError::UnknownHandle(_) => "UnknownHandle",
WirePayError::Reversal(_) => "Reversal",
}
}
/// **The ten ops over the guest's op table** (A24 §2.6) — the host-side adapter,
/// ONCE. The host holds it as a `monetize_pay::Vendor`: its name and markets (below) and these ten ops.
impl Payment for GuestVendor {
/// Asked of the guest once, at load (`holds`, the test payer, the markets); the
/// vendor is the instance and the setting names are the block's.
fn manifest(&self) -> PayManifest {
let settings = self.settings.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
PayManifest { vendor: self.names.id.to_string(), markets: self.markets.to_vec(), settings, holds: self.holds, test_payer: self.test_payer.clone() }
}
/// ONE op for every page; the way back rides with the charge (the guest applies it).
/// Handles and paid sign-ups name THIS instance.
fn page(&self, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError> {
let charge = WireCharge::of(charge);
let page: WirePage = self.verb(ops::PAGE, &PageReq { charge, paid: back.paid.clone(), cancel: back.cancel.clone() }).map_err(|e| self.names.error(e))?;
Ok(match Page::from(page) {
Page::Order { handle, url } => Page::Order { handle: self.names.outbound(handle), url },
Page::TestSignup(paid) => Page::TestSignup(paid.map(|mut p| {
p.vendor = self.names.id.to_string();
p
})),
other => other,
})
}
fn status(&self, of: &Of<'_>) -> Result<Standing, PayError> {
let q = match of {
Of::Charge(h) => WireOf::Charge(self.names.inbound(h)?),
// A page's URL may not fail: the host's failure is "no page".
Of::Page(h) => {
let Ok(h) = self.names.inbound(h) else { return Ok(Standing::Page(None)) };
return Ok(match self.verb::<WireStanding, _>(ops::STATUS, &WireOf::Page(h)) {
Ok(WireStanding::Page(url)) => Standing::Page(url),
Ok(other) => return Err(monetize_pay::payment::wrong_standing("status", &other.into())),
Err(e) => {
eprintln!("{} page url: {e}; treated as no answer", monetize_pay::instance::block_of(self.names.id));
Standing::Page(None)
}
});
}
other => WireOf::of(other),
};
let st: WireStanding = self.verb(ops::STATUS, &q).map_err(|e| self.names.error(e))?;
Ok(match Standing::from(st) {
Standing::Signup(Some(SignupAtVendor::Paid(mut p))) => {
p.vendor = self.names.id.to_string();
Standing::Signup(Some(SignupAtVendor::Paid(p)))
}
other => other,
})
}
fn capture(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.verb(ops::CAPTURE, &self.names.inbound(handle)?).map_err(|e| self.names.error(e))
}
fn release(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
self.verb(ops::RELEASE, &self.names.inbound(handle)?).map_err(|e| self.names.error(e))
}
fn refund(&self, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError> {
self.verb(ops::REFUND, &RefundReq { handle: self.names.inbound(handle)?, amount }).map_err(|e| self.names.error(e))
}
fn callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
let e: WireCallbackEvent = self.verb(ops::CALLBACK, &CallbackReq { headers: raw.headers.to_vec(), body: raw.body.to_vec() }).map_err(|e| self.names.error(e))?;
Ok(self.names.event(CallbackEvent { reference: e.reference, handle: e.handle, status: e.status }))
}
fn settlement(&self, handle: &ChargeHandle) -> Option<Settlement> {
self.quiet(ops::SETTLEMENT, &self.names.inbound(handle).ok()?)
}
fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError> {
self.verb::<Vec<PaidAtVendor>, _>(ops::PAID_SINCE, &PaidSinceReq { since_unix_ms })
.map(|v| v.into_iter().map(|p| self.names.paid(p)).collect())
.map_err(|e| self.names.error(e))
}
/// The host's failure is "cannot say" (`None`), said on the journal.
fn livemode(&self) -> Option<bool> {
self.quiet(ops::LIVEMODE, &())
}
}
impl GuestVendor {
/// The instance's name: what the registry holds it under.
pub fn name(&self) -> &'static str {
self.names.id
}
/// The markets the guest answered at build (interned).
pub fn markets(&self) -> &'static [Market] {
self.markets
}
}
/// **A block's identity as far as a replacement is concerned**: the module's
/// bytes, every guest key, and the settings. Hashed, so nothing of the block
/// is kept as text.
pub fn vendor_fingerprint(b: &VendorGuestBuild<'_>) -> Result<String, String> {
let bytes = std::fs::read(&b.spec.module).map_err(|e| b.err(format!("guest {}: could not be read: {e}", b.spec.module.display())))?;
let text = format!("{}|{:?}|{}", guest_host::digest_hex(&bytes), b.spec, b.settings);
Ok(guest_host::digest_hex(text.as_bytes()))
}
/// What a replacement did — for the operator's log line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VendorReplaced {
pub id: &'static str,
pub from_digest: String,
pub to_digest: String,
pub signer: Option<String>,
}
/// **One vendor, served by whichever guest was last swapped in** — the
/// [`crate::HotGuest`] shape. A call clones the serving `Arc` and runs on it;
/// a swap changes only which one the NEXT call clones. The ledger holds a
/// `monetize_pay::Vendor` over THIS, which never moves.
pub struct HotVendor {
id: &'static str,
cell: RwLock<Arc<GuestVendor>>,
source: RwLock<String>,
writer: std::sync::Mutex<()>,
}
impl std::fmt::Debug for HotVendor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HotVendor").field("id", &self.id).field("serving", &*self.current()).finish()
}
}
impl HotVendor {
pub fn build(b: &VendorGuestBuild<'_>) -> Result<HotVendor, String> {
let source = vendor_fingerprint(b)?;
let g = Arc::new(GuestVendor::build(b)?);
Ok(HotVendor { id: g.names.id, cell: RwLock::new(g), source: RwLock::new(source), writer: std::sync::Mutex::new(()) })
}
/// The guest serving NOW.
pub fn current(&self) -> Arc<GuestVendor> {
Arc::clone(&self.cell.read().unwrap_or_else(|p| p.into_inner()))
}
/// Has the block changed since the serving guest was built from it?
pub fn is_current_for(&self, b: &VendorGuestBuild<'_>) -> Result<bool, String> {
Ok(*self.source.read().unwrap_or_else(|p| p.into_inner()) == vendor_fingerprint(b)?)
}
/// **Replace the serving guest, live.** Built completely — every gate,
/// `id`, `check`, `markets` — before one pointer write; on `Err` the
/// serving guest is untouched and keeps taking money.
pub fn replace(&self, b: &VendorGuestBuild<'_>) -> Result<VendorReplaced, String> {
if b.name != self.id {
return Err(b.err(format!("is not {}: a live replacement cannot move a guest to another instance", monetize_pay::instance::block_of(self.id))));
}
let _w = self.writer.lock().unwrap_or_else(|p| p.into_inner());
let source = vendor_fingerprint(b)?;
let previous = self.current();
let next = Arc::new(GuestVendor::build_after(b, Some(&previous))?);
let r = VendorReplaced {
id: self.id,
from_digest: previous.digest_hex().to_string(),
to_digest: next.digest_hex().to_string(),
signer: next.signer().map(str::to_string),
};
*self.cell.write().unwrap_or_else(|p| p.into_inner()) = next;
*self.source.write().unwrap_or_else(|p| p.into_inner()) = source;
Ok(r)
}
}
impl Payment for HotVendor {
fn manifest(&self) -> PayManifest {
Payment::manifest(&*self.current())
}
fn page(&self, charge: &Charge<'_>, back: &Return) -> Result<Page, PayError> {
Payment::page(&*self.current(), charge, back)
}
fn status(&self, of: &Of<'_>) -> Result<Standing, PayError> {
Payment::status(&*self.current(), of)
}
fn capture(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
Payment::capture(&*self.current(), handle)
}
fn release(&self, handle: &ChargeHandle) -> Result<ChargeStatus, PayError> {
Payment::release(&*self.current(), handle)
}
fn refund(&self, handle: &ChargeHandle, amount: Money) -> Result<ChargeStatus, PayError> {
Payment::refund(&*self.current(), handle, amount)
}
fn callback(&self, raw: RawCallback<'_>) -> Result<CallbackEvent, PayError> {
Payment::callback(&*self.current(), raw)
}
fn settlement(&self, handle: &ChargeHandle) -> Option<Settlement> {
Payment::settlement(&*self.current(), handle)
}
fn paid_since(&self, since_unix_ms: i64) -> Result<Vec<PaidAtVendor>, PayError> {
Payment::paid_since(&*self.current(), since_unix_ms)
}
fn livemode(&self) -> Option<bool> {
Payment::livemode(&*self.current())
}
}
impl HotVendor {
/// The instance's name: what the registry holds it under; a replacement keeps it.
pub fn name(&self) -> &'static str {
self.id
}
/// Interned per distinct set, so it outlives the guest it came from.
pub fn markets(&self) -> &'static [Market] {
self.current().markets
}
}