File size: 8,891 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
//! BTTC royalty distribution β€” RoyaltyDistributor.sol via ethers-rs.
//!
//! Production path:
//!   - Builds typed `distribute()` calldata via ethers-rs ABI encoding
//!   - Signs with Ledger hardware wallet (LedgerWallet provider)
//!   - Sends via `eth_sendRawTransaction`
//!   - ZK proof passed as ABI-encoded `bytes` argument
//!
//! Dev path (BTTC_DEV_MODE=1):
//!   - Returns stub tx hash, no network calls
//!
//! Value cap: MAX_DISTRIBUTION_BTT enforced before ABI encoding.
//! The same cap is enforced in Solidity (defence-in-depth).

use ethers_core::{
    abi::{encode, Token},
    types::{Address, Bytes, U256},
    utils::keccak256,
};
use shared::types::{BtfsCid, RoyaltySplit};
use tracing::{info, instrument, warn};

/// 1 million BTT (18 decimals) β€” matches MAX_DISTRIBUTION_BTT in Solidity.
pub const MAX_DISTRIBUTION_BTT: u128 = 1_000_000 * 10u128.pow(18);

/// 4-byte selector for `distribute(address[],uint256[],uint8,uint256,bytes)`
fn distribute_selector() -> [u8; 4] {
    let sig = "distribute(address[],uint256[],uint8,uint256,bytes)";
    let hash = keccak256(sig.as_bytes());
    [hash[0], hash[1], hash[2], hash[3]]
}

/// ABI-encodes the `distribute()` calldata.
/// Equivalent to `abi.encodeWithSelector(distribute.selector, recipients, amounts, band, bpSum, proof)`.
fn encode_distribute_calldata(
    recipients: &[Address],
    amounts: &[U256],
    band: u8,
    bp_sum: u64,
    proof: &[u8],
) -> Bytes {
    let selector = distribute_selector();
    let tokens = vec![
        Token::Array(recipients.iter().map(|a| Token::Address(*a)).collect()),
        Token::Array(amounts.iter().map(|v| Token::Uint(*v)).collect()),
        Token::Uint(U256::from(band)),
        Token::Uint(U256::from(bp_sum)),
        Token::Bytes(proof.to_vec()),
    ];
    let mut calldata = selector.to_vec();
    calldata.extend_from_slice(&encode(&tokens));
    Bytes::from(calldata)
}

#[derive(Debug, Clone)]
pub struct SubmitResult {
    pub tx_hash: String,
    #[allow(dead_code)] // band included for callers and future API responses
    pub band: u8,
}

#[instrument(skip(proof))]
pub async fn submit_distribution(
    cid: &BtfsCid,
    splits: &[RoyaltySplit],
    band: u8,
    proof: Option<&[u8]>,
) -> anyhow::Result<SubmitResult> {
    let rpc = std::env::var("BTTC_RPC_URL").unwrap_or_else(|_| "http://127.0.0.1:8545".into());
    let contract = std::env::var("ROYALTY_CONTRACT_ADDR")
        .unwrap_or_else(|_| "0x0000000000000000000000000000000000000001".into());

    info!(cid=%cid.0, band=%band, rpc=%rpc, "Submitting to BTTC");

    // ── Dev mode ────────────────────────────────────────────────────────
    if std::env::var("BTTC_DEV_MODE").unwrap_or_default() == "1" {
        warn!("BTTC_DEV_MODE=1 β€” returning stub tx hash");
        return Ok(SubmitResult {
            tx_hash: format!("0x{}", "ab".repeat(32)),
            band,
        });
    }

    // ── Value cap (Rust layer β€” Solidity enforces the same) ─────────────
    let total_btt: u128 = splits.iter().map(|s| s.amount_btt).sum();
    if total_btt > MAX_DISTRIBUTION_BTT {
        anyhow::bail!(
            "Distribution of {} BTT exceeds MAX_DISTRIBUTION_BTT ({} BTT). \
             Use the timelock queue for large distributions.",
            total_btt / 10u128.pow(18),
            MAX_DISTRIBUTION_BTT / 10u128.pow(18),
        );
    }

    // ── Parse recipients + amounts ───────────────────────────────────────
    let mut recipients: Vec<Address> = Vec::with_capacity(splits.len());
    let mut amounts: Vec<U256> = Vec::with_capacity(splits.len());
    for split in splits {
        let addr: Address = split
            .address
            .0
            .parse()
            .map_err(|e| anyhow::anyhow!("Invalid EVM address in split: {e}"))?;
        recipients.push(addr);
        amounts.push(U256::from(split.amount_btt));
    }

    let bp_sum: u64 = splits.iter().map(|s| s.bps as u64).sum();
    anyhow::ensure!(
        bp_sum == 10_000,
        "Basis points must sum to 10,000, got {}",
        bp_sum
    );

    let proof_bytes = proof.unwrap_or(&[]);
    let calldata = encode_distribute_calldata(&recipients, &amounts, band, bp_sum, proof_bytes);
    let contract_addr: Address = contract
        .parse()
        .map_err(|e| anyhow::anyhow!("Invalid ROYALTY_CONTRACT_ADDR: {e}"))?;

    // ── Sign via Ledger and send ─────────────────────────────────────────
    let tx_hash = send_via_ledger(&rpc, contract_addr, calldata).await?;

    // Validate returned hash through LangSec recognizer
    shared::parsers::recognize_tx_hash(&tx_hash)
        .map_err(|e| anyhow::anyhow!("RPC returned invalid tx hash: {e}"))?;

    info!(tx_hash=%tx_hash, cid=%cid.0, band=%band, "BTTC distribution submitted");
    Ok(SubmitResult { tx_hash, band })
}

/// Signs and broadcasts a transaction using the Ledger hardware wallet.
///
/// Uses ethers-rs `LedgerWallet` with HDPath `m/44'/60'/0'/0/0`.
/// The Ledger must be connected, unlocked, and the Ethereum app open.
/// Signing is performed directly via `Signer::sign_transaction` β€” no
/// `SignerMiddleware` (and therefore no ethers-middleware / reqwest 0.11)
/// is required.
async fn send_via_ledger(rpc_url: &str, to: Address, calldata: Bytes) -> anyhow::Result<String> {
    use ethers_core::types::{transaction::eip2718::TypedTransaction, TransactionRequest};
    use ethers_providers::{Http, Middleware, Provider};
    use ethers_signers::{HDPath, Ledger, Signer};

    let provider = Provider::<Http>::try_from(rpc_url)
        .map_err(|e| anyhow::anyhow!("Cannot connect to RPC {rpc_url}: {e}"))?;
    let chain_id = provider.get_chainid().await?.as_u64();

    let ledger = Ledger::new(HDPath::LedgerLive(0), chain_id)
        .await
        .map_err(|e| {
            anyhow::anyhow!(
                "Ledger connection failed: {e}. \
             Ensure device is connected, unlocked, and Ethereum app is open."
            )
        })?;

    let from = ledger.address();
    let nonce = provider.get_transaction_count(from, None).await?;

    let mut typed_tx = TypedTransaction::Legacy(
        TransactionRequest::new()
            .from(from)
            .to(to)
            .data(calldata)
            .nonce(nonce)
            .chain_id(chain_id),
    );

    let gas_est = provider
        .estimate_gas(&typed_tx, None)
        .await
        .unwrap_or(U256::from(300_000u64));
    // 20% gas buffer
    typed_tx.set_gas(gas_est * 120u64 / 100u64);

    // Sign with Ledger hardware wallet (no middleware needed)
    let signature = ledger
        .sign_transaction(&typed_tx)
        .await
        .map_err(|e| anyhow::anyhow!("Transaction rejected by Ledger: {e}"))?;

    // Broadcast signed raw transaction via provider
    let raw = typed_tx.rlp_signed(&signature);
    let pending = provider
        .send_raw_transaction(raw)
        .await
        .map_err(|e| anyhow::anyhow!("RPC rejected transaction: {e}"))?;

    // Wait for 1 confirmation
    let receipt = pending
        .confirmations(1)
        .await?
        .ok_or_else(|| anyhow::anyhow!("Transaction dropped from mempool"))?;

    Ok(format!("{:#x}", receipt.transaction_hash))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn selector_is_stable() {
        // The 4-byte selector for distribute() must never change β€”
        // it's what the Solidity ABI expects.
        let sel = distribute_selector();
        // Verify it's non-zero (actual value depends on full sig hash)
        assert!(sel.iter().any(|b| *b != 0), "selector must be non-zero");
    }

    #[test]
    fn value_cap_enforced() {
        // total > MAX should be caught before any network call
        let splits = vec![shared::types::RoyaltySplit {
            address: shared::types::EvmAddress("0x0000000000000000000000000000000000000001".into()),
            bps: 10_000,
            amount_btt: MAX_DISTRIBUTION_BTT + 1,
        }];
        // We can't call the async fn in a sync test, but we verify the cap constant
        assert!(splits.iter().map(|s| s.amount_btt).sum::<u128>() > MAX_DISTRIBUTION_BTT);
    }

    #[test]
    fn calldata_encodes_without_panic() {
        let recipients = vec!["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
            .parse::<Address>()
            .unwrap()];
        let amounts = vec![U256::from(1000u64)];
        let proof = vec![0x01u8, 0x02, 0x03];
        let data = encode_distribute_calldata(&recipients, &amounts, 0, 10_000, &proof);
        // 4 selector bytes + at least 5 ABI words
        assert!(data.len() >= 4 + 5 * 32);
    }
}