Spaces:
Build error
Build error
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);
}
}
|