Skip to content

Commit f445e1a

Browse files
clawdbot-glitch003glitch003claude
authored
fix(billing): decode contract reverts + treat reverted tx as failure (#481)
Two independent correctness bugs surfaced while debugging k6 smoke 500/402 failures on staging: 1. `get_billing_wallet_address` / `get_account_wallet_address` propagated the raw alloy error from `.call()` without running it through `decode_contract_revert`. The resulting string is just the 4-byte selector, so `billing::wallet_resolution_err`'s substring match on "AccountDoesNotExist" never fired — a missing account was returned as a 500 ("internal billing lookup error") instead of the intended 400. Now both helpers decode the revert, so a missing account maps to 400 and the error string is human-readable in logs. 2. `send_transaction` treated any returned receipt as success. A transaction that reverts on-chain still yields a receipt (EIP-658 status 0), so a reverted write (e.g. `newAccount`) was reported as succeeded. Added `receipt_to_result`, which inspects the status and surfaces an on-chain revert as an error, applied at both receipt sites. Adds regression tests for the selector→name mapping and the wallet-resolution status mapping (400 vs 500). Co-authored-by: Chris Cassano <chris@litprotocol.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a1232fc commit f445e1a

4 files changed

Lines changed: 99 additions & 4 deletions

File tree

lit-api-server/src/accounts/decode_revert.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,29 @@ pub fn decode_contract_revert(err: &alloy::contract::Error) -> String {
9797

9898
format!("{err}")
9999
}
100+
101+
#[cfg(test)]
102+
mod tests {
103+
use super::*;
104+
use alloy::sol_types::SolError;
105+
106+
use crate::accounts::contracts::account_config_contract::AccountConfig;
107+
108+
/// The `AccountDoesNotExist` selector must resolve to its name. Billing's
109+
/// `wallet_resolution_err` substring-matches on this name to return a 400
110+
/// (account not found) rather than a 500, so a selector/name drift here
111+
/// would silently turn missing-account lookups back into 500s.
112+
#[test]
113+
fn account_does_not_exist_selector_resolves_to_name() {
114+
let selector = AccountConfig::AccountDoesNotExist::SELECTOR;
115+
assert_eq!(
116+
account_config_error_name(&selector),
117+
Some("AccountDoesNotExist")
118+
);
119+
}
120+
121+
#[test]
122+
fn unknown_selector_resolves_to_none() {
123+
assert_eq!(account_config_error_name(&[0x00, 0x00, 0x00, 0x00]), None);
124+
}
125+
}

lit-api-server/src/accounts/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,15 @@ pub async fn can_execute_action_and_use_wallet(
768768
pub async fn get_account_wallet_address(key_or_hash: &str) -> Result<String> {
769769
let contract = get_read_only_account_config_contract().await?;
770770
let key_hash = usage_api_key_to_hash(key_or_hash);
771-
let wallet_address = contract.getAccountWalletAddress(key_hash).call().await?;
771+
// Decode contract reverts (e.g. `AccountDoesNotExist`) into a human-readable
772+
// string. Without this the raw alloy error is just the 4-byte selector, which
773+
// callers like `billing::wallet_resolution_err` substring-match on to map to
774+
// the right HTTP status (a missing account is a 400, not a 500).
775+
let wallet_address = contract
776+
.getAccountWalletAddress(key_hash)
777+
.call()
778+
.await
779+
.map_err(|e| anyhow::anyhow!("{}", decode_contract_revert(&e)))?;
772780
if wallet_address == Address::ZERO {
773781
anyhow::bail!("account has no wallet address");
774782
}
@@ -793,7 +801,15 @@ pub async fn get_account_wallet_address(key_or_hash: &str) -> Result<String> {
793801
pub async fn get_billing_wallet_address(key_or_hash: &str) -> Result<String> {
794802
let contract = get_read_only_account_config_contract().await?;
795803
let key_hash = usage_api_key_to_hash(key_or_hash);
796-
let wallet_address = contract.getBillingWalletAddress(key_hash).call().await?;
804+
// Decode contract reverts (e.g. `AccountDoesNotExist`) into a human-readable
805+
// string so `billing::wallet_resolution_err` can map a missing account to a
806+
// 400 instead of a confusing 500. A raw alloy revert is just the 4-byte
807+
// selector, which the substring match never catches.
808+
let wallet_address = contract
809+
.getBillingWalletAddress(key_hash)
810+
.call()
811+
.await
812+
.map_err(|e| anyhow::anyhow!("{}", decode_contract_revert(&e)))?;
797813
if wallet_address == Address::ZERO {
798814
anyhow::bail!("account has no wallet address");
799815
}

lit-api-server/src/accounts/signable_contract.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,25 @@ pub(crate) async fn get_read_only_account_config_contract() -> Result<AccountCon
116116
Ok(contract)
117117
}
118118

119+
/// Convert a mined transaction receipt into a success/failure result.
120+
///
121+
/// `get_receipt()` resolves successfully even when the transaction reverted
122+
/// on-chain — the receipt simply carries an EIP-658 status of 0. Treating that
123+
/// as success would report a failed write (e.g. a reverted `newAccount`) as
124+
/// having succeeded, leaving callers to act on state that was never persisted.
125+
/// We inspect the status explicitly and surface a revert as an error.
126+
fn receipt_to_result(receipt: alloy::rpc::types::TransactionReceipt) -> Result<bool> {
127+
if receipt.status() {
128+
Ok(true)
129+
} else {
130+
Err(anyhow::anyhow!(
131+
"transaction reverted on-chain (tx hash: {:#x}, block: {:?})",
132+
receipt.transaction_hash,
133+
receipt.block_number
134+
))
135+
}
136+
}
137+
119138
pub async fn send_transaction<D>(
120139
function_call: CallBuilder<&SigningClient, D, Ethereum>,
121140
signer_pool: std::sync::Arc<SignerPool>,
@@ -139,7 +158,7 @@ where
139158
let first_err = match function_call.send().await {
140159
Ok(tx) => {
141160
let result = match tx.get_receipt().await {
142-
Ok(_) => Ok(true),
161+
Ok(receipt) => receipt_to_result(receipt),
143162
Err(e) => Err(anyhow::Error::from(e)),
144163
};
145164
signer_pool.release(signer_address).await?;
@@ -205,7 +224,7 @@ where
205224
};
206225

207226
let result = match tx.get_receipt().await {
208-
Ok(_) => Ok(true),
227+
Ok(receipt) => receipt_to_result(receipt),
209228
Err(e) => Err(e.into()),
210229
};
211230

lit-api-server/src/core/v1/endpoints/billing.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,37 @@ async fn billing_confirm_payment_impl(
162162
.map_err(|e| ApiStatus::internal_server_error(e, "Stripe error"))?;
163163
Ok(AccountOpResponse { success: true })
164164
}
165+
166+
#[cfg(test)]
167+
mod tests {
168+
use super::*;
169+
use rocket::http::Status;
170+
171+
/// A missing account must map to 400, not 500. The wallet-lookup helpers
172+
/// run the contract revert through `decode_contract_revert`, so the error
173+
/// string contains the `AccountDoesNotExist` error name — this asserts the
174+
/// substring match still routes it to a client error.
175+
#[test]
176+
fn account_does_not_exist_maps_to_400() {
177+
let err = anyhow::anyhow!("Contract error: AccountDoesNotExist (0xd4a84737...)");
178+
assert_eq!(wallet_resolution_err(err).status, Status::BadRequest);
179+
}
180+
181+
#[test]
182+
fn missing_wallet_address_maps_to_400() {
183+
let err = anyhow::anyhow!("account has no wallet address");
184+
assert_eq!(wallet_resolution_err(err).status, Status::BadRequest);
185+
}
186+
187+
/// RPC/transport failures (anything that isn't a known missing-account
188+
/// revert) stay 500 so transient infra problems aren't reported to clients
189+
/// as a bad API key.
190+
#[test]
191+
fn other_errors_map_to_500() {
192+
let err = anyhow::anyhow!("error sending request: connection refused");
193+
assert_eq!(
194+
wallet_resolution_err(err).status,
195+
Status::InternalServerError
196+
);
197+
}
198+
}

0 commit comments

Comments
 (0)