Skip to content

Commit d7522bc

Browse files
fix(mpp): prevent key_authorization from being included when key is already provisioned
Two fixes for the 'access key already exists' error on MPP charge payments: 1. pay_charge (session.rs): Strip key_authorization from the signing mode when key_provisioned is true. Previously the charge path passed self.signing_mode directly to TempoCharge::sign_with_options, which always included key_authorization from keys.toml. The session path (create_open_tx) already had this guard, but the charge path did not. 2. 402 retry (transport.rs): Only retry with key_authorization when the error specifically indicates the key is not provisioned ('access key does not exist'). Previously any 402 retry unconditionally called mark_key_not_provisioned(), causing the second attempt to include key_authorization even when the first failure was for a different reason (e.g. wrong currency, insufficient balance). Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d4989-0b60-72ef-99a0-2d0d9f0bc62c
1 parent 98560b1 commit d7522bc

File tree

2 files changed

+183
-31
lines changed

2 files changed

+183
-31
lines changed

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

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,8 +344,24 @@ impl SessionProvider {
344344
use mpp::client::tempo::charge::{SignOptions, TempoCharge};
345345

346346
let charge = TempoCharge::from_challenge(challenge)?;
347-
let options =
348-
SignOptions { signing_mode: Some(self.signing_mode.clone()), ..Default::default() };
347+
348+
// Strip key_authorization from the signing mode when the key is already
349+
// provisioned on-chain. Otherwise the payment tx includes a redundant
350+
// key provisioning call that fails with "access key already exists".
351+
let signing_mode = if *self.key_provisioned.lock().unwrap() {
352+
match &self.signing_mode {
353+
TempoSigningMode::Keychain { wallet, version, .. } => TempoSigningMode::Keychain {
354+
wallet: *wallet,
355+
key_authorization: None,
356+
version: *version,
357+
},
358+
other => other.clone(),
359+
}
360+
} else {
361+
self.signing_mode.clone()
362+
};
363+
364+
let options = SignOptions { signing_mode: Some(signing_mode), ..Default::default() };
349365
let signed = charge.sign_with_options(&self.signer, options).await?;
350366
Ok(signed.into_credential())
351367
}
@@ -471,3 +487,122 @@ impl PaymentProvider for SessionProvider {
471487
Ok(build_credential(challenge, payload, chain_id, payer))
472488
}
473489
}
490+
491+
#[cfg(test)]
492+
mod tests {
493+
use super::*;
494+
use mpp::client::tempo::signing::KeychainVersion;
495+
496+
#[test]
497+
fn test_key_provisioned_default_is_true() {
498+
let signer = mpp::PrivateKeySigner::random();
499+
let provider = SessionProvider::new(signer, "https://rpc.example.com".into());
500+
assert!(*provider.key_provisioned.lock().unwrap());
501+
}
502+
503+
#[test]
504+
fn test_set_key_provisioned() {
505+
let signer = mpp::PrivateKeySigner::random();
506+
let provider = SessionProvider::new(signer, "https://rpc.example.com".into());
507+
provider.set_key_provisioned(false);
508+
assert!(!*provider.key_provisioned.lock().unwrap());
509+
provider.set_key_provisioned(true);
510+
assert!(*provider.key_provisioned.lock().unwrap());
511+
}
512+
513+
#[test]
514+
fn test_pay_charge_strips_key_auth_when_provisioned() {
515+
// When key_provisioned is true (default), pay_charge should produce a
516+
// signing mode with key_authorization: None.
517+
let signer = mpp::PrivateKeySigner::random();
518+
let wallet = Address::repeat_byte(0xAA);
519+
let signing_mode = TempoSigningMode::Keychain {
520+
wallet,
521+
key_authorization: Some(Box::new(
522+
// Dummy value — never sent on-chain in this test.
523+
unsafe { std::mem::zeroed() },
524+
)),
525+
version: KeychainVersion::V2,
526+
};
527+
let provider = SessionProvider::new(signer, "https://rpc.example.com".into())
528+
.with_signing_mode(signing_mode);
529+
530+
// Simulate the stripping logic from pay_charge
531+
let result_mode = if *provider.key_provisioned.lock().unwrap() {
532+
match &provider.signing_mode {
533+
TempoSigningMode::Keychain { wallet, version, .. } => TempoSigningMode::Keychain {
534+
wallet: *wallet,
535+
key_authorization: None,
536+
version: *version,
537+
},
538+
other => other.clone(),
539+
}
540+
} else {
541+
provider.signing_mode.clone()
542+
};
543+
544+
assert!(
545+
result_mode.key_authorization().is_none(),
546+
"key_authorization should be stripped when key is provisioned"
547+
);
548+
}
549+
550+
#[test]
551+
fn test_pay_charge_keeps_key_auth_when_not_provisioned() {
552+
let signer = mpp::PrivateKeySigner::random();
553+
let wallet = Address::repeat_byte(0xAA);
554+
let signing_mode = TempoSigningMode::Keychain {
555+
wallet,
556+
key_authorization: Some(Box::new(unsafe { std::mem::zeroed() })),
557+
version: KeychainVersion::V2,
558+
};
559+
let provider = SessionProvider::new(signer, "https://rpc.example.com".into())
560+
.with_signing_mode(signing_mode);
561+
562+
// Mark key as NOT provisioned
563+
provider.set_key_provisioned(false);
564+
565+
let result_mode = if *provider.key_provisioned.lock().unwrap() {
566+
match &provider.signing_mode {
567+
TempoSigningMode::Keychain { wallet, version, .. } => TempoSigningMode::Keychain {
568+
wallet: *wallet,
569+
key_authorization: None,
570+
version: *version,
571+
},
572+
other => other.clone(),
573+
}
574+
} else {
575+
provider.signing_mode.clone()
576+
};
577+
578+
assert!(
579+
result_mode.key_authorization().is_some(),
580+
"key_authorization should be preserved when key is NOT provisioned"
581+
);
582+
}
583+
584+
#[test]
585+
fn test_pay_charge_direct_mode_unaffected() {
586+
let signer = mpp::PrivateKeySigner::random();
587+
let provider = SessionProvider::new(signer, "https://rpc.example.com".into())
588+
.with_signing_mode(TempoSigningMode::Direct);
589+
590+
let result_mode = if *provider.key_provisioned.lock().unwrap() {
591+
match &provider.signing_mode {
592+
TempoSigningMode::Keychain { wallet, version, .. } => TempoSigningMode::Keychain {
593+
wallet: *wallet,
594+
key_authorization: None,
595+
version: *version,
596+
},
597+
other => other.clone(),
598+
}
599+
} else {
600+
provider.signing_mode.clone()
601+
};
602+
603+
assert!(
604+
matches!(result_mode, TempoSigningMode::Direct),
605+
"Direct mode should pass through unchanged"
606+
);
607+
}
608+
}

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

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -271,38 +271,55 @@ where
271271
)));
272272
}
273273

274-
// Retry 402 → try with key_authorization
274+
// Retry 402 → only retry with key_authorization if the error indicates
275+
// the access key is not provisioned on-chain. Unconditionally retrying
276+
// caused "access key already exists" when the 402 was for a different
277+
// reason (e.g. wrong currency, insufficient balance).
275278
if retry_resp.status() == StatusCode::PAYMENT_REQUIRED {
276-
self.provider.mark_key_not_provisioned();
277-
let resolved = self.provider.resolve()?;
279+
let retry_body = retry_resp.bytes().await.map_err(TransportErrorKind::custom)?;
280+
let retry_text = String::from_utf8_lossy(&retry_body);
281+
282+
if retry_text.contains("access key does not exist")
283+
|| retry_text.contains("key is not provisioned")
284+
{
285+
self.provider.mark_key_not_provisioned();
286+
let resolved = self.provider.resolve()?;
287+
288+
if resolved.supports(challenge.method.as_str(), challenge.intent.as_str()) {
289+
debug!(
290+
"MPP 402 indicates key not provisioned, retrying with key_authorization"
291+
);
278292

279-
if resolved.supports(challenge.method.as_str(), challenge.intent.as_str()) {
280-
debug!("first MPP attempt returned 402, retrying with key_authorization");
281-
282-
let credential = resolved.pay(challenge).await.map_err(|e| {
283-
TransportErrorKind::custom(std::io::Error::other(format!(
284-
"MPP payment failed: {e}"
285-
)))
286-
})?;
287-
let auth_header = format_authorization(&credential).map_err(|e| {
288-
TransportErrorKind::custom(std::io::Error::other(format!(
289-
"failed to format MPP credential: {e}"
290-
)))
291-
})?;
292-
293-
let final_resp = self
294-
.client
295-
.post(self.url.clone())
296-
.headers(headers)
297-
.header("content-type", "application/json")
298-
.header(AUTHORIZATION_HEADER, auth_header)
299-
.body(body)
300-
.send()
301-
.await
302-
.map_err(TransportErrorKind::custom)?;
303-
304-
return Self::handle_response(final_resp).await;
293+
let credential = resolved.pay(challenge).await.map_err(|e| {
294+
TransportErrorKind::custom(std::io::Error::other(format!(
295+
"MPP payment failed: {e}"
296+
)))
297+
})?;
298+
let auth_header = format_authorization(&credential).map_err(|e| {
299+
TransportErrorKind::custom(std::io::Error::other(format!(
300+
"failed to format MPP credential: {e}"
301+
)))
302+
})?;
303+
304+
let final_resp = self
305+
.client
306+
.post(self.url.clone())
307+
.headers(headers)
308+
.header("content-type", "application/json")
309+
.header(AUTHORIZATION_HEADER, auth_header)
310+
.body(body)
311+
.send()
312+
.await
313+
.map_err(TransportErrorKind::custom)?;
314+
315+
return Self::handle_response(final_resp).await;
316+
}
305317
}
318+
319+
return Err(TransportErrorKind::http_error(
320+
StatusCode::PAYMENT_REQUIRED.as_u16(),
321+
retry_text.into_owned(),
322+
));
306323
}
307324

308325
Self::handle_response(retry_resp).await

0 commit comments

Comments
 (0)