Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/fix-zero-amount-charge-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
tempo-request: patch
---

Preserve both the MPP client attribution ID and the configured signing mode when handling zero-amount charge payments.
8 changes: 8 additions & 0 deletions crates/tempo-request/src/payment/charge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ use super::{
};
use tempo_common::payment::{classify_payment_error, map_mpp_validation_error};

/// Client identifier for MPP attribution memos, written into the on-chain
/// transaction data so charge payments can be attributed to the Tempo CLI.
const CLIENT_ID: &str = "tempo-wallet";

/// Whether a post-submission HTTP status code warrants a provisioning retry.
///
/// Only auth/payment codes (401–403) and server errors (5xx) are retried.
Expand Down Expand Up @@ -79,6 +83,7 @@ pub(super) async fn handle_charge_request(
provider: "tempo payment provider",
source: Box::new(source),
})?
.with_client_id(CLIENT_ID)
.with_signing_mode(signer.signing_mode.clone());

let credential = match provider.pay(challenge).await {
Expand All @@ -103,6 +108,7 @@ pub(super) async fn handle_charge_request(
provider: "tempo payment provider (provisioning retry)",
source: Box::new(source),
})?
.with_client_id(CLIENT_ID)
.with_signing_mode(provisioning_signer.signing_mode);
retry_provider
.pay(challenge)
Expand Down Expand Up @@ -145,6 +151,7 @@ pub(super) async fn handle_charge_request(
provider: "tempo payment provider (provisioning retry)",
source: Box::new(source),
})?
.with_client_id(CLIENT_ID)
.with_signing_mode(provisioning_signer.signing_mode);
let original_resp_rejection = parse_payment_rejection(&resp);
let retry_credential = retry_provider
Expand Down Expand Up @@ -217,6 +224,7 @@ async fn handle_zero_amount_charge(
provider: "tempo payment provider",
source: Box::new(source),
})?
.with_client_id(CLIENT_ID)
.with_signing_mode(signer.signing_mode.clone());

let credential = provider
Expand Down
49 changes: 49 additions & 0 deletions crates/tempo-request/tests/query/payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,55 @@ async fn status_402_charge_flow_keychain() {
);
}

// ==================== Client ID Attribution ====================

/// Charge flow sends an Authorization credential whose embedded transaction
/// contains the "tempo-wallet" client fingerprint in the MPP attribution memo.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn status_402_charge_flow_sends_client_id_in_credential() {
let h = PaymentTestHarness::charge_with_body("client id ok").await;

let output = test_command(&h.temp)
.args([&h.url("/api")])
.output()
.unwrap();

assert_success(&output, "charge flow with client id should succeed");

let auth_headers = h.server.captured_auth_headers();
assert!(
!auth_headers.is_empty(),
"expected at least one Authorization header from the paid request"
);

// The credential contains the signed transaction which includes the
// keccak256("tempo-wallet")[0..10] fingerprint in the attribution memo.
let client_fingerprint = {
let hash = alloy::primitives::keccak256(b"tempo-wallet");
hex::encode(&hash[..10])
};
// The credential is "Payment <base64-json>" where the JSON contains
// hex-encoded transaction bytes with the attribution memo inside.
let header = &auth_headers[0];
let payload_b64 = header.strip_prefix("Payment ").unwrap_or(header);
let decoded = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD_NO_PAD,
payload_b64,
)
.or_else(|_| {
base64::Engine::decode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
payload_b64,
)
})
.expect("credential should be valid base64");
let decoded_str = String::from_utf8_lossy(&decoded);
assert!(
decoded_str.contains(&client_fingerprint),
"decoded credential should contain tempo-wallet client fingerprint ({client_fingerprint})"
);
}

// ==================== --private-key Flag ====================

/// The 402 charge flow works with --private-key (no keys.toml needed).
Expand Down
24 changes: 23 additions & 1 deletion crates/tempo-test/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub struct MockServer {
_handle: tokio::task::JoinHandle<()>,
/// Deferred www-authenticate header (set after server binds).
www_auth_tx: Option<std::sync::Arc<tokio::sync::watch::Sender<String>>>,
/// Authorization header values captured from paid requests.
captured_auth: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}

impl MockServer {
Expand Down Expand Up @@ -70,6 +72,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: None,
captured_auth: Default::default(),
}
}

Expand Down Expand Up @@ -123,6 +126,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: None,
captured_auth: Default::default(),
}
}

Expand Down Expand Up @@ -186,6 +190,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: None,
captured_auth: Default::default(),
}
}

Expand All @@ -207,14 +212,20 @@ impl MockServer {
let (watch_tx, watch_rx) = tokio::sync::watch::channel(String::new());
let watch_tx = std::sync::Arc::new(watch_tx);
let owned_body = success_body.to_string();
let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let cap = captured.clone();

let app = Router::new().route(
"/{*path}",
any(move |headers: axum::http::HeaderMap| {
let rx = watch_rx.clone();
let b = owned_body.clone();
let cap = cap.clone();
async move {
if headers.get("authorization").is_some() {
if let Some(auth) = headers.get("authorization") {
if let Ok(v) = auth.to_str() {
cap.lock().unwrap().push(v.to_string());
}
(StatusCode::OK, b).into_response()
} else {
let h = rx.borrow().clone();
Expand Down Expand Up @@ -245,6 +256,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: Some(watch_tx),
captured_auth: captured,
}
}

Expand Down Expand Up @@ -304,6 +316,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: Some(watch_tx),
captured_auth: Default::default(),
}
}

Expand Down Expand Up @@ -364,6 +377,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: Some(watch_tx),
captured_auth: Default::default(),
}
}

Expand Down Expand Up @@ -409,6 +423,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: None,
captured_auth: Default::default(),
}
}

Expand Down Expand Up @@ -470,6 +485,7 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: None,
captured_auth: Default::default(),
}
}

Expand Down Expand Up @@ -515,9 +531,15 @@ impl MockServer {
shutdown_tx: Some(shutdown_tx),
_handle: handle,
www_auth_tx: None,
captured_auth: Default::default(),
}
}

/// Returns Authorization header values captured from paid requests.
pub fn captured_auth_headers(&self) -> Vec<String> {
self.captured_auth.lock().unwrap().clone()
}

/// Get the full URL for a path on this server.
#[must_use]
pub fn url(&self, path: &str) -> String {
Expand Down
Loading