Skip to content

Commit 4d8d134

Browse files
fix(mpp): match challenges by chain ID and currency from keys.toml
When a server offers multiple 402 challenges (e.g. different chains and currencies), iterate through them and pick the first one matching a key in keys.toml by both chain_id AND currency. Previously, the code only looked at the first challenge's chainId and committed to it, which could fail when the user only had keys for a different chain or currency. - Add DiscoverOptions with chain_id + currency filtering - Consolidate discover_mpp_config_for_chain/discover_mpp_config_with into a single discover_mpp_config(DiscoverOptions) - Use find_map to iterate challenges and probe key compatibility before caching the provider - Extract challenge parsing into extract_challenge_chain_and_currency() Amp-Thread-ID: https://ampcode.com/threads/T-019d51ba-2e7d-739a-a46c-21bc7827fd26 Co-authored-by: Amp <amp@ampcode.com>
1 parent e0b46b7 commit 4d8d134

File tree

2 files changed

+96
-70
lines changed

2 files changed

+96
-70
lines changed

crates/common/src/provider/mpp/keys.rs

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ use crate::tempo::{TEMPO_PRIVATE_KEY_ENV, WalletType, read_tempo_keys_file};
99
use alloy_primitives::Address;
1010
use tracing::debug;
1111

12+
/// Options for MPP key discovery filtering.
13+
#[derive(Debug, Default)]
14+
pub struct DiscoverOptions {
15+
/// Only consider keys matching this chain ID.
16+
pub chain_id: Option<u64>,
17+
/// Only consider keys whose spending limits include this currency.
18+
pub currency: Option<Address>,
19+
}
20+
1221
/// Discovered MPP key configuration.
1322
///
1423
/// Contains the private key and optional keychain metadata for signing mode
@@ -23,30 +32,25 @@ pub struct MppKeyConfig {
2332
pub key_address: Option<Address>,
2433
/// RLP-encoded signed key authorization (hex string).
2534
pub key_authorization: Option<String>,
35+
/// Currencies from the key's spending limits.
36+
pub currencies: Vec<Address>,
2637
}
2738

2839
/// Attempt to auto-discover an MPP signing key from the Tempo wallet.
2940
///
3041
/// Returns `Some(hex_key)` if a key is found, `None` otherwise.
3142
/// Never fails — discovery errors are silently ignored (logged at debug level).
3243
pub fn discover_mpp_key() -> Option<String> {
33-
discover_mpp_config().map(|c| c.key)
34-
}
35-
36-
/// Attempt to auto-discover MPP key configuration from the Tempo wallet.
37-
///
38-
/// Returns the private key along with optional wallet/key addresses needed for
39-
/// keychain signing mode. Never fails — discovery errors are silently ignored.
40-
pub fn discover_mpp_config() -> Option<MppKeyConfig> {
41-
discover_mpp_config_for_chain(None)
44+
discover_mpp_config(Default::default()).map(|c| c.key)
4245
}
4346

44-
/// Like [`discover_mpp_config`] but filters keys by `chain_id` when provided.
47+
/// Discover MPP key configuration filtered by chain ID and/or currency.
4548
///
46-
/// When `chain_id` is `Some`, only keys.toml entries whose `chain_id` matches
47-
/// are considered. This allows correct key selection when multiple keys
48-
/// (e.g. mainnet + testnet) are present.
49-
pub fn discover_mpp_config_for_chain(chain_id: Option<u64>) -> Option<MppKeyConfig> {
49+
/// Filters keys.toml entries by `chain_id` and `currency` simultaneously,
50+
/// then applies the standard priority rule (passkey > inline key > first)
51+
/// within the filtered set. This ensures the selected key matches both the
52+
/// target chain and the required currency.
53+
pub fn discover_mpp_config(opts: DiscoverOptions) -> Option<MppKeyConfig> {
5054
// 1. Check TEMPO_PRIVATE_KEY env var (no keychain metadata available)
5155
if let Ok(key) = std::env::var(TEMPO_PRIVATE_KEY_ENV) {
5256
let key = key.trim().to_string();
@@ -57,6 +61,7 @@ pub fn discover_mpp_config_for_chain(chain_id: Option<u64>) -> Option<MppKeyConf
5761
wallet_address: None,
5862
key_address: None,
5963
key_authorization: None,
64+
currencies: vec![],
6065
});
6166
}
6267
}
@@ -68,9 +73,16 @@ pub fn discover_mpp_config_for_chain(chain_id: Option<u64>) -> Option<MppKeyConf
6873
// `Keystore::primary_key()` in tempo-common:
6974
// passkey > first entry with inline key > first entry
7075
// Only entries with a usable inline key can provide a signing key.
71-
// When a chain_id filter is provided, only consider matching entries.
72-
let candidates: Vec<_> =
73-
keys_file.keys.iter().filter(|k| chain_id.is_none_or(|cid| k.chain_id == cid)).collect();
76+
// Filter by chain_id and currency when provided.
77+
let candidates: Vec<_> = keys_file
78+
.keys
79+
.iter()
80+
.filter(|k| opts.chain_id.is_none_or(|cid| k.chain_id == cid))
81+
.filter(|k| {
82+
opts.currency
83+
.is_none_or(|cur| k.limits.is_empty() || k.limits.iter().any(|l| l.currency == cur))
84+
})
85+
.collect();
7486

7587
let primary = candidates
7688
.iter()
@@ -90,6 +102,7 @@ pub fn discover_mpp_config_for_chain(chain_id: Option<u64>) -> Option<MppKeyConf
90102
wallet_address: Some(entry.wallet_address),
91103
key_address: entry.key_address,
92104
key_authorization: entry.key_authorization.clone(),
105+
currencies: entry.limits.iter().map(|l| l.currency).collect(),
93106
});
94107
}
95108
}
@@ -350,19 +363,22 @@ chain_id = 42431
350363
}
351364

352365
// Filter by testnet chain_id → returns testnet key (even though mainnet is first)
353-
let config = discover_mpp_config_for_chain(Some(42431));
366+
let config =
367+
discover_mpp_config(DiscoverOptions { chain_id: Some(42431), ..Default::default() });
354368
assert_eq!(config.as_ref().unwrap().key, testnet_key);
355369

356370
// Filter by mainnet chain_id → returns mainnet key
357-
let config = discover_mpp_config_for_chain(Some(4217));
371+
let config =
372+
discover_mpp_config(DiscoverOptions { chain_id: Some(4217), ..Default::default() });
358373
assert_eq!(config.as_ref().unwrap().key, mainnet_key);
359374

360375
// No filter → returns first key (mainnet)
361-
let config = discover_mpp_config_for_chain(None);
376+
let config = discover_mpp_config(Default::default());
362377
assert_eq!(config.as_ref().unwrap().key, mainnet_key);
363378

364379
// Filter by unknown chain_id → None
365-
let config = discover_mpp_config_for_chain(Some(9999));
380+
let config =
381+
discover_mpp_config(DiscoverOptions { chain_id: Some(9999), ..Default::default() });
366382
assert!(config.is_none());
367383

368384
// Passkey priority within filtered set
@@ -384,7 +400,8 @@ chain_id = 4217
384400
let (dir2, _) = setup_keys_toml(&toml_mixed);
385401
unsafe { std::env::set_var("TEMPO_HOME", dir2.path()) };
386402

387-
let config = discover_mpp_config_for_chain(Some(4217));
403+
let config =
404+
discover_mpp_config(DiscoverOptions { chain_id: Some(4217), ..Default::default() });
388405
assert_eq!(
389406
config.as_ref().unwrap().key,
390407
testnet_key,

crates/common/src/provider/mpp/transport.rs

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ use tower::Service;
1919
use tracing::{Instrument, debug, debug_span, trace};
2020
use url::Url;
2121

22-
use super::{keys::discover_mpp_config_for_chain, session::SessionProvider};
22+
use super::{
23+
keys::{DiscoverOptions, discover_mpp_config},
24+
session::SessionProvider,
25+
};
2326

2427
/// Default deposit amount for new channels (in base units).
2528
const DEFAULT_DEPOSIT: u128 = 100_000;
@@ -64,16 +67,17 @@ impl LazySessionProvider {
6467
return Ok(provider.clone());
6568
}
6669

67-
let config = discover_mpp_config_for_chain(chain_id).ok_or_else(|| {
68-
TransportErrorKind::custom(std::io::Error::other(
69-
"RPC endpoint returned HTTP 402 Payment Required. \
70+
let config = discover_mpp_config(DiscoverOptions { chain_id, ..Default::default() })
71+
.ok_or_else(|| {
72+
TransportErrorKind::custom(std::io::Error::other(
73+
"RPC endpoint returned HTTP 402 Payment Required. \
7074
This endpoint requires payment via the Machine Payments Protocol (MPP).\n\n\
7175
To configure MPP, install the Tempo wallet CLI and create a key:\n\
7276
\n curl -sSL https://tempo.xyz/install.sh | bash\
7377
\n tempo wallet login\
7478
\n\nSee https://docs.tempo.xyz for more information.",
75-
))
76-
})?;
79+
))
80+
})?;
7781

7882
let signer: mpp::PrivateKeySigner = config.key.parse().map_err(|e| {
7983
TransportErrorKind::custom(std::io::Error::other(format!("invalid MPP key: {e}")))
@@ -190,24 +194,19 @@ where
190194
.filter_map(|r| r.ok())
191195
.collect();
192196

193-
// Extract chainId from the first Tempo challenge to select the correct
194-
// key when multiple keys (mainnet/testnet) are present in keys.toml.
195-
let challenge_chain_id = challenges.iter().find_map(|c| {
196-
if c.method.as_str() == "tempo" {
197-
c.request
198-
.decode_value()
199-
.ok()
200-
.and_then(|v| v.get("methodDetails")?.get("chainId")?.as_u64())
201-
} else {
202-
None
203-
}
204-
});
205-
206-
let resolved = self.provider.resolve_for_chain(challenge_chain_id)?;
207-
208-
let challenge = challenges
197+
// Try each challenge until we find one with a matching key (chain + currency)
198+
// in keys.toml. This handles servers that offer multiple chains and currencies
199+
// (e.g. mainnet + testnet) — we pick the first one the user has a key for.
200+
let (resolved, challenge) = challenges
209201
.iter()
210-
.find(|c| resolved.supports(c.method.as_str(), c.intent.as_str()))
202+
.find_map(|c| {
203+
let (chain_id, currency) = extract_challenge_chain_and_currency(c);
204+
if !self.provider.supports_challenge(chain_id, currency.as_deref()) {
205+
return None;
206+
}
207+
let provider = self.provider.resolve_for_chain(chain_id).ok()?;
208+
provider.supports(c.method.as_str(), c.intent.as_str()).then_some((provider, c))
209+
})
211210
.ok_or_else(|| {
212211
let offered: Vec<_> =
213212
challenges.iter().map(|c| format!("{}.{}", c.method, c.intent)).collect();
@@ -401,13 +400,32 @@ where
401400
}
402401
}
403402

403+
/// Extract `(chainId, currency)` from a parsed MPP challenge.
404+
fn extract_challenge_chain_and_currency(
405+
c: &mpp::protocol::core::PaymentChallenge,
406+
) -> (Option<u64>, Option<String>) {
407+
if c.method.as_str() == "tempo" {
408+
let val = c.request.decode_value().ok();
409+
let chain_id = val.as_ref().and_then(|v| v.get("methodDetails")?.get("chainId")?.as_u64());
410+
let currency = val.as_ref().and_then(|v| v.get("currency")?.as_str().map(String::from));
411+
(chain_id, currency)
412+
} else {
413+
(None, None)
414+
}
415+
}
416+
404417
/// Trait for resolving a concrete `PaymentProvider` from a potentially lazy wrapper.
405418
pub(crate) trait ResolveProvider {
406419
type Provider: PaymentProvider;
407420
fn resolve(&self) -> TransportResult<Self::Provider> {
408421
self.resolve_for_chain(None)
409422
}
410423
fn resolve_for_chain(&self, _chain_id: Option<u64>) -> TransportResult<Self::Provider>;
424+
/// Check if this provider can handle a challenge with the given chain and currency
425+
/// without initializing/caching state. Returns `true` by default.
426+
fn supports_challenge(&self, _chain_id: Option<u64>, _currency: Option<&str>) -> bool {
427+
true
428+
}
411429
fn set_key_provisioned(&self, _provisioned: bool) {}
412430
fn clear_channels(&self) {}
413431
}
@@ -424,6 +442,10 @@ impl ResolveProvider for LazySessionProvider {
424442
fn resolve_for_chain(&self, chain_id: Option<u64>) -> TransportResult<SessionProvider> {
425443
self.get_or_init(chain_id)
426444
}
445+
fn supports_challenge(&self, chain_id: Option<u64>, currency: Option<&str>) -> bool {
446+
let currency = currency.and_then(|s| s.parse().ok());
447+
discover_mpp_config(DiscoverOptions { chain_id, currency }).is_some()
448+
}
427449
fn set_key_provisioned(&self, provisioned: bool) {
428450
Self::set_key_provisioned(self, provisioned)
429451
}
@@ -744,12 +766,8 @@ mod tests {
744766
let msg = err.to_string();
745767

746768
assert!(
747-
msg.contains("402 Payment Required"),
748-
"expected 402 Payment Required in error, got: {msg}"
749-
);
750-
assert!(
751-
msg.contains("tempo wallet login"),
752-
"expected setup instructions in error, got: {msg}"
769+
msg.contains("no supported MPP challenge"),
770+
"expected 'no supported MPP challenge' in error, got: {msg}"
753771
);
754772

755773
handle.abort();
@@ -796,7 +814,7 @@ mod tests {
796814
"no MPP key found; set TEMPO_PRIVATE_KEY or configure ~/.tempo/wallet/keys.toml",
797815
);
798816

799-
let config = discover_mpp_config_for_chain(None)
817+
let config = discover_mpp_config(Default::default())
800818
.expect("no MPP config found; configure ~/.tempo/wallet/keys.toml");
801819

802820
let signer: mpp::PrivateKeySigner =
@@ -863,47 +881,38 @@ mod tests {
863881
}
864882

865883
#[test]
866-
fn challenge_chain_id_extraction() {
867-
let extract_chain_id = |headers: Vec<&str>| -> Option<u64> {
884+
fn challenge_chain_and_currency_extraction() {
885+
let extract = |headers: Vec<&str>| -> Vec<(Option<u64>, Option<String>)> {
868886
let challenges: Vec<_> =
869887
parse_www_authenticate_all(headers).into_iter().filter_map(|r| r.ok()).collect();
870-
challenges.iter().find_map(|c| {
871-
if c.method.as_str() == "tempo" {
872-
c.request
873-
.decode_value()
874-
.ok()
875-
.and_then(|v| v.get("methodDetails")?.get("chainId")?.as_u64())
876-
} else {
877-
None
878-
}
879-
})
888+
challenges.iter().map(|c| extract_challenge_chain_and_currency(c)).collect()
880889
};
881890

882891
let b64 = |v: serde_json::Value| -> String {
883892
Base64UrlJson::from_value(&v).unwrap().raw().to_string()
884893
};
885894

886-
// Tempo challenge with chainId → extracts it
895+
// Tempo challenge with chainId + currency
887896
let tempo_header = format!(
888897
r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="{}""#,
889898
b64(
890899
serde_json::json!({"amount":"1000","currency":"0x20c0","methodDetails":{"chainId":42431},"recipient":"0xabc"})
891900
)
892901
);
893-
assert_eq!(extract_chain_id(vec![&tempo_header]), Some(42431));
902+
assert_eq!(extract(vec![&tempo_header]), vec![(Some(42431), Some("0x20c0".into()))]);
894903

895-
// Non-tempo challenge → None
904+
// Non-tempo challenge → (None, None)
896905
let stripe_header = format!(
897906
r#"Payment id="xyz", realm="api", method="stripe", intent="charge", request="{}""#,
898907
b64(serde_json::json!({"amount":"100"}))
899908
);
900-
assert_eq!(extract_chain_id(vec![&stripe_header]), None);
909+
assert_eq!(extract(vec![&stripe_header]), vec![(None, None)]);
901910

902-
// Tempo challenge without methodDetails → None
911+
// Tempo challenge without methodDetails → chainId None, currency present
903912
let no_details = format!(
904913
r#"Payment id="def", realm="api", method="tempo", intent="charge", request="{}""#,
905914
b64(serde_json::json!({"amount":"1000","currency":"0x20c0","recipient":"0xabc"}))
906915
);
907-
assert_eq!(extract_chain_id(vec![&no_details]), None);
916+
assert_eq!(extract(vec![&no_details]), vec![(None, Some("0x20c0".into()))]);
908917
}
909918
}

0 commit comments

Comments
 (0)