diff --git a/README.md b/README.md index ff53bcc..4a14ece 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ Additional environment variables: ### JSON Config File +Optional **`chainIndices`** maps EIP-155 `chainId` strings to OKX-style `chainIndex` values for [status webhooks](#webhook-callbacks). When omitted, the decimal `chainId` is sent as `chainIndex`. + ```json { "http_address": "0.0.0.0", @@ -95,6 +97,10 @@ Additional environment variables: "http_cors": "*", "log_level": "info", "relayerPrivateKey": "0x...", + "chainIndices": { + "1": "1", + "8453": "8453" + }, "feeCollector": "0x55f3a93f544e01ce4378d25e927d7c493b863bd6", "feeCollectors": { "1": "0x55f3a93f544e01ce4378d25e927d7c493b863bd6", @@ -461,19 +467,26 @@ Pass a `callbackUrl` inside `context` to receive a `POST` when the transaction s } ``` -The payload POSTed to your URL is the `relayer_getStatus` response with `taskId` at the top level: +The HTTP body is a **JSON array** with one element, matching the OKX Wallet “Submit Intent Status” shape ([Relayer Integration API](https://hackmd.io/@michaelwong/SJVSZ1wcgx)): flattened fields, string timestamps and status codes, and `blockHeight` / `gasUsed` as `0x` hex quantities when a receipt is available. ```json -{ - "taskId": "0x0e670ec6...", - "chainId": "137", - "createdAt": 1741234567, - "status": 200, - "hash": "0x9b7bb827...", - "receipt": { ... } -} +[ + { + "timestamp": "1755914000", + "requestId": "550e8400-e29b-41d4-a716-446655440000", + "taskId": "0x0e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", + "chainIndex": "137", + "status": "200", + "blockHash": "0x6789b0746d84002f2f258129cfd9714d412e78b4d91b8e61608fac9165988baf", + "blockHeight": "0x22a1e6e", + "gasUsed": "0x9cf2", + "txHash": "0xd9b01a72502e7f518fb043bfacd1e13b07f24995f404f8cbb60a1212ca8b4c42" + } +] ``` +For failures, `errorMessage` is set (and optional `errorData`) per the same integration guide. `chainIndex` defaults to the request’s decimal `chainId` unless you configure `chainIndices` in `config.json`. + Callbacks fire on all terminal states: | Fired when | `status` | diff --git a/src/config.rs b/src/config.rs index 46db844..8bf67a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -467,4 +467,18 @@ impl Config { }) .unwrap_or_else(|| "https://api.etherscan.io/v2/api".to_string()) } + + /// OKX / WaaS-style chain index for outbound status webhooks. + /// + /// Reads `{ "chainIndices": { "1": "1", "8453": "8453" } }` from the JSON config when + /// present. Otherwise returns the decimal EIP-155 `chainId` string (many networks share + /// the same numeric value as OKX `chainIndex`; see OKX docs for exceptions). + pub fn chain_index_for_chain_id(&self, chain_id: &str) -> String { + self.get_json_config() + .and_then(|root| root.get("chainIndices")) + .and_then(|m| m.get(chain_id)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| chain_id.to_string()) + } } diff --git a/src/methods/send_tx/shared.rs b/src/methods/send_tx/shared.rs index 9dd9590..d951159 100644 --- a/src/methods/send_tx/shared.rs +++ b/src/methods/send_tx/shared.rs @@ -248,7 +248,7 @@ pub async fn process_single_transaction( message: Some(e), data: None, }; - fire_callback(&relayer_request, &status_resp).await; + fire_callback(&relayer_request, &status_resp, cfg).await; } } } diff --git a/src/provider/fetch.rs b/src/provider/fetch.rs index 01ec171..0df4ee8 100644 --- a/src/provider/fetch.rs +++ b/src/provider/fetch.rs @@ -161,7 +161,7 @@ pub async fn fetch_and_store_receipt( message: None, data: None, }; - fire_callback(req, &status_resp).await; + fire_callback(req, &status_resp, cfg).await; Some(receipt) } else { let msg = "onchain revert".to_string(); @@ -177,7 +177,7 @@ pub async fn fetch_and_store_receipt( message: Some(msg), data: None, }; - fire_callback(req, &status_resp).await; + fire_callback(req, &status_resp, cfg).await; None } } diff --git a/src/rpc.rs b/src/rpc.rs index 3b62da9..5113944 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -399,7 +399,12 @@ impl RpcServer { message: Some(e), data: None, }; - fire_callback(&req, &status_resp).await; + fire_callback( + &req, + &status_resp, + &cfg_bg, + ) + .await; } } } diff --git a/src/utils/callback.rs b/src/utils/callback.rs index 7056a26..5bc7e85 100644 --- a/src/utils/callback.rs +++ b/src/utils/callback.rs @@ -1,28 +1,128 @@ -use crate::{RelayerRequest, SpecStatusResponse}; +//! Status update webhooks for wallet integrations. +//! +//! Outbound `POST` bodies follow the OKX Wallet “Submit Intent Status” shape +//! ([Relayer Integration API](https://hackmd.io/@michaelwong/SJVSZ1wcgx)): a **JSON array** +//! (max 100 items per request in that spec; we send one item per callback) of flattened +//! status objects using `chainIndex`, `requestId`, `txHash`, etc. -/// POST the final status payload to the callback URL registered for a request. -/// -/// The payload mirrors the `relayer_getStatus` response, with `taskId` added at the top level. -/// Failures are logged and silently swallowed — a failed callback never affects the relay flow. -pub async fn fire_callback(req: &RelayerRequest, status: &SpecStatusResponse) { - let url = match &req.callback_url { - Some(u) => u.clone(), - None => return, +use serde::Serialize; +use uuid::Uuid; + +use crate::{config::Config, RelayerRequest, SpecStatusResponse}; + +/// Convert a decimal quantity string (e.g. block number from receipts) to a `0x` hex string +/// as required by the OKX transaction-status webhook for `blockHeight` / `gasUsed`. +fn decimal_string_to_hex_quantity(s: &str) -> Option { + let t = s.trim(); + if t.is_empty() { + return None; + } + if let Some(rest) = t.strip_prefix("0x").or_else(|| t.strip_prefix("0X")) { + if rest.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(format!("0x{}", rest.to_ascii_lowercase())); + } + return None; + } + let n = t.parse::().ok()?; + Some(format!("0x{:x}", n)) +} + +fn error_data_to_string(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => s.clone(), + _ => v.to_string(), + } +} + +/// One row in the OKX `transaction-status` webhook array. +#[derive(Debug, Serialize)] +pub struct OkxTransactionStatusItem { + /// Unix timestamp in seconds when this status push was generated (string). + pub timestamp: String, + /// Unique identifier for this status update delivery. + #[serde(rename = "requestId")] + pub request_id: String, + #[serde(rename = "taskId")] + pub task_id: String, + /// OKX chain index; defaults to EIP-155 `chainId` unless overridden in config. + #[serde(rename = "chainIndex")] + pub chain_index: String, + /// EIP relayer status code as a string (`"100"`, `"110"`, …). + pub status: String, + #[serde(rename = "blockHash", skip_serializing_if = "Option::is_none")] + pub block_hash: Option, + #[serde(rename = "blockHeight", skip_serializing_if = "Option::is_none")] + pub block_height: Option, + #[serde(rename = "gasUsed", skip_serializing_if = "Option::is_none")] + pub gas_used: Option, + #[serde(rename = "txHash", skip_serializing_if = "Option::is_none")] + pub tx_hash: Option, + #[serde(rename = "errorMessage", skip_serializing_if = "Option::is_none")] + pub error_message: Option, + #[serde(rename = "errorData", skip_serializing_if = "Option::is_none")] + pub error_data: Option, +} + +/// Build a single OKX-format status item from internal relayer state. +pub fn build_okx_transaction_status_item( + req: &RelayerRequest, + status: &SpecStatusResponse, + cfg: &Config, +) -> OkxTransactionStatusItem { + let chain_index = cfg.chain_index_for_chain_id(&req.chain_id.to_string()); + let status_str = status.status.to_string(); + + let mut item = OkxTransactionStatusItem { + timestamp: chrono::Utc::now().timestamp().to_string(), + request_id: Uuid::new_v4().to_string(), + task_id: req.task_id.clone(), + chain_index, + status: status_str, + block_hash: None, + block_height: None, + gas_used: None, + tx_hash: None, + error_message: None, + error_data: None, }; - #[derive(serde::Serialize)] - struct CallbackPayload<'a> { - #[serde(rename = "taskId")] - task_id: &'a str, - #[serde(flatten)] - status: &'a SpecStatusResponse, + match status.status { + 110 => { + item.tx_hash = status.hash.clone().or_else(|| req.transaction_hash.clone()); + } + 200 => { + if let Some(ref r) = status.receipt { + item.block_hash = Some(r.block_hash.clone()); + item.block_height = decimal_string_to_hex_quantity(&r.block_number); + item.gas_used = decimal_string_to_hex_quantity(&r.gas_used); + item.tx_hash = Some(r.transaction_hash.clone()); + } else { + item.tx_hash = status.hash.clone().or_else(|| req.transaction_hash.clone()); + } + } + 400 | 500 => { + item.error_message = Some(status.message.clone().unwrap_or_default()); + item.error_data = status.data.as_ref().map(error_data_to_string); + } + _ => {} } - let payload = CallbackPayload { - task_id: &req.task_id, - status, + item +} + +/// POST the status update to the callback URL as a **JSON array** of +/// [`OkxTransactionStatusItem`] (OKX “Submit Intent Status” wire format). +/// +/// Failures are logged and silently swallowed — a failed callback never affects the relay flow. +pub async fn fire_callback(req: &RelayerRequest, status: &SpecStatusResponse, cfg: &Config) { + let url = match &req.callback_url { + Some(u) if !u.is_empty() => u.clone(), + _ => return, }; + let item = build_okx_transaction_status_item(req, status, cfg); + let payload = vec![item]; + match reqwest::Client::new() .post(&url) .json(&payload) @@ -47,3 +147,121 @@ pub async fn fire_callback(req: &RelayerRequest, status: &SpecStatusResponse) { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{RequestStatus, SpecReceipt}; + use chrono::Utc; + use uuid::Uuid; + + fn minimal_cfg() -> Config { + Config { + rpc_host: "127.0.0.1".to_string(), + rpc_port: 8545, + db_path: std::path::PathBuf::from("./relayx_db_cb_test"), + relayers: String::new(), + max_concurrent_requests: 10, + request_timeout: 30, + config_path: None, + http_address: "127.0.0.1".to_string(), + http_port: 4937, + http_cors: "*".to_string(), + log_level: "error".to_string(), + relayer_private_key: None, + disable_simulation: false, + sentry_dsn: None, + disable_multichain: false, + } + } + + fn sample_req() -> RelayerRequest { + RelayerRequest { + id: Uuid::new_v4(), + task_id: "0x0e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331" + .to_string(), + from_address: "0x1".to_string(), + to_address: "0x2".to_string(), + amount: "0".to_string(), + gas_limit: 21000, + gas_price: "0x1".to_string(), + data: None, + nonce: 0, + chain_id: 1, + transaction_hash: Some( + "0xd9b01a72502e7f518fb043bfacd1e13b07f24995f404f8cbb60a1212ca8b4c42".to_string(), + ), + status: RequestStatus::Completed, + created_at: Utc::now(), + updated_at: Utc::now(), + error_message: None, + callback_url: Some("https://example.invalid/webhook".to_string()), + } + } + + #[test] + fn okx_payload_is_array_with_expected_keys_for_status_200() { + let cfg = minimal_cfg(); + let req = sample_req(); + let status = SpecStatusResponse { + chain_id: "1".to_string(), + created_at: 1755917874, + status: 200, + hash: None, + receipt: Some(SpecReceipt { + block_hash: "0x6789b0746d84002f2f258129cfd9714d412e78b4d91b8e61608fac9165988baf" + .to_string(), + block_number: "36314734".to_string(), + gas_used: "40178".to_string(), + logs: None, + transaction_hash: + "0xd9b01a72502e7f518fb043bfacd1e13b07f24995f404f8cbb60a1212ca8b4c42".to_string(), + }), + message: None, + data: None, + }; + + let item = build_okx_transaction_status_item(&req, &status, &cfg); + let json = serde_json::to_value(vec![item]).unwrap(); + assert!(json.is_array()); + let row = &json[0]; + assert_eq!(row["chainIndex"], "1"); + assert_eq!(row["status"], "200"); + assert_eq!( + row["taskId"], + "0x0e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331" + ); + assert_eq!( + row["blockHash"], + "0x6789b0746d84002f2f258129cfd9714d412e78b4d91b8e61608fac9165988baf" + ); + assert_eq!(row["blockHeight"], "0x22a1e6e"); + assert_eq!(row["gasUsed"], "0x9cf2"); + assert_eq!( + row["txHash"], + "0xd9b01a72502e7f518fb043bfacd1e13b07f24995f404f8cbb60a1212ca8b4c42" + ); + assert!(row.get("errorMessage").is_none()); + } + + #[test] + fn okx_payload_for_status_400_includes_error_fields() { + let cfg = minimal_cfg(); + let req = sample_req(); + let status = SpecStatusResponse { + chain_id: "1".to_string(), + created_at: 1, + status: 400, + hash: None, + receipt: None, + message: Some("Insufficient payment".to_string()), + data: Some(serde_json::json!("0x")), + }; + + let item = build_okx_transaction_status_item(&req, &status, &cfg); + let v = serde_json::to_value(&item).unwrap(); + assert_eq!(v["status"], "400"); + assert_eq!(v["errorMessage"], "Insufficient payment"); + assert_eq!(v["errorData"], "0x"); + } +}