File size: 21,786 Bytes
1295969
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
// ── multisig_vault.rs ─────────────────────────────────────────────────────────
//! Multi-sig vault integration for artist royalty payouts.
//!
//! Pipeline:
//!   DSP revenue (USD) β†’ business bank β†’ USDC stablecoin β†’ Safe multi-sig vault
//!   Smart contract conditions checked β†’ propose Safe transaction β†’ artist wallets
//!
//! Implementation:
//!   - Uses the Safe{Wallet} Transaction Service REST API (v1)
//!     <https://docs.safe.global/api-overview/transaction-service>
//!   - Supports Ethereum mainnet, Polygon, Arbitrum, and BTTC (custom Safe instance)
//!   - USDC balance monitoring via a standard ERC-20 `balanceOf` RPC call
//!   - Smart contract conditions: minimum balance threshold, minimum elapsed time
//!     since last distribution, and optional ZK proof of correct split commitment
//!
//! GMP note: every proposed transaction is logged with a sequence number.
//! The sequence is the DDEX-gateway audit event number, providing a single audit
//! trail from DSR ingestion β†’ USDC conversion β†’ Safe proposal β†’ on-chain execution.

#![allow(dead_code)]

use serde::{Deserialize, Serialize};
use tracing::{info, warn};

// ── Chain registry ────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Chain {
    EthereumMainnet,
    Polygon,
    Arbitrum,
    Base,
    Bttc,
    Custom(u64),
}

impl Chain {
    pub fn chain_id(self) -> u64 {
        match self {
            Self::EthereumMainnet => 1,
            Self::Polygon => 137,
            Self::Arbitrum => 42161,
            Self::Base => 8453,
            Self::Bttc => 199,
            Self::Custom(id) => id,
        }
    }

    /// Safe Transaction Service base URL for this chain.
    pub fn safe_api_url(self) -> String {
        match self {
            Self::EthereumMainnet => "https://safe-transaction-mainnet.safe.global/api/v1".into(),
            Self::Polygon => "https://safe-transaction-polygon.safe.global/api/v1".into(),
            Self::Arbitrum => "https://safe-transaction-arbitrum.safe.global/api/v1".into(),
            Self::Base => "https://safe-transaction-base.safe.global/api/v1".into(),
            Self::Bttc | Self::Custom(_) => std::env::var("SAFE_API_URL")
                .unwrap_or_else(|_| "http://localhost:8080/api/v1".into()),
        }
    }

    /// USDC contract address on this chain.
    pub fn usdc_address(self) -> &'static str {
        match self {
            Self::EthereumMainnet => "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
            Self::Polygon => "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
            Self::Arbitrum => "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
            Self::Base => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
            // BTTC / custom: operator-configured
            Self::Bttc | Self::Custom(_) => "0x0000000000000000000000000000000000000000",
        }
    }
}

// ── Vault configuration ────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct VaultConfig {
    /// Gnosis Safe address (checksummed EIP-55).
    pub safe_address: String,
    pub chain: Chain,
    /// JSON-RPC endpoint for balance queries.
    pub rpc_url: String,
    /// Minimum USDC balance (6 decimals) required before proposing a payout.
    pub min_payout_threshold_usdc: u64,
    /// Minimum seconds between payouts (e.g., 30 days = 2_592_000).
    pub min_payout_interval_secs: u64,
    /// If set, a ZK proof of the royalty split must be supplied with each proposal.
    pub require_zk_proof: bool,
    pub dev_mode: bool,
}

impl VaultConfig {
    pub fn from_env() -> Self {
        let chain = match std::env::var("VAULT_CHAIN").as_deref() {
            Ok("polygon") => Chain::Polygon,
            Ok("arbitrum") => Chain::Arbitrum,
            Ok("base") => Chain::Base,
            Ok("bttc") => Chain::Bttc,
            _ => Chain::EthereumMainnet,
        };
        Self {
            safe_address: std::env::var("VAULT_SAFE_ADDRESS")
                .unwrap_or_else(|_| "0x0000000000000000000000000000000000000001".into()),
            chain,
            rpc_url: std::env::var("VAULT_RPC_URL")
                .unwrap_or_else(|_| "http://localhost:8545".into()),
            min_payout_threshold_usdc: std::env::var("VAULT_MIN_PAYOUT_USDC")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(100_000_000), // 100 USDC
            min_payout_interval_secs: std::env::var("VAULT_MIN_INTERVAL_SECS")
                .ok()
                .and_then(|v| v.parse().ok())
                .unwrap_or(2_592_000), // 30 days
            require_zk_proof: std::env::var("VAULT_REQUIRE_ZK_PROOF").unwrap_or_default() != "0",
            dev_mode: std::env::var("VAULT_DEV_MODE").unwrap_or_default() == "1",
        }
    }
}

// ── Artist payout instruction ─────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistPayout {
    /// EIP-55 checksummed Ethereum address.
    pub wallet: String,
    /// Basis points (0-10000) of the total pool.
    pub bps: u16,
    /// ISRC or ISWC this payout is associated with.
    pub isrc: Option<String>,
    pub artist_name: String,
}

// ── USDC balance query ────────────────────────────────────────────────────────

/// Query the USDC balance of the Safe vault via `eth_call` β†’ `balanceOf(address)`.
pub async fn query_usdc_balance(config: &VaultConfig) -> anyhow::Result<u64> {
    if config.dev_mode {
        warn!("VAULT_DEV_MODE=1 β€” returning stub USDC balance 500_000_000 (500 USDC)");
        return Ok(500_000_000);
    }

    // ABI: balanceOf(address) β†’ bytes4 selector = 0x70a08231
    let selector = "70a08231";
    let padded_addr = format!(
        "000000000000000000000000{}",
        config.safe_address.trim_start_matches("0x")
    );
    let call_data = format!("0x{selector}{padded_addr}");

    let body = serde_json::json!({
        "jsonrpc": "2.0",
        "method":  "eth_call",
        "params":  [
            {
                "to":   config.chain.usdc_address(),
                "data": call_data,
            },
            "latest"
        ],
        "id": 1
    });

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .build()?;
    let resp: serde_json::Value = client
        .post(&config.rpc_url)
        .json(&body)
        .send()
        .await?
        .json()
        .await?;

    let hex = resp["result"]
        .as_str()
        .ok_or_else(|| anyhow::anyhow!("eth_call: missing result"))?
        .trim_start_matches("0x");
    let balance = u64::from_str_radix(&hex[hex.len().saturating_sub(16)..], 16).unwrap_or(0);
    info!(safe = %config.safe_address, usdc = balance, "USDC balance queried");
    Ok(balance)
}

// ── Safe API client ────────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SafePendingTx {
    pub safe_tx_hash: String,
    pub nonce: u64,
    pub to: String,
    pub value: String,
    pub data: String,
    pub confirmations_required: u32,
    pub confirmations_submitted: u32,
    pub is_executed: bool,
}

/// Fetch pending Safe transactions awaiting confirmation.
pub async fn list_pending_transactions(config: &VaultConfig) -> anyhow::Result<Vec<SafePendingTx>> {
    if config.dev_mode {
        return Ok(vec![]);
    }
    let url = format!(
        "{}/safes/{}/multisig-transactions/?executed=false",
        config.chain.safe_api_url(),
        config.safe_address
    );
    let client = reqwest::Client::new();
    let resp: serde_json::Value = client.get(&url).send().await?.json().await?;
    let results = resp["results"].as_array().cloned().unwrap_or_default();
    let txs: Vec<SafePendingTx> = results
        .iter()
        .filter_map(|v| serde_json::from_value(v.clone()).ok())
        .collect();
    Ok(txs)
}

// ── Payout proposal ───────────────────────────────────────────────────────────

/// Result of proposing a payout via Safe.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayoutProposal {
    pub safe_tx_hash: String,
    pub nonce: u64,
    pub total_usdc: u64,
    pub payouts: Vec<ArtistPayoutItem>,
    pub proposed_at: String,
    pub requires_confirmations: u32,
    pub status: ProposalStatus,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistPayoutItem {
    pub wallet: String,
    pub usdc_amount: u64,
    pub bps: u16,
    pub artist_name: String,
    pub isrc: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ProposalStatus {
    Proposed,
    AwaitingConfirmations,
    Executed,
    Rejected,
    DevModeStub,
}

/// Check smart contract conditions and, if met, propose a USDC payout via Safe.
///
/// Conditions checked (V-model gate):
///   1. Pool balance β‰₯ `config.min_payout_threshold_usdc`
///   2. No pending unexecuted Safe tx with same nonce
///   3. If `config.require_zk_proof`, a valid proof must be supplied
pub async fn propose_artist_payouts(
    config: &VaultConfig,
    payouts: &[ArtistPayout],
    total_usdc_pool: u64,
    zk_proof: Option<&[u8]>,
    audit_seq: u64,
) -> anyhow::Result<PayoutProposal> {
    // ── Condition 1: balance threshold ─────────────────────────────────────
    if total_usdc_pool < config.min_payout_threshold_usdc {
        anyhow::bail!(
            "Payout conditions not met: pool {} USDC < threshold {} USDC",
            total_usdc_pool / 1_000_000,
            config.min_payout_threshold_usdc / 1_000_000
        );
    }

    // ── Condition 2: ZK proof ──────────────────────────────────────────────
    if config.require_zk_proof && zk_proof.is_none() {
        anyhow::bail!("Payout conditions not met: ZK proof required but not supplied");
    }

    // ── Validate basis points sum to 10000 ──────────────────────────────────
    let bp_sum: u32 = payouts.iter().map(|p| p.bps as u32).sum();
    if bp_sum != 10_000 {
        anyhow::bail!("Payout basis points must sum to 10000, got {bp_sum}");
    }

    // ── Compute per-artist amounts ─────────────────────────────────────────
    let items: Vec<ArtistPayoutItem> = payouts
        .iter()
        .map(|p| {
            let usdc_amount = (total_usdc_pool as u128 * p.bps as u128 / 10_000) as u64;
            ArtistPayoutItem {
                wallet: p.wallet.clone(),
                usdc_amount,
                bps: p.bps,
                artist_name: p.artist_name.clone(),
                isrc: p.isrc.clone(),
            }
        })
        .collect();

    info!(
        safe = %config.safe_address,
        chain = ?config.chain,
        pool_usdc = total_usdc_pool,
        payees = payouts.len(),
        audit_seq,
        "Proposing multi-sig payout"
    );

    if config.dev_mode {
        warn!("VAULT_DEV_MODE=1 β€” returning stub proposal");
        return Ok(PayoutProposal {
            safe_tx_hash: format!("0x{}", "cd".repeat(32)),
            nonce: audit_seq,
            total_usdc: total_usdc_pool,
            payouts: items,
            proposed_at: chrono::Utc::now().to_rfc3339(),
            requires_confirmations: 2,
            status: ProposalStatus::DevModeStub,
        });
    }

    // ── Build Safe multi-send calldata ────────────────────────────────────
    // For simplicity we propose a USDC multi-transfer using a batch payload.
    // Each transfer is encoded as: transfer(address recipient, uint256 amount)
    // In production this would be a Safe multi-send batched transaction.
    let multisend_data = encode_usdc_multisend(&items, config.chain.usdc_address());

    // ── POST to Safe Transaction Service ─────────────────────────────────
    let nonce = fetch_next_nonce(config).await?;
    let body = serde_json::json!({
        "safe":             config.safe_address,
        "to":               config.chain.usdc_address(),
        "value":            "0",
        "data":             multisend_data,
        "operation":        0,   // CALL
        "safeTxGas":        0,
        "baseGas":          0,
        "gasPrice":         "0",
        "gasToken":         "0x0000000000000000000000000000000000000000",
        "refundReceiver":   "0x0000000000000000000000000000000000000000",
        "nonce":            nonce,
        "contractTransactionHash": "",   // filled by Safe API
        "sender":           config.safe_address,
        "signature":        "",          // requires owner key signing (handled off-band)
        "origin":           format!("retrosync-gateway-seq-{audit_seq}"),
    });

    let url = format!(
        "{}/safes/{}/multisig-transactions/",
        config.chain.safe_api_url(),
        config.safe_address
    );
    let client = reqwest::Client::new();
    let resp = client.post(&url).json(&body).send().await?;
    if !resp.status().is_success() {
        let text = resp.text().await.unwrap_or_default();
        anyhow::bail!("Safe API proposal failed: {text}");
    }

    let safe_tx_hash: String = resp
        .json::<serde_json::Value>()
        .await
        .ok()
        .and_then(|v| v["safeTxHash"].as_str().map(String::from))
        .unwrap_or_else(|| format!("0x{}", "00".repeat(32)));

    Ok(PayoutProposal {
        safe_tx_hash,
        nonce,
        total_usdc: total_usdc_pool,
        payouts: items,
        proposed_at: chrono::Utc::now().to_rfc3339(),
        requires_confirmations: 2,
        status: ProposalStatus::Proposed,
    })
}

async fn fetch_next_nonce(config: &VaultConfig) -> anyhow::Result<u64> {
    let url = format!(
        "{}/safes/{}/",
        config.chain.safe_api_url(),
        config.safe_address
    );
    let client = reqwest::Client::new();
    let resp: serde_json::Value = client.get(&url).send().await?.json().await?;
    Ok(resp["nonce"].as_u64().unwrap_or(0))
}

/// Encode USDC multi-transfer as a hex-string calldata payload.
/// Each item becomes `transfer(address, uint256)` ABI call.
fn encode_usdc_multisend(items: &[ArtistPayoutItem], _usdc_addr: &str) -> String {
    // ABI selector for ERC-20 transfer(address,uint256) = 0xa9059cbb
    let mut calls = Vec::new();
    for item in items {
        let addr = item.wallet.trim_start_matches("0x");
        let padded_addr = format!("{addr:0>64}");
        let usdc_amount = item.usdc_amount;
        let amount_hex = format!("{usdc_amount:0>64x}");
        calls.push(format!("a9059cbb{padded_addr}{amount_hex}"));
    }
    format!("0x{}", calls.join(""))
}

// ── Deposit monitoring ────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize)]
pub struct IncomingDeposit {
    pub tx_hash: String,
    pub from: String,
    pub usdc_amount: u64,
    pub block_number: u64,
    pub detected_at: String,
}

/// Scan recent ERC-20 Transfer events to the Safe address for USDC deposits.
/// In production, this should be replaced by a webhook from an indexer (e.g. Alchemy).
pub async fn scan_usdc_deposits(
    config: &VaultConfig,
    from_block: u64,
) -> anyhow::Result<Vec<IncomingDeposit>> {
    if config.dev_mode {
        return Ok(vec![IncomingDeposit {
            tx_hash: format!("0x{}", "ef".repeat(32)),
            from: "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".into(),
            usdc_amount: 500_000_000,
            block_number: from_block,
            detected_at: chrono::Utc::now().to_rfc3339(),
        }]);
    }

    // ERC-20 Transfer event topic:
    // keccak256("Transfer(address,address,uint256)") =
    //   0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
    let transfer_topic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
    let to_topic = format!(
        "0x000000000000000000000000{}",
        config.safe_address.trim_start_matches("0x")
    );

    let body = serde_json::json!({
        "jsonrpc": "2.0",
        "method": "eth_getLogs",
        "params": [{
            "fromBlock": format!("0x{from_block:x}"),
            "toBlock":   "latest",
            "address":   config.chain.usdc_address(),
            "topics":    [transfer_topic, null, to_topic],
        }],
        "id": 1
    });

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()?;
    let resp: serde_json::Value = client
        .post(&config.rpc_url)
        .json(&body)
        .send()
        .await?
        .json()
        .await?;

    let logs = resp["result"].as_array().cloned().unwrap_or_default();
    let deposits: Vec<IncomingDeposit> = logs
        .iter()
        .filter_map(|log| {
            let tx_hash = log["transactionHash"].as_str()?.to_string();
            let from = log["topics"].get(1)?.as_str().map(|t| {
                format!("0x{}", &t[26..]) // strip 12-byte padding
            })?;
            let data = log["data"]
                .as_str()
                .unwrap_or("0x")
                .trim_start_matches("0x");
            let usdc_amount =
                u64::from_str_radix(&data[data.len().saturating_sub(16)..], 16).unwrap_or(0);
            let block_hex = log["blockNumber"].as_str().unwrap_or("0x0");
            let block_number =
                u64::from_str_radix(block_hex.trim_start_matches("0x"), 16).unwrap_or(0);
            Some(IncomingDeposit {
                tx_hash,
                from,
                usdc_amount,
                block_number,
                detected_at: chrono::Utc::now().to_rfc3339(),
            })
        })
        .collect();

    info!(deposits = deposits.len(), "USDC deposits scanned");
    Ok(deposits)
}

// ── Execution status ─────────────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionStatus {
    pub safe_tx_hash: String,
    pub is_executed: bool,
    pub execution_tx_hash: Option<String>,
    pub executor: Option<String>,
    pub submission_date: Option<String>,
    pub modified: Option<String>,
}

/// Check whether a proposed payout transaction has been executed on-chain.
pub async fn check_execution_status(
    config: &VaultConfig,
    safe_tx_hash: &str,
) -> anyhow::Result<ExecutionStatus> {
    if config.dev_mode {
        return Ok(ExecutionStatus {
            safe_tx_hash: safe_tx_hash.into(),
            is_executed: false,
            execution_tx_hash: None,
            executor: None,
            submission_date: None,
            modified: None,
        });
    }
    let url = format!(
        "{}/multisig-transactions/{}/",
        config.chain.safe_api_url(),
        safe_tx_hash
    );
    let client = reqwest::Client::new();
    let v: serde_json::Value = client.get(&url).send().await?.json().await?;
    Ok(ExecutionStatus {
        safe_tx_hash: safe_tx_hash.into(),
        is_executed: v["isExecuted"].as_bool().unwrap_or(false),
        execution_tx_hash: v["transactionHash"].as_str().map(String::from),
        executor: v["executor"].as_str().map(String::from),
        submission_date: v["submissionDate"].as_str().map(String::from),
        modified: v["modified"].as_str().map(String::from),
    })
}

// ── Vault summary ─────────────────────────────────────────────────────────────

#[derive(Debug, Serialize)]
pub struct VaultSummary {
    pub safe_address: String,
    pub chain: Chain,
    pub usdc_balance: u64,
    pub pending_tx_count: usize,
    pub min_threshold_usdc: u64,
    pub can_propose_payout: bool,
    pub queried_at: String,
}

pub async fn vault_summary(config: &VaultConfig) -> anyhow::Result<VaultSummary> {
    let (balance, pending) = tokio::try_join!(
        query_usdc_balance(config),
        list_pending_transactions(config),
    )?;
    Ok(VaultSummary {
        safe_address: config.safe_address.clone(),
        chain: config.chain,
        usdc_balance: balance,
        pending_tx_count: pending.len(),
        min_threshold_usdc: config.min_payout_threshold_usdc,
        can_propose_payout: balance >= config.min_payout_threshold_usdc && pending.is_empty(),
        queried_at: chrono::Utc::now().to_rfc3339(),
    })
}