Skip to main content

predict_sdk/
onchain.rs

1//! On-chain operations for Predict.fun
2//!
3//! This module handles direct contract interactions for split/merge/redeem operations.
4
5use crate::constants::Addresses;
6use crate::errors::{Error, Result};
7use crate::types::ChainId;
8use alloy::primitives::{Address, Bytes, FixedBytes, U256};
9use alloy::providers::{Provider, ProviderBuilder};
10use alloy::signers::local::PrivateKeySigner;
11use alloy::sol;
12use alloy::sol_types::SolCall;
13use tracing::{debug, info};
14
15// Define the contract ABIs using alloy's sol! macro
16
17sol! {
18    /// Conditional Tokens contract for non-neg-risk markets
19    #[sol(rpc)]
20    interface IConditionalTokens {
21        function splitPosition(
22            address collateralToken,
23            bytes32 parentCollectionId,
24            bytes32 conditionId,
25            uint256[] calldata partition,
26            uint256 amount
27        ) external;
28
29        function mergePositions(
30            address collateralToken,
31            bytes32 parentCollectionId,
32            bytes32 conditionId,
33            uint256[] calldata partition,
34            uint256 amount
35        ) external;
36    }
37}
38
39sol! {
40    /// Neg Risk Adapter for neg-risk markets
41    #[sol(rpc)]
42    interface INegRiskAdapter {
43        #[allow(non_snake_case)]
44        function splitPosition(bytes32 conditionId, uint256 amount) external;
45
46        #[allow(non_snake_case)]
47        function mergePositions(bytes32 conditionId, uint256 amount) external;
48    }
49}
50
51sol! {
52    /// Kernel smart wallet contract
53    #[sol(rpc)]
54    interface IKernel {
55        function execute(bytes32 mode, bytes calldata executionCalldata) external payable returns (bytes memory);
56    }
57}
58
59sol! {
60    /// ERC20 interface for approvals
61    #[sol(rpc)]
62    interface IERC20 {
63        function approve(address spender, uint256 amount) external returns (bool);
64        function allowance(address owner, address spender) external view returns (uint256);
65        function balanceOf(address account) external view returns (uint256);
66    }
67}
68
69sol! {
70    /// ERC1155 interface for operator approvals and transfers (Conditional Tokens)
71    #[sol(rpc)]
72    interface IERC1155 {
73        function setApprovalForAll(address operator, bool approved) external;
74        function isApprovedForAll(address account, address operator) external view returns (bool);
75        function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) external;
76        function balanceOf(address account, uint256 id) external view returns (uint256);
77    }
78}
79
80/// Execution mode for Kernel smart wallet (single call)
81const KERNEL_EXEC_MODE: [u8; 32] = [0u8; 32];
82
83/// Options for split operation
84#[derive(Debug, Clone)]
85pub struct SplitOptions {
86    /// Condition ID of the market
87    pub condition_id: String,
88    /// Amount to split (in USDT, will be converted to wei - 18 decimals)
89    pub amount: f64,
90    /// Whether this is a neg-risk market
91    pub is_neg_risk: bool,
92    /// Whether this is a yield-bearing market
93    pub is_yield_bearing: bool,
94}
95
96/// On-chain client for Predict.fun operations
97pub struct OnchainClient {
98    chain_id: ChainId,
99    signer: PrivateKeySigner,
100    addresses: Addresses,
101    rpc_url: String,
102    /// Predict Account address for smart wallet operations
103    predict_account: Option<Address>,
104}
105
106impl OnchainClient {
107    /// Create a new OnchainClient for direct EOA operations
108    pub fn new(chain_id: ChainId, signer: PrivateKeySigner) -> Self {
109        let addresses = Addresses::for_chain(chain_id);
110        let rpc_url = match chain_id {
111            ChainId::BnbMainnet => "https://bsc-dataseed.bnbchain.org/".to_string(),
112            ChainId::BnbTestnet => "https://bsc-testnet-dataseed.bnbchain.org/".to_string(),
113        };
114
115        Self {
116            chain_id,
117            signer,
118            addresses,
119            rpc_url,
120            predict_account: None,
121        }
122    }
123
124    /// Create a new OnchainClient for Predict Account (smart wallet) operations
125    pub fn with_predict_account(
126        chain_id: ChainId,
127        signer: PrivateKeySigner,
128        predict_account: &str,
129    ) -> Result<Self> {
130        let mut client = Self::new(chain_id, signer);
131        client.predict_account = Some(
132            predict_account
133                .parse()
134                .map_err(|e| Error::Other(format!("Invalid predict account address: {}", e)))?,
135        );
136        Ok(client)
137    }
138
139    /// Get the signer address
140    pub fn signer_address(&self) -> Address {
141        self.signer.address()
142    }
143
144    /// Get the trading address (predict_account if set, otherwise signer)
145    pub fn trading_address(&self) -> Address {
146        self.predict_account.unwrap_or_else(|| self.signer.address())
147    }
148
149    /// Check if using smart wallet
150    pub fn is_smart_wallet(&self) -> bool {
151        self.predict_account.is_some()
152    }
153
154    /// Get the addresses configuration
155    pub fn addresses(&self) -> &Addresses {
156        &self.addresses
157    }
158
159    /// Set all necessary approvals for trading on the CTF Exchange.
160    ///
161    /// This sets:
162    /// 1. ERC-1155 `setApprovalForAll` on Conditional Tokens → CTF Exchange
163    ///    (allows exchange to move outcome tokens on behalf of the trader)
164    /// 2. ERC-20 `approve` on USDT → CTF Exchange
165    ///    (allows exchange to take USDT collateral)
166    ///
167    /// For neg-risk markets, also approves the Neg Risk Adapter.
168    pub async fn set_approvals(
169        &self,
170        is_neg_risk: bool,
171        is_yield_bearing: bool,
172    ) -> Result<()> {
173        let provider = ProviderBuilder::new()
174            .wallet(alloy::network::EthereumWallet::from(self.signer.clone()))
175            .connect_http(self.rpc_url.parse().unwrap());
176
177        let owner = self.trading_address();
178
179        // 1. ERC-1155 approval: Conditional Tokens → CTF Exchange
180        let ct_address: Address = self.addresses.get_conditional_tokens(is_yield_bearing, is_neg_risk)
181            .parse().unwrap();
182        let exchange_address: Address = self.addresses.get_ctf_exchange(is_yield_bearing, is_neg_risk)
183            .parse().unwrap();
184
185        let ct = IERC1155::new(ct_address, provider.clone());
186        let is_approved = ct
187            .isApprovedForAll(owner, exchange_address)
188            .call()
189            .await
190            .map_err(|e| Error::Other(format!("Failed to check ERC-1155 approval: {}", e)))?;
191
192        if !is_approved {
193            info!("Setting ERC-1155 approval: {} → {}", ct_address, exchange_address);
194            let tx = ct
195                .setApprovalForAll(exchange_address, true)
196                .send()
197                .await
198                .map_err(|e| Error::Other(format!("Failed to send setApprovalForAll: {}", e)))?;
199            let receipt = tx
200                .get_receipt()
201                .await
202                .map_err(|e| Error::Other(format!("Failed to get approval receipt: {}", e)))?;
203            if !receipt.status() {
204                return Err(Error::Other(format!(
205                    "setApprovalForAll reverted: {:?}", receipt.transaction_hash
206                )));
207            }
208            info!("ERC-1155 approval set: {:?}", receipt.transaction_hash);
209        } else {
210            debug!("ERC-1155 already approved: {} → {}", ct_address, exchange_address);
211        }
212
213        // 2. ERC-20 approval: USDT → CTF Exchange
214        let usdt_address: Address = self.addresses.usdt.parse().unwrap();
215        let usdt = IERC20::new(usdt_address, provider.clone());
216        let allowance = usdt
217            .allowance(owner, exchange_address)
218            .call()
219            .await
220            .map_err(|e| Error::Other(format!("Failed to check USDT allowance: {}", e)))?;
221
222        if allowance < U256::from(1_000_000_000_000_000_000_000_u128) {
223            // Less than 1000 USDT allowance, approve max
224            info!("Approving USDT for CTF Exchange: {}", exchange_address);
225            let tx = usdt
226                .approve(exchange_address, U256::MAX)
227                .send()
228                .await
229                .map_err(|e| Error::Other(format!("Failed to send USDT approval: {}", e)))?;
230            let receipt = tx
231                .get_receipt()
232                .await
233                .map_err(|e| Error::Other(format!("Failed to get USDT approval receipt: {}", e)))?;
234            if !receipt.status() {
235                return Err(Error::Other(format!(
236                    "USDT approval reverted: {:?}", receipt.transaction_hash
237                )));
238            }
239            info!("USDT approval set: {:?}", receipt.transaction_hash);
240        } else {
241            debug!("USDT already approved for CTF Exchange");
242        }
243
244        // 3. For neg-risk markets, also approve the Neg Risk Adapter
245        if is_neg_risk {
246            let adapter_address: Address = if is_yield_bearing {
247                self.addresses.yield_bearing_neg_risk_adapter
248            } else {
249                self.addresses.neg_risk_adapter
250            }.parse().unwrap();
251
252            let is_adapter_approved = ct
253                .isApprovedForAll(owner, adapter_address)
254                .call()
255                .await
256                .map_err(|e| Error::Other(format!("Failed to check adapter approval: {}", e)))?;
257
258            if !is_adapter_approved {
259                info!("Setting ERC-1155 approval for Neg Risk Adapter: {}", adapter_address);
260                let tx = ct
261                    .setApprovalForAll(adapter_address, true)
262                    .send()
263                    .await
264                    .map_err(|e| Error::Other(format!("Failed to approve adapter: {}", e)))?;
265                let receipt = tx
266                    .get_receipt()
267                    .await
268                    .map_err(|e| Error::Other(format!("Failed to get adapter approval receipt: {}", e)))?;
269                if !receipt.status() {
270                    return Err(Error::Other(format!(
271                        "Adapter approval reverted: {:?}", receipt.transaction_hash
272                    )));
273                }
274                info!("Neg Risk Adapter approval set: {:?}", receipt.transaction_hash);
275            }
276        }
277
278        Ok(())
279    }
280
281    /// Split USDT into UP/DOWN outcome tokens
282    ///
283    /// # Arguments
284    /// * `options` - Split options including condition_id, amount, and market type
285    ///
286    /// # Returns
287    /// Transaction hash on success
288    pub async fn split_positions(&self, options: SplitOptions) -> Result<String> {
289        info!(
290            "Splitting {} USDT for condition {} (neg_risk={}, yield_bearing={})",
291            options.amount, options.condition_id, options.is_neg_risk, options.is_yield_bearing
292        );
293
294        // Convert amount to wei (USDT has 18 decimals on BNB Chain)
295        let amount_wei = U256::from((options.amount * 1e18) as u128);
296
297        // Parse condition ID
298        let condition_id: FixedBytes<32> = options
299            .condition_id
300            .parse()
301            .map_err(|e| Error::Other(format!("Invalid condition ID: {}", e)))?;
302
303        // Create provider with signer
304        let provider = ProviderBuilder::new()
305            .wallet(alloy::network::EthereumWallet::from(self.signer.clone()))
306            .connect_http(self.rpc_url.parse().unwrap());
307
308        // First, ensure USDT approval
309        self.ensure_usdt_approval(&provider, amount_wei, options.is_neg_risk, options.is_yield_bearing)
310            .await?;
311
312        // Execute split based on wallet type and market type
313        let tx_hash = if self.is_smart_wallet() {
314            self.split_via_kernel(&provider, condition_id, amount_wei, &options)
315                .await?
316        } else {
317            self.split_direct(&provider, condition_id, amount_wei, &options)
318                .await?
319        };
320
321        info!("Split transaction submitted: {}", tx_hash);
322        Ok(tx_hash)
323    }
324
325    /// Ensure USDT is approved for the target contract
326    async fn ensure_usdt_approval<P: Provider + Clone>(
327        &self,
328        provider: &P,
329        amount: U256,
330        is_neg_risk: bool,
331        is_yield_bearing: bool,
332    ) -> Result<()> {
333        let usdt_address: Address = self.addresses.usdt.parse().unwrap();
334        let spender = self.get_target_contract(is_neg_risk, is_yield_bearing);
335        let owner = self.trading_address();
336
337        let usdt = IERC20::new(usdt_address, provider.clone());
338
339        // Check current allowance
340        let allowance = usdt
341            .allowance(owner, spender)
342            .call()
343            .await
344            .map_err(|e| Error::Other(format!("Failed to check allowance: {}", e)))?;
345
346        if allowance < amount {
347            info!("Approving USDT spend for {:?}", spender);
348
349            // Approve max amount
350            let approve_call = usdt.approve(spender, U256::MAX);
351
352            if self.is_smart_wallet() {
353                // Execute approval through Kernel
354                let encoded = approve_call.calldata().clone();
355                self.execute_via_kernel(provider, usdt_address, encoded)
356                    .await?;
357            } else {
358                // Direct approval
359                let tx = approve_call
360                    .send()
361                    .await
362                    .map_err(|e| Error::Other(format!("Failed to send approval: {}", e)))?;
363                let receipt = tx
364                    .get_receipt()
365                    .await
366                    .map_err(|e| Error::Other(format!("Failed to get approval receipt: {}", e)))?;
367
368                // Check transaction status
369                if !receipt.status() {
370                    return Err(Error::Other(format!(
371                        "Approval transaction reverted: {:?}",
372                        receipt.transaction_hash
373                    )));
374                }
375
376                debug!("Approval tx: {:?}", receipt.transaction_hash);
377            }
378        }
379
380        Ok(())
381    }
382
383    /// Get the target contract address for split operations
384    fn get_target_contract(&self, is_neg_risk: bool, is_yield_bearing: bool) -> Address {
385        let addr_str = if is_neg_risk {
386            if is_yield_bearing {
387                self.addresses.yield_bearing_neg_risk_adapter
388            } else {
389                self.addresses.neg_risk_adapter
390            }
391        } else if is_yield_bearing {
392            self.addresses.yield_bearing_conditional_tokens
393        } else {
394            self.addresses.conditional_tokens
395        };
396        addr_str.parse().unwrap()
397    }
398
399    /// Split directly (EOA wallet)
400    async fn split_direct<P: Provider + Clone>(
401        &self,
402        provider: &P,
403        condition_id: FixedBytes<32>,
404        amount: U256,
405        options: &SplitOptions,
406    ) -> Result<String> {
407        let target = self.get_target_contract(options.is_neg_risk, options.is_yield_bearing);
408
409        if options.is_neg_risk {
410            // Neg risk: splitPosition(bytes32, uint256)
411            let contract = INegRiskAdapter::new(target, provider.clone());
412            let tx = contract
413                .splitPosition(condition_id, amount)
414                .send()
415                .await
416                .map_err(|e| Error::Other(format!("Failed to send split tx: {}", e)))?;
417
418            let receipt = tx
419                .get_receipt()
420                .await
421                .map_err(|e| Error::Other(format!("Failed to get receipt: {}", e)))?;
422
423            // Check transaction status (0 = reverted, 1 = success)
424            if !receipt.status() {
425                return Err(Error::Other(format!(
426                    "Transaction reverted: {:?}",
427                    receipt.transaction_hash
428                )));
429            }
430
431            Ok(format!("{:?}", receipt.transaction_hash))
432        } else {
433            // Regular: splitPosition(address, bytes32, bytes32, uint256[], uint256)
434            let contract = IConditionalTokens::new(target, provider.clone());
435            let usdt: Address = self.addresses.usdt.parse().unwrap();
436            let parent_collection = FixedBytes::<32>::ZERO;
437            let partition = vec![U256::from(1), U256::from(2)];
438
439            let tx = contract
440                .splitPosition(usdt, parent_collection, condition_id, partition, amount)
441                .send()
442                .await
443                .map_err(|e| Error::Other(format!("Failed to send split tx: {}", e)))?;
444
445            let receipt = tx
446                .get_receipt()
447                .await
448                .map_err(|e| Error::Other(format!("Failed to get receipt: {}", e)))?;
449
450            // Check transaction status (0 = reverted, 1 = success)
451            if !receipt.status() {
452                return Err(Error::Other(format!(
453                    "Transaction reverted: {:?}",
454                    receipt.transaction_hash
455                )));
456            }
457
458            Ok(format!("{:?}", receipt.transaction_hash))
459        }
460    }
461
462    /// Split via Kernel smart wallet
463    async fn split_via_kernel<P: Provider + Clone>(
464        &self,
465        provider: &P,
466        condition_id: FixedBytes<32>,
467        amount: U256,
468        options: &SplitOptions,
469    ) -> Result<String> {
470        let target = self.get_target_contract(options.is_neg_risk, options.is_yield_bearing);
471
472        // Encode the split call
473        let calldata = if options.is_neg_risk {
474            // Neg risk: splitPosition(bytes32, uint256)
475            let call = INegRiskAdapter::splitPositionCall {
476                conditionId: condition_id,
477                amount,
478            };
479            Bytes::from(call.abi_encode())
480        } else {
481            // Regular: splitPosition(address, bytes32, bytes32, uint256[], uint256)
482            let usdt: Address = self.addresses.usdt.parse().unwrap();
483            let parent_collection = FixedBytes::<32>::ZERO;
484            let partition = vec![U256::from(1), U256::from(2)];
485
486            let call = IConditionalTokens::splitPositionCall {
487                collateralToken: usdt,
488                parentCollectionId: parent_collection,
489                conditionId: condition_id,
490                partition,
491                amount,
492            };
493            Bytes::from(call.abi_encode())
494        };
495
496        self.execute_via_kernel(provider, target, calldata).await
497    }
498
499    /// Execute a call through the Kernel smart wallet
500    async fn execute_via_kernel<P: Provider + Clone>(
501        &self,
502        provider: &P,
503        target: Address,
504        calldata: Bytes,
505    ) -> Result<String> {
506        let predict_account = self
507            .predict_account
508            .ok_or_else(|| Error::Other("No predict account configured".to_string()))?;
509
510        // Encode execution calldata: target (20 bytes) + value (32 bytes) + calldata
511        let mut execution_calldata = Vec::new();
512        execution_calldata.extend_from_slice(target.as_slice());
513        execution_calldata.extend_from_slice(&U256::ZERO.to_be_bytes::<32>());
514        execution_calldata.extend_from_slice(&calldata);
515
516        let kernel = IKernel::new(predict_account, provider.clone());
517        let mode = FixedBytes::<32>::from(KERNEL_EXEC_MODE);
518
519        let tx = kernel
520            .execute(mode, Bytes::from(execution_calldata))
521            .send()
522            .await
523            .map_err(|e| Error::Other(format!("Failed to send kernel execute: {}", e)))?;
524
525        let receipt = tx
526            .get_receipt()
527            .await
528            .map_err(|e| Error::Other(format!("Failed to get receipt: {}", e)))?;
529
530        // Check transaction status (0 = reverted, 1 = success)
531        if !receipt.status() {
532            return Err(Error::Other(format!(
533                "Kernel execute reverted: {:?}",
534                receipt.transaction_hash
535            )));
536        }
537
538        Ok(format!("{:?}", receipt.transaction_hash))
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    #[test]
547    fn test_create_onchain_client() {
548        let signer = PrivateKeySigner::random();
549        let client = OnchainClient::new(ChainId::BnbTestnet, signer);
550        assert!(!client.is_smart_wallet());
551    }
552
553    #[test]
554    fn test_create_smart_wallet_client() {
555        let signer = PrivateKeySigner::random();
556        let client = OnchainClient::with_predict_account(
557            ChainId::BnbTestnet,
558            signer,
559            "0x1234567890123456789012345678901234567890",
560        )
561        .unwrap();
562        assert!(client.is_smart_wallet());
563    }
564}