Skip to main content

nautilus_polymarket/positions/
calldata.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! ABI encoding for Polymarket Conditional Token split, merge, and redeem calls.
17
18use alloy::{primitives::Bytes, sol, sol_types::SolCall};
19use alloy_primitives::{Address, B256, U256};
20use rust_decimal::Decimal;
21
22use super::amounts::pusd_to_base_units;
23use crate::{
24    http::error::Result,
25    signing::eip712::{
26        CTF_COLLATERAL_ADAPTER, NEG_RISK_CTF_COLLATERAL_ADAPTER, POLYMARKET_COLLATERAL_TOKEN,
27        parse_bytes32,
28    },
29};
30
31sol! {
32    function splitPosition(
33        address collateralToken,
34        bytes32 parentCollectionId,
35        bytes32 conditionId,
36        uint256[] partition,
37        uint256 amount
38    );
39
40    function mergePositions(
41        address collateralToken,
42        bytes32 parentCollectionId,
43        bytes32 conditionId,
44        uint256[] partition,
45        uint256 amount
46    );
47
48    function redeemPositions(
49        address collateralToken,
50        bytes32 parentCollectionId,
51        bytes32 conditionId,
52        uint256[] indexSets
53    );
54}
55
56const PARENT_COLLECTION_ID: B256 = B256::ZERO;
57const BINARY_INDEX_SETS: [U256; 2] = [
58    U256::from_limbs([1, 0, 0, 0]),
59    U256::from_limbs([2, 0, 0, 0]),
60];
61
62/// Encoded collateral-adapter call for a position operation.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct PositionCall {
65    /// Collateral adapter that receives the call.
66    pub target: Address,
67    /// ABI-encoded function call.
68    pub data: Bytes,
69}
70
71/// Returns the canonical collateral adapter for a standard or negative-risk market.
72#[must_use]
73pub const fn collateral_adapter(neg_risk: bool) -> Address {
74    if neg_risk {
75        NEG_RISK_CTF_COLLATERAL_ADAPTER
76    } else {
77        CTF_COLLATERAL_ADAPTER
78    }
79}
80
81/// Encodes a `splitPosition` call for `amount` pUSD.
82///
83/// # Errors
84///
85/// Returns an error if `condition_id` is not a 32-byte hex value or `amount`
86/// is not an exact positive six-decimal pUSD quantity.
87pub fn encode_split_position(
88    condition_id: &str,
89    amount: Decimal,
90    neg_risk: bool,
91) -> Result<PositionCall> {
92    let condition_id = parse_condition_id(condition_id)?;
93    let amount = pusd_to_base_units(amount)?;
94    Ok(PositionCall {
95        target: collateral_adapter(neg_risk),
96        data: Bytes::from(
97            splitPositionCall {
98                collateralToken: POLYMARKET_COLLATERAL_TOKEN,
99                parentCollectionId: PARENT_COLLECTION_ID,
100                conditionId: condition_id,
101                partition: BINARY_INDEX_SETS.to_vec(),
102                amount,
103            }
104            .abi_encode(),
105        ),
106    })
107}
108
109/// Encodes a `mergePositions` call for `amount` pUSD of complete sets.
110///
111/// # Errors
112///
113/// Returns an error if `condition_id` is not a 32-byte hex value or `amount`
114/// is not an exact positive six-decimal pUSD quantity.
115pub fn encode_merge_positions(
116    condition_id: &str,
117    amount: Decimal,
118    neg_risk: bool,
119) -> Result<PositionCall> {
120    let condition_id = parse_condition_id(condition_id)?;
121    let amount = pusd_to_base_units(amount)?;
122    Ok(PositionCall {
123        target: collateral_adapter(neg_risk),
124        data: Bytes::from(
125            mergePositionsCall {
126                collateralToken: POLYMARKET_COLLATERAL_TOKEN,
127                parentCollectionId: PARENT_COLLECTION_ID,
128                conditionId: condition_id,
129                partition: BINARY_INDEX_SETS.to_vec(),
130                amount,
131            }
132            .abi_encode(),
133        ),
134    })
135}
136
137/// Encodes a `redeemPositions` call for both binary index sets.
138///
139/// # Errors
140///
141/// Returns an error if `condition_id` is not a 32-byte hex value.
142pub fn encode_redeem_positions(condition_id: &str, neg_risk: bool) -> Result<PositionCall> {
143    let condition_id = parse_condition_id(condition_id)?;
144    Ok(PositionCall {
145        target: collateral_adapter(neg_risk),
146        data: Bytes::from(
147            redeemPositionsCall {
148                collateralToken: POLYMARKET_COLLATERAL_TOKEN,
149                parentCollectionId: PARENT_COLLECTION_ID,
150                conditionId: condition_id,
151                indexSets: BINARY_INDEX_SETS.to_vec(),
152            }
153            .abi_encode(),
154        ),
155    })
156}
157
158fn parse_condition_id(condition_id: &str) -> Result<B256> {
159    parse_bytes32(condition_id, "condition_id")
160}
161
162#[cfg(test)]
163mod tests {
164    use alloy_primitives::keccak256;
165    use rstest::rstest;
166    use rust_decimal_macros::dec;
167
168    use super::*;
169
170    const CONDITION_ID: &str = "0x1111111111111111111111111111111111111111111111111111111111111111";
171
172    fn selector(signature: &str) -> [u8; 4] {
173        let hash = keccak256(signature.as_bytes());
174        [hash[0], hash[1], hash[2], hash[3]]
175    }
176
177    #[rstest]
178    fn test_collateral_adapter_targets() {
179        assert_eq!(collateral_adapter(false), CTF_COLLATERAL_ADAPTER);
180        assert_eq!(collateral_adapter(true), NEG_RISK_CTF_COLLATERAL_ADAPTER);
181        assert_ne!(CTF_COLLATERAL_ADAPTER, NEG_RISK_CTF_COLLATERAL_ADAPTER);
182    }
183
184    #[rstest]
185    fn test_encode_split_standard_fixture() {
186        let call = encode_split_position(CONDITION_ID, dec!(1), false).unwrap();
187        assert_eq!(call.target, CTF_COLLATERAL_ADAPTER);
188        assert_eq!(
189            &call.data[..4],
190            selector("splitPosition(address,bytes32,bytes32,uint256[],uint256)")
191        );
192        assert_eq!(
193            format!("0x{}", alloy_primitives::hex::encode(&call.data)),
194            concat!(
195                "0x72ce4275",
196                "000000000000000000000000c011a7e12a19f7b1f670d46f03b03f3342e82dfb",
197                "0000000000000000000000000000000000000000000000000000000000000000",
198                "1111111111111111111111111111111111111111111111111111111111111111",
199                "00000000000000000000000000000000000000000000000000000000000000a0",
200                "00000000000000000000000000000000000000000000000000000000000f4240",
201                "0000000000000000000000000000000000000000000000000000000000000002",
202                "0000000000000000000000000000000000000000000000000000000000000001",
203                "0000000000000000000000000000000000000000000000000000000000000002",
204            )
205        );
206    }
207
208    #[rstest]
209    fn test_encode_merge_neg_risk_fixture() {
210        let call = encode_merge_positions(CONDITION_ID, dec!(1), true).unwrap();
211        assert_eq!(call.target, NEG_RISK_CTF_COLLATERAL_ADAPTER);
212        assert_eq!(
213            &call.data[..4],
214            selector("mergePositions(address,bytes32,bytes32,uint256[],uint256)")
215        );
216        assert_eq!(
217            format!("0x{}", alloy_primitives::hex::encode(&call.data)),
218            concat!(
219                "0x9e7212ad",
220                "000000000000000000000000c011a7e12a19f7b1f670d46f03b03f3342e82dfb",
221                "0000000000000000000000000000000000000000000000000000000000000000",
222                "1111111111111111111111111111111111111111111111111111111111111111",
223                "00000000000000000000000000000000000000000000000000000000000000a0",
224                "00000000000000000000000000000000000000000000000000000000000f4240",
225                "0000000000000000000000000000000000000000000000000000000000000002",
226                "0000000000000000000000000000000000000000000000000000000000000001",
227                "0000000000000000000000000000000000000000000000000000000000000002",
228            )
229        );
230    }
231
232    #[rstest]
233    fn test_encode_redeem_standard_and_neg_risk_share_calldata() {
234        let standard = encode_redeem_positions(CONDITION_ID, false).unwrap();
235        let neg_risk = encode_redeem_positions(CONDITION_ID, true).unwrap();
236        assert_eq!(standard.target, CTF_COLLATERAL_ADAPTER);
237        assert_eq!(neg_risk.target, NEG_RISK_CTF_COLLATERAL_ADAPTER);
238        assert_eq!(standard.data, neg_risk.data);
239        assert_eq!(
240            &standard.data[..4],
241            selector("redeemPositions(address,bytes32,bytes32,uint256[])")
242        );
243        assert_eq!(
244            format!("0x{}", alloy_primitives::hex::encode(&standard.data)),
245            concat!(
246                "0x01b7037c",
247                "000000000000000000000000c011a7e12a19f7b1f670d46f03b03f3342e82dfb",
248                "0000000000000000000000000000000000000000000000000000000000000000",
249                "1111111111111111111111111111111111111111111111111111111111111111",
250                "0000000000000000000000000000000000000000000000000000000000000080",
251                "0000000000000000000000000000000000000000000000000000000000000002",
252                "0000000000000000000000000000000000000000000000000000000000000001",
253                "0000000000000000000000000000000000000000000000000000000000000002",
254            )
255        );
256    }
257
258    #[rstest]
259    fn test_encode_rejects_invalid_condition_id() {
260        let err = encode_split_position("not-a-condition", dec!(1), false).unwrap_err();
261        assert!(err.to_string().contains("Invalid condition_id"));
262    }
263}