use std::sync::Arc;
use std::time::Duration;
use axum::{extract::State, routing::post, Json, Router};
use serde_json::{json, Value};
use tokio::net::TcpListener;
async fn spawn_mock_upstream() -> (String, Arc<tokio::sync::Mutex<Vec<Value>>>) {
let seen = Arc::new(tokio::sync::Mutex::new(Vec::<Value>::new()));
let seen_for_handler = Arc::clone(&seen);
let app = Router::new()
.route(
"/",
post(
|State(seen): State<Arc<tokio::sync::Mutex<Vec<Value>>>>,
Json(body): Json<Value>| async move {
seen.lock().await.push(body.clone());
let id = body.get("id").cloned().unwrap_or(Value::Null);
Json(json!({
"jsonrpc": "2.0",
"id": id,
"result": "upstream-saw-you"
}))
},
),
)
.with_state(seen_for_handler);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}"), seen)
}
async fn spawn_mock_upstream_by_method(
responses: std::collections::HashMap<&'static str, Value>,
) -> String {
let responses = Arc::new(responses);
let app = Router::new()
.route(
"/",
post(
move |State(r): State<Arc<std::collections::HashMap<&'static str, Value>>>,
Json(body): Json<Value>| {
let r = Arc::clone(&r);
async move {
let id = body.get("id").cloned().unwrap_or(Value::Null);
let method = body.get("method").and_then(Value::as_str).unwrap_or("");
let result = r.get(method).cloned().unwrap_or(Value::Null);
Json(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
}
},
),
)
.with_state(responses);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
async fn spawn_tidepool(upstream_url: String) -> String {
spawn_tidepool_with_state(upstream_url, None).await
}
async fn spawn_tidepool_with_state(upstream_url: String, db: Option<std::path::PathBuf>) -> String {
use tidepool_server::{run, ServerConfig};
let (port, ws_port) = pick_two_free_ports().await;
let config = ServerConfig {
port,
ws_port: Some(ws_port),
upstream_url,
upstream_ws_url: "ws://127.0.0.1:1".into(),
rpc_timeout: Duration::from_secs(5),
index_trees: vec![],
db,
snapshots: vec![],
offchain_metadata: false,
};
tokio::spawn(async move {
run(config).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(120)).await;
format!("http://127.0.0.1:{port}")
}
async fn pick_two_free_ports() -> (u16, u16) {
let a = TcpListener::bind("127.0.0.1:0").await.unwrap();
let b = TcpListener::bind("127.0.0.1:0").await.unwrap();
let ap = a.local_addr().unwrap().port();
let bp = b.local_addr().unwrap().port();
drop(a);
drop(b);
(ap, bp)
}
#[tokio::test]
async fn tidepool_info_native_dispatch() {
let (upstream_url, _seen) = spawn_mock_upstream().await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let client = reqwest::Client::new();
let resp: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tidepool_info",
"params": {}
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["id"], 1);
assert_eq!(resp["result"]["name"], "tidepool");
let methods = resp["result"]["methods"].as_array().expect("methods array");
assert!(methods.iter().any(|m| m["method"] == "getAsset"));
assert!(methods.iter().any(|m| m["method"] == "tidepool_indexTree"));
let transports: std::collections::HashSet<_> = methods
.iter()
.filter_map(|m| m["transport"].as_str())
.collect();
for expected in ["json_rpc", "rest", "ws", "sdk_wrapper"] {
assert!(
transports.contains(expected),
"tidepool_info should surface `{expected}` transport; got {transports:?}"
);
}
let create_webhook = methods
.iter()
.find(|m| m["method"] == "createWebhook")
.expect("createWebhook in manifest");
assert_eq!(create_webhook["transport"], "rest");
}
#[tokio::test]
async fn unknown_method_is_passed_through_to_upstream() {
let (upstream_url, seen) = spawn_mock_upstream().await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let client = reqwest::Client::new();
let resp: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 7,
"method": "getSlot",
"params": []
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["result"], "upstream-saw-you");
assert_eq!(resp["id"], 7);
let seen = seen.lock().await;
assert_eq!(seen.len(), 1);
assert_eq!(seen[0]["method"], "getSlot");
}
#[tokio::test]
async fn get_asset_proof_with_missing_tree_reports_not_found() {
let (upstream_url, _seen) = spawn_mock_upstream().await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let client = reqwest::Client::new();
let resp: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 3,
"method": "getAssetProof",
"params": { "id": "11111111111111111111111111111111" }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(resp["error"].is_object(), "should be a JSON-RPC error");
assert_eq!(resp["error"]["code"], -32000);
assert!(resp["error"]["message"]
.as_str()
.unwrap()
.contains("not found"));
}
#[tokio::test]
async fn get_priority_fee_estimate_single_level() {
let samples: Vec<Value> = (1..=10)
.map(|i| json!({ "slot": 100 + i, "prioritizationFee": i * 1000 }))
.collect();
let mut responses = std::collections::HashMap::new();
responses.insert("getRecentPrioritizationFees", Value::Array(samples));
let upstream_url = spawn_mock_upstream_by_method(responses).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 11,
"method": "getPriorityFeeEstimate",
"params": [{ "options": { "priorityLevel": "medium" } }]
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["id"], 11);
assert_eq!(resp["result"]["priorityFeeEstimate"], 6000);
}
#[tokio::test]
async fn get_priority_fee_estimate_all_levels() {
let samples: Vec<Value> = (1..=10)
.map(|i| json!({ "slot": 100 + i, "prioritizationFee": i * 1000 }))
.collect();
let mut responses = std::collections::HashMap::new();
responses.insert("getRecentPrioritizationFees", Value::Array(samples));
let upstream_url = spawn_mock_upstream_by_method(responses).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 12,
"method": "getPriorityFeeEstimate",
"params": [{ "options": { "includeAllPriorityFeeLevels": true } }]
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let levels = &resp["result"]["priorityFeeLevels"];
#[allow(clippy::float_cmp)]
{
assert_eq!(levels["min"].as_f64().unwrap(), 1000.0);
assert_eq!(levels["unsafeMax"].as_f64().unwrap(), 10000.0);
}
assert!(levels["low"].as_f64().unwrap() <= levels["medium"].as_f64().unwrap());
assert!(levels["medium"].as_f64().unwrap() <= levels["high"].as_f64().unwrap());
assert!(levels["high"].as_f64().unwrap() <= levels["veryHigh"].as_f64().unwrap());
}
#[tokio::test]
async fn get_priority_fee_estimate_empty_upstream_returns_zero() {
let mut responses = std::collections::HashMap::new();
responses.insert("getRecentPrioritizationFees", Value::Array(vec![]));
let upstream_url = spawn_mock_upstream_by_method(responses).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 13,
"method": "getPriorityFeeEstimate",
"params": [{}]
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["result"]["priorityFeeEstimate"], 0);
}
#[tokio::test]
async fn get_program_accounts_v2_paginates_via_cursor() {
let mut responses = std::collections::HashMap::new();
responses.insert(
"getProgramAccounts",
json!([
{ "pubkey": "CCC", "account": { "data": ["", "base64"], "owner": "ProgID" } },
{ "pubkey": "AAA", "account": { "data": ["", "base64"], "owner": "ProgID" } },
{ "pubkey": "DDD", "account": { "data": ["", "base64"], "owner": "ProgID" } },
{ "pubkey": "BBB", "account": { "data": ["", "base64"], "owner": "ProgID" } },
]),
);
let upstream_url = spawn_mock_upstream_by_method(responses).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let client = reqwest::Client::new();
let page1: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getProgramAccountsV2",
"params": { "programId": "ProgID", "limit": 2 }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(page1["result"]["items"][0]["pubkey"], "AAA");
assert_eq!(page1["result"]["items"][1]["pubkey"], "BBB");
assert_eq!(page1["result"]["cursor"], "BBB");
let page2: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "getProgramAccountsV2",
"params": { "programId": "ProgID", "limit": 2, "cursor": "BBB" }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(page2["result"]["items"][0]["pubkey"], "CCC");
assert_eq!(page2["result"]["items"][1]["pubkey"], "DDD");
assert!(page2["result"].get("cursor").is_none());
}
#[tokio::test]
async fn get_token_accounts_by_owner_v2_requires_mint_or_program_id() {
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenAccountsByOwnerV2",
"params": { "owner": "OWNER" }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(resp["error"]["code"], -32602);
}
#[tokio::test]
async fn get_token_accounts_by_owner_v2_slices_via_cursor() {
let mut responses = std::collections::HashMap::new();
responses.insert(
"getTokenAccountsByOwner",
json!({
"context": { "slot": 1 },
"value": [
{ "pubkey": "TB", "account": { "data": ["", "base64"], "owner": "TOKEN" } },
{ "pubkey": "TA", "account": { "data": ["", "base64"], "owner": "TOKEN" } },
{ "pubkey": "TC", "account": { "data": ["", "base64"], "owner": "TOKEN" } },
]
}),
);
let upstream_url = spawn_mock_upstream_by_method(responses).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let got: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenAccountsByOwnerV2",
"params": { "owner": "OWNER", "mint": "MINT", "limit": 1 }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(got["result"]["items"].as_array().unwrap().len(), 1);
assert_eq!(got["result"]["items"][0]["pubkey"], "TA");
assert_eq!(got["result"]["cursor"], "TA");
}
#[tokio::test]
async fn webhook_crud_round_trip() {
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let client = reqwest::Client::new();
let create: Value = client
.post(format!("{tidepool_url}/v0/webhooks"))
.json(&json!({
"webhookURL": "https://example.com/hook",
"accountAddresses": ["ADDR_A"]
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let id = create["webhookID"].as_str().unwrap().to_string();
assert!(id.starts_with("wh_"));
assert_eq!(create["webhookURL"], "https://example.com/hook");
let fetched: Value = client
.get(format!("{tidepool_url}/v0/webhooks/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(fetched["webhookID"], id);
let all: Value = client
.get(format!("{tidepool_url}/v0/webhooks"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(all.as_array().unwrap().len(), 1);
let edited: Value = client
.put(format!("{tidepool_url}/v0/webhooks/{id}"))
.json(&json!({ "webhookURL": "https://example.com/v2" }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(edited["webhookURL"], "https://example.com/v2");
let deleted: Value = client
.delete(format!("{tidepool_url}/v0/webhooks/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(deleted["deleted"], true);
let missing: Value = client
.get(format!("{tidepool_url}/v0/webhooks/{id}"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(missing.is_null());
}
#[tokio::test]
async fn create_webhook_rejects_missing_url() {
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp = reqwest::Client::new()
.post(format!("{tidepool_url}/v0/webhooks"))
.json(&json!({ "accountAddresses": ["ADDR"] }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["code"], -32602);
}
#[tokio::test]
async fn get_transactions_returns_enhanced_envelope() {
let mut responses = std::collections::HashMap::new();
responses.insert(
"getTransaction",
json!({
"slot": 200,
"blockTime": 1_700_000_100,
"transaction": {
"message": {
"accountKeys": ["WALLET_A", "WALLET_B", "11111111111111111111111111111111"],
"instructions": [
{ "programIdIndex": 2, "accounts": [0, 1], "data": "3Bxs" }
]
}
},
"meta": {
"fee": 5000,
"err": null,
"preBalances": [1_000_000, 0, 1],
"postBalances": [494_000, 500_000, 1],
"innerInstructions": []
}
}),
);
let upstream_url = spawn_mock_upstream_by_method(responses).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp: Value = reqwest::Client::new()
.post(format!("{tidepool_url}/v0/transactions"))
.json(&json!({ "transactions": ["SIG1"] }))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let items = resp.as_array().expect("array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["signature"], "SIG1");
assert_eq!(items[0]["type"], "TRANSFER");
assert_eq!(items[0]["source"], "SYSTEM_PROGRAM");
assert_eq!(items[0]["feePayer"], "WALLET_A");
assert_eq!(items[0]["nativeTransfers"][0]["amount"], 500_000);
}
#[tokio::test]
async fn get_transactions_rejects_empty_signatures() {
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp = reqwest::Client::new()
.post(format!("{tidepool_url}/v0/transactions"))
.json(&json!({ "transactions": [] }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 400);
let body: Value = resp.json().await.unwrap();
assert_eq!(body["code"], -32602);
}
#[tokio::test]
async fn webhook_registry_persists_across_restart_with_db_flag() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let db_path = tmp.path().to_path_buf();
drop(tmp);
let upstream_url1 = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url1 = spawn_tidepool_with_state(upstream_url1, Some(db_path.clone())).await;
let client = reqwest::Client::new();
let created: Value = client
.post(format!("{tidepool_url1}/v0/webhooks"))
.json(&json!({
"webhookURL": "https://persisted.example.com",
"accountAddresses": ["PERSIST_ADDR"]
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let webhook_id = created["webhookID"].as_str().unwrap().to_string();
assert!(!webhook_id.is_empty());
let upstream_url2 = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url2 = spawn_tidepool_with_state(upstream_url2, Some(db_path.clone())).await;
let listed: Value = client
.get(format!("{tidepool_url2}/v0/webhooks"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let items = listed.as_array().expect("array");
assert!(
items.iter().any(|w| w["webhookID"] == webhook_id),
"webhook should survive across restarts when --db is set"
);
}
#[tokio::test]
async fn tidepool_tree_snapshot_export_and_load_round_trip() {
use tidepool_core::Creator;
use tidepool_rpc::cnft::snapshot::dump_tree;
use tidepool_rpc::cnft::SnapshotBlob;
use tidepool_rpc::cnft::{
apply::derive_asset_id, apply_event, CnftEvent, MemoryCnftStore, MintMetadata,
};
let tree: [u8; 32] = [0x33; 32];
let src = MemoryCnftStore::new();
apply_event(
&src,
CnftEvent::CreateTree {
tree,
depth: 10,
max_buffer_size: 32,
},
)
.await
.unwrap();
apply_event(
&src,
CnftEvent::Mint {
tree,
owner: [0x01; 32],
delegate: [0x02; 32],
metadata: MintMetadata {
name: "Snap Asset".into(),
symbol: "SNAP".into(),
uri: "https://example.com/s.json".into(),
seller_fee_basis_points: 100,
primary_sale_happened: false,
is_mutable: true,
creators: vec![Creator {
address: [0x44; 32],
verified: true,
share: 100,
}],
collection: None,
data_hash_input: vec![0xab; 16],
},
verify_collection: None,
noop: None,
},
)
.await
.unwrap();
let snapshot = dump_tree(&src, &tree).await.unwrap().expect("Some");
let blob = SnapshotBlob::from_tree(&snapshot);
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let client = reqwest::Client::new();
let load: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tidepool_loadTreeSnapshot",
"params": { "snapshot": blob }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(load["result"]["leafCount"], 1);
let asset_id = derive_asset_id(&tree, 0);
let asset_id_b58 = bs58::encode(asset_id).into_string();
let proof: Value = client
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "getAssetProof",
"params": { "id": asset_id_b58 }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(proof["result"]["tree_id"], bs58::encode(tree).into_string());
assert_eq!(proof["result"]["node_index"].as_u64().unwrap(), 1u64 << 10);
}
#[tokio::test]
async fn tidepool_export_tree_snapshot_returns_null_for_unknown() {
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tidepool_exportTreeSnapshot",
"params": { "tree": "11111111111111111111111111111111" }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(resp["result"].is_null());
}
#[tokio::test]
async fn snapshot_flag_preloads_tree_before_serving() {
use tidepool_core::Creator;
use tidepool_rpc::cnft::snapshot::dump_tree;
use tidepool_rpc::cnft::{
apply::derive_asset_id, apply_event, CnftEvent, MemoryCnftStore, MintMetadata, SnapshotBlob,
};
use tidepool_server::{run, ServerConfig};
let tree: [u8; 32] = [0x55; 32];
let seed = MemoryCnftStore::new();
apply_event(
&seed,
CnftEvent::CreateTree {
tree,
depth: 10,
max_buffer_size: 32,
},
)
.await
.unwrap();
apply_event(
&seed,
CnftEvent::Mint {
tree,
owner: [0x01; 32],
delegate: [0x02; 32],
metadata: MintMetadata {
name: "Preloaded".into(),
symbol: "PRE".into(),
uri: "https://example.com/pre.json".into(),
seller_fee_basis_points: 100,
primary_sale_happened: false,
is_mutable: true,
creators: vec![Creator {
address: [0x44; 32],
verified: true,
share: 100,
}],
collection: None,
data_hash_input: vec![0xab; 16],
},
verify_collection: None,
noop: None,
},
)
.await
.unwrap();
let snapshot = dump_tree(&seed, &tree).await.unwrap().expect("Some");
let blob = SnapshotBlob::from_tree(&snapshot);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), serde_json::to_vec(&blob).unwrap()).unwrap();
let upstream_url = spawn_mock_upstream_by_method(std::collections::HashMap::new()).await;
let (port, ws_port) = pick_two_free_ports().await;
let config = ServerConfig {
port,
ws_port: Some(ws_port),
upstream_url,
upstream_ws_url: "ws://127.0.0.1:1".into(),
rpc_timeout: Duration::from_secs(5),
index_trees: vec![],
db: None,
snapshots: vec![tmp.path().to_path_buf()],
offchain_metadata: false,
};
tokio::spawn(async move {
run(config).await.unwrap();
});
tokio::time::sleep(Duration::from_millis(200)).await;
let asset_id = derive_asset_id(&tree, 0);
let asset_id_b58 = bs58::encode(asset_id).into_string();
let tidepool_url = format!("http://127.0.0.1:{port}");
let proof: Value = reqwest::Client::new()
.post(&tidepool_url)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getAssetProof",
"params": { "id": asset_id_b58 }
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(proof["result"]["tree_id"], bs58::encode(tree).into_string());
}
#[tokio::test]
async fn cors_headers_are_set() {
let (upstream_url, _seen) = spawn_mock_upstream().await;
let tidepool_url = spawn_tidepool(upstream_url).await;
let resp = reqwest::Client::new()
.post(&tidepool_url)
.header("Origin", "http://example.com")
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tidepool_info",
"params": {}
}))
.send()
.await
.unwrap();
let headers = resp.headers();
assert_eq!(
headers
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("*")
);
}