use std::str::FromStr;
use compact_str::CompactString;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::network::ChainId;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[non_exhaustive]
pub struct PaymentRequirements<
TScheme = CompactString,
TAmount = CompactString,
TAddress = CompactString,
TExtra = serde_json::Value,
> {
pub scheme: TScheme,
pub network: ChainId,
pub amount: TAmount,
pub pay_to: TAddress,
pub max_timeout_seconds: u64,
pub asset: TAddress,
#[serde(default = "Option::default", skip_serializing_if = "Option::is_none")]
pub extra: Option<TExtra>,
}
#[must_use]
pub fn find_matching_requirements<'a>(
available: &'a [PaymentRequirements],
accepted: &PaymentRequirements,
) -> Option<&'a PaymentRequirements> {
available
.iter()
.find(|req| req.matches_payload_accepted(accepted))
}
impl<TScheme, TAmount, TAddress, TExtra> PaymentRequirements<TScheme, TAmount, TAddress, TExtra> {
#[must_use]
pub const fn new(
scheme: TScheme,
network: ChainId,
amount: TAmount,
pay_to: TAddress,
asset: TAddress,
max_timeout_seconds: u64,
) -> Self {
Self {
scheme,
network,
amount,
pay_to,
asset,
max_timeout_seconds,
extra: None,
}
}
#[must_use]
pub fn with_extra(mut self, extra: TExtra) -> Self {
self.extra = Some(extra);
self
}
#[must_use]
pub fn with_optional_extra(mut self, extra: Option<TExtra>) -> Self {
self.extra = extra;
self
}
}
impl<TScheme, TAmount, TAddress, TExtra> PaymentRequirements<TScheme, TAmount, TAddress, TExtra>
where
TScheme: PartialEq,
TAmount: PartialEq,
TAddress: PartialEq,
TExtra: Serialize,
{
#[must_use]
pub fn matches_payload_accepted(&self, accepted: &Self) -> bool {
self.matches_payload_accepted_with_dynamic(accepted, &[])
}
#[must_use]
pub fn matches_payload_accepted_with_dynamic(
&self,
accepted: &Self,
dynamic_extra_fields: &[&str],
) -> bool {
self.scheme == accepted.scheme
&& self.network == accepted.network
&& self.amount == accepted.amount
&& self.asset == accepted.asset
&& self.pay_to == accepted.pay_to
&& self.max_timeout_seconds == accepted.max_timeout_seconds
&& extra_contains_subset(
self.extra.as_ref(),
accepted.extra.as_ref(),
dynamic_extra_fields,
)
}
}
fn extra_contains_subset<T: Serialize>(
required: Option<&T>,
accepted: Option<&T>,
dynamic_extra_fields: &[&str],
) -> bool {
let Some(required_extra) = required else {
return true;
};
let Ok(required_value) = serde_json::to_value(required_extra) else {
return false;
};
let accepted_value = accepted.and_then(|extra| serde_json::to_value(extra).ok());
if accepted.is_some() && accepted_value.is_none() {
return false;
}
let required_omitted = omit_fields(&required_value, dynamic_extra_fields);
let accepted_omitted = accepted_value
.as_ref()
.map(|value| omit_fields(value, dynamic_extra_fields));
object_contains_subset(&required_omitted, accepted_omitted.as_ref())
}
fn omit_fields(value: &serde_json::Value, fields: &[&str]) -> serde_json::Value {
if fields.is_empty() {
return value.clone();
}
let serde_json::Value::Object(map) = value else {
return value.clone();
};
let mut copied = map.clone();
for field in fields {
copied.remove(*field);
}
serde_json::Value::Object(copied)
}
fn object_contains_subset(
expected: &serde_json::Value,
actual: Option<&serde_json::Value>,
) -> bool {
let serde_json::Value::Object(expected_map) = expected else {
return actual.is_some_and(|got| got == expected);
};
let Some(serde_json::Value::Object(actual_map)) = actual else {
return false;
};
expected_map.iter().all(|(key, value)| {
actual_map.get(key).map_or_else(
|| value.is_null(),
|got| object_contains_subset(value, Some(got)),
)
})
}
impl PaymentRequirements {
#[must_use]
pub fn as_concrete<TScheme, TAmount, TAddress, TExtra>(
&self,
) -> Option<PaymentRequirements<TScheme, TAmount, TAddress, TExtra>>
where
TScheme: FromStr,
TAmount: FromStr,
TAddress: FromStr,
TExtra: DeserializeOwned,
{
let scheme = self.scheme.parse::<TScheme>().ok()?;
let amount = self.amount.parse::<TAmount>().ok()?;
let pay_to = self.pay_to.parse::<TAddress>().ok()?;
let asset = self.asset.parse::<TAddress>().ok()?;
let extra = self
.extra
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
Some(PaymentRequirements {
scheme,
network: self.network.clone(),
amount,
pay_to,
max_timeout_seconds: self.max_timeout_seconds,
asset,
extra,
})
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "unit tests panic on assertion failure")]
mod tests {
use super::*;
#[test]
fn rejects_unknown_top_level_field() {
let json = serde_json::json!({
"scheme": "exact",
"network": "eip155:8453",
"amount": "1",
"payTo": "0x0",
"maxTimeoutSeconds": 60,
"asset": "0x0",
"unknownField": 1
});
assert!(serde_json::from_value::<PaymentRequirements>(json).is_err());
}
fn sample(network: &str, amount: &str, pay_to: &str, timeout: u64) -> PaymentRequirements {
PaymentRequirements::new(
"exact".into(),
network.parse().unwrap(),
amount.into(),
pay_to.into(),
"USDC".into(),
timeout,
)
}
#[test]
fn find_matching_requirements_go_semantics() {
let a = sample("eip155:1", "1000000", "0xrecipient1", 60);
let b = sample("eip155:8453", "2000000", "0xrecipient2", 30);
let available = [a.clone(), b.clone()];
let matched = find_matching_requirements(&available, &b).unwrap();
assert_eq!(matched.network.to_string(), "eip155:8453");
assert_eq!(matched.max_timeout_seconds, 30);
let mut timeout_miss = b;
timeout_miss.max_timeout_seconds = 999;
assert!(find_matching_requirements(&available, &timeout_miss).is_none());
let mut miss = a;
miss.scheme = "nonexistent".into();
assert!(find_matching_requirements(&available, &miss).is_none());
}
#[test]
fn matches_when_accepted_extra_has_additional_object_keys() {
let required =
sample("eip155:8453", "1000000", "0xabc", 300).with_extra(serde_json::json!({
"name": "USDC",
"version": "2",
"nested": { "required": true }
}));
let accepted =
sample("eip155:8453", "1000000", "0xabc", 300).with_extra(serde_json::json!({
"name": "USDC",
"version": "2",
"nested": { "required": true, "clientOnly": "ok" },
"channelState": { "chargedCumulativeAmount": "2000" }
}));
assert!(required.matches_payload_accepted(&accepted));
}
#[test]
fn matches_when_required_extra_is_absent() {
let required = sample("eip155:8453", "1000000", "0xabc", 300);
let accepted = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "clientOnly": true }));
assert!(required.matches_payload_accepted(&accepted));
}
#[test]
fn matches_when_required_null_key_is_missing_on_accepted() {
let required = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "k": null }));
let accepted =
sample("eip155:8453", "1000000", "0xabc", 300).with_extra(serde_json::json!({}));
assert!(required.matches_payload_accepted(&accepted));
}
#[test]
fn does_not_match_when_accepted_extra_overwrites_server_field() {
let required = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "name": "USDC", "version": "2" }));
let accepted = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "name": "USDC", "version": "3" }));
assert!(!required.matches_payload_accepted(&accepted));
}
#[test]
fn does_not_match_when_accepted_extra_array_is_a_superset() {
let required = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "allowedSigners": ["0xalice"] }));
let accepted = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "allowedSigners": ["0xmallory", "0xalice"] }));
assert!(!required.matches_payload_accepted(&accepted));
}
#[test]
fn does_not_match_when_accepted_extra_array_is_reordered() {
let required = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "allowedSigners": ["0xalice", "0xbob"] }));
let accepted = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "allowedSigners": ["0xbob", "0xalice"] }));
assert!(!required.matches_payload_accepted(&accepted));
}
#[test]
fn does_not_match_when_accepted_extra_omits_server_fields() {
let required = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "name": "USDC", "version": "2" }));
let accepted = sample("eip155:8453", "1000000", "0xabc", 300)
.with_extra(serde_json::json!({ "name": "USDC" }));
assert!(!required.matches_payload_accepted(&accepted));
}
#[test]
fn matches_when_only_declared_dynamic_extra_fields_differ() {
let required =
sample("solana:mainnet", "1000000", "PayTo1", 60).with_extra(serde_json::json!({
"feePayer": "FeePayer111111111111111111111111111111111",
"recentBlockhash": "freshBlockhash",
"lastValidBlockHeight": "200"
}));
let accepted =
sample("solana:mainnet", "1000000", "PayTo1", 60).with_extra(serde_json::json!({
"feePayer": "FeePayer111111111111111111111111111111111",
"recentBlockhash": "staleBlockhash",
"lastValidBlockHeight": "100"
}));
assert!(required.matches_payload_accepted_with_dynamic(
&accepted,
&["recentBlockhash", "lastValidBlockHeight"],
));
assert!(!required.matches_payload_accepted(&accepted));
}
#[test]
fn does_not_match_when_static_extra_differs_despite_dynamic_fields() {
let required =
sample("solana:mainnet", "1000000", "PayTo1", 60).with_extra(serde_json::json!({
"feePayer": "FeePayer111111111111111111111111111111111",
"recentBlockhash": "freshBlockhash"
}));
let accepted =
sample("solana:mainnet", "1000000", "PayTo1", 60).with_extra(serde_json::json!({
"feePayer": "OtherPayer1111111111111111111111111111111",
"recentBlockhash": "staleBlockhash"
}));
assert!(!required.matches_payload_accepted_with_dynamic(
&accepted,
&["recentBlockhash", "lastValidBlockHeight"],
));
}
}