diff --git a/packages/wasm-ton/Cargo.toml b/packages/wasm-ton/Cargo.toml index 0f391bcf759..f1c3c08fbdf 100644 --- a/packages/wasm-ton/Cargo.toml +++ b/packages/wasm-ton/Cargo.toml @@ -25,7 +25,7 @@ crc = "3" sha2 = "0.10" num-bigint = "0.4" tlb-ton = { version = "0.7.3", features = ["sha2"] } -ton-contracts = { version = "0.7.3", features = ["wallet"] } +ton-contracts = { version = "0.7.3", features = ["wallet", "jetton"] } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/packages/wasm-ton/src/builder/build.rs b/packages/wasm-ton/src/builder/build.rs index 15820d12a1c..9750ebf0a2f 100644 --- a/packages/wasm-ton/src/builder/build.rs +++ b/packages/wasm-ton/src/builder/build.rs @@ -8,6 +8,7 @@ use tlb_ton::{ ser::CellSerializeExt, BagOfCells, BagOfCellsArgs, Cell, MsgAddress, }; +use ton_contracts::jetton::{ForwardPayload, ForwardPayloadComment, JettonTransfer}; use ton_contracts::wallet::v4r2::{WalletV4R2ExternalBody, WalletV4R2SignBody, V4R2}; use ton_contracts::wallet::WalletVersion; @@ -18,8 +19,6 @@ use crate::error::WasmTonError; const WHALES_DEPOSIT_OPCODE: u32 = 0x7bcd1fef; const WHALES_WITHDRAW_OPCODE: u32 = 0xda803efd; const SINGLE_NOMINATOR_WITHDRAW_OPCODE: u32 = 0x00001000; -// Jetton transfer opcode -const JETTON_TRANSFER_OPCODE: u32 = 0x0f8a7ea5; const BOC_ARGS: BagOfCellsArgs = BagOfCellsArgs { has_idx: false, @@ -294,58 +293,21 @@ fn build_jetton_transfer_body( forward_ton_amount: u64, memo: Option<&str>, ) -> Result { - let mut builder = Cell::builder(); - builder - .pack(JETTON_TRANSFER_OPCODE, ()) - .map_err(|e| WasmTonError::new(&format!("jetton: failed to write opcode: {e}")))?; - builder - .pack(query_id, ()) - .map_err(|e| WasmTonError::new(&format!("jetton: failed to write query_id: {e}")))?; - builder - .pack_as::<_, &Grams>(&BigUint::from(amount), ()) - .map_err(|e| WasmTonError::new(&format!("jetton: failed to write amount: {e}")))?; - builder - .pack(destination, ()) - .map_err(|e| WasmTonError::new(&format!("jetton: failed to write destination: {e}")))?; - builder.pack(response_destination, ()).map_err(|e| { - WasmTonError::new(&format!( - "jetton: failed to write response_destination: {e}" - )) - })?; - // custom_payload: Maybe ^Cell = None - builder.pack(false, ()).map_err(|e| { - WasmTonError::new(&format!("jetton: failed to write custom_payload flag: {e}")) - })?; - builder - .pack_as::<_, &Grams>(&BigUint::from(forward_ton_amount), ()) - .map_err(|e| { - WasmTonError::new(&format!("jetton: failed to write forward_ton_amount: {e}")) - })?; - - // forward_payload: Either Cell ^Cell - if let Some(text) = memo { - builder.pack(false, ()).map_err(|e| { - WasmTonError::new(&format!( - "jetton: failed to write forward_payload flag: {e}" - )) - })?; - builder - .pack(0u32, ()) - .map_err(|e| WasmTonError::new(&format!("jetton: failed to write memo opcode: {e}")))?; - for byte in text.as_bytes() { - builder.pack(*byte, ()).map_err(|e| { - WasmTonError::new(&format!("jetton: failed to write memo byte: {e}")) - })?; - } - } else { - builder.pack(false, ()).map_err(|e| { - WasmTonError::new(&format!( - "jetton: failed to write forward_payload flag: {e}" - )) - })?; + let forward_payload = match memo { + Some(text) => ForwardPayload::Comment(ForwardPayloadComment::Text(text.to_string())), + None => ForwardPayload::Data(Cell::default()), + }; + JettonTransfer:: { + query_id, + amount: BigUint::from(amount), + dst: destination, + response_dst: response_destination, + custom_payload: None, + forward_ton_amount: BigUint::from(forward_ton_amount), + forward_payload, } - - Ok(builder.into_cell()) + .to_cell(()) + .map_err(|e| WasmTonError::new(&format!("jetton: failed to serialize transfer: {e}"))) } fn build_whales_deposit_body(query_id: u64) -> Result { diff --git a/packages/wasm-ton/src/parser.rs b/packages/wasm-ton/src/parser.rs index eac8c4a98e7..aad108c178d 100644 --- a/packages/wasm-ton/src/parser.rs +++ b/packages/wasm-ton/src/parser.rs @@ -42,7 +42,7 @@ const WHALES_DEPOSIT_OPCODE: u32 = 0x7bcd1fef; const WHALES_WITHDRAW_OPCODE: u32 = 0xda803efd; const SINGLE_NOMINATOR_WITHDRAW_OPCODE: u32 = 0x00001000; // 4096 -/// Parsed jetton transfer fields (manually parsed, not using ton_contracts JettonTransfer) +/// Parsed jetton transfer fields exposed to the JS layer. #[derive(Debug, Clone)] pub struct JettonTransferFields { pub query_id: u64, @@ -50,7 +50,6 @@ pub struct JettonTransferFields { pub destination: String, pub response_destination: String, pub forward_ton_amount: u64, - pub forward_payload: Option>, } /// A single send action parsed from the transaction @@ -200,17 +199,24 @@ fn parse_message_body(body: &Cell) -> Result { } if opcode == JETTON_TRANSFER_OPCODE { - let jetton = parse_jetton_transfer_body(&mut parser)?; - return Ok((Some(opcode), None, Some(jetton))); + let (jetton, memo) = parse_jetton_transfer_body(&mut parser, body)?; + return Ok((Some(opcode), memo, Some(jetton))); } // Other known opcodes Ok((Some(opcode), None, None)) } +/// Parse a jetton transfer body, returning the parsed fields and any text memo. +/// +/// Parses TEP-74 fields manually instead of using `JettonTransfer::::parse` due to +/// a bug in `tlbits` 0.7.3: `Remainder::unpack_as` for byte/string types passes `bits_left()` +/// (bits) to `BorrowCow` which expects bytes, causing "EOF" errors when parsing text comments. +/// See `test_tlbits_remainder_bug_prevents_crate_jetton_parse` for the proof. fn parse_jetton_transfer_body( parser: &mut tlb_ton::de::CellParser<'_>, -) -> Result { + body: &Cell, +) -> Result<(JettonTransferFields, Option), WasmTonError> { // query_id: uint64 let query_id: u64 = parser .unpack(()) @@ -238,10 +244,9 @@ fn parse_jetton_transfer_body( response_dst.to_base64_url_flags(false, false) }; - // custom_payload: Maybe ^Cell (skip it) + // custom_payload: Maybe ^Cell — skip if present let has_custom_payload: bool = parser.unpack(()).unwrap_or(false); if has_custom_payload { - // Skip the ref let _: Cell = parser.parse_as::<_, tlb_ton::Ref>(()).unwrap_or_default(); } @@ -251,14 +256,67 @@ fn parse_jetton_transfer_body( })?; let forward_ton_amount = biguint_to_u64(&forward_big); - Ok(JettonTransferFields { - query_id, - amount, - destination, - response_destination, - forward_ton_amount, - forward_payload: None, - }) + // forward_payload: Either Cell ^Cell — extract text memo if present + let memo = parse_forward_payload_memo(parser, body); + + Ok(( + JettonTransferFields { + query_id, + amount, + destination, + response_destination, + forward_ton_amount, + }, + memo, + )) +} + +/// Extract a text memo from `forward_payload:(Either Cell ^Cell)`. +/// +/// Handles both inline (bit=0) and ref (bit=1) forward_payload storage. +/// Returns `None` on any parse error or if the payload is not a text comment. +fn parse_forward_payload_memo( + parser: &mut tlb_ton::de::CellParser<'_>, + _body: &Cell, +) -> Option { + // Read the Either bit: 0 = inline, 1 = ref + let is_ref: bool = parser.unpack(()).ok()?; + + if is_ref { + // Payload is in the next ref cell — read the ref and parse from there + let ref_cell: Cell = parser.parse_as::<_, tlb_ton::Ref>(()).ok()?; + read_text_comment(&mut ref_cell.parser()) + } else { + // Payload is inline in the remaining bits + read_text_comment(parser) + } +} + +/// Read a TEP-74 text comment: `0x00000000` prefix followed by UTF-8 bytes. +/// Returns `None` if the data doesn't start with the comment prefix or is not valid text. +fn read_text_comment(parser: &mut tlb_ton::de::CellParser<'_>) -> Option { + const COMMENT_PREFIX: u32 = 0x0000_0000; + if parser.bits_left() < 32 { + return None; + } + let prefix: u32 = parser.unpack(()).ok()?; + if prefix != COMMENT_PREFIX { + return None; + } + let remaining = parser.bits_left() / 8; + let mut bytes = Vec::with_capacity(remaining); + for _ in 0..remaining { + match parser.unpack::(()) { + Ok(b) => bytes.push(b), + Err(_) => break, + } + } + let text = String::from_utf8_lossy(&bytes).into_owned(); + if text.is_empty() { + None + } else { + Some(text) + } } fn determine_transaction_type(actions: &[ParsedSendAction]) -> TransactionType { @@ -313,3 +371,86 @@ fn biguint_to_u64(v: &BigUint) -> u64 { v.to_u64_digits().first().copied().unwrap_or(0) } } + +#[cfg(test)] +mod parser_tests { + use super::*; + use crate::transaction::Transaction; + use base64::{engine::general_purpose::STANDARD, Engine}; + use tlb_ton::de::CellDeserialize; + use tlb_ton::Cell; + use ton_contracts::jetton::JettonTransfer; + use ton_contracts::wallet::v4r2::WalletV4R2Op; + + /// signedTokenSendTransaction.tx from sdk-coin-ton fixtures. + /// forward_payload is stored as a ref cell (Either bit=1) with memo "jetton testing". + const TOKEN_TX: &str = "te6cckECGgEABB0AAuGIAVSGb+UGjjP3lvt+zFA8wouI3McEd6CKbO2TwcZ3OfLKGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/pAMCD6RAHIygfL/8nQ7UTQgQFA1yH0BDBcgQEI9ApvoTGzkl8H4AXTP8glghBwbHVnupI4MOMNA4IQZHN0crqSXwbjDQYHAHgB+gD0BDD4J28iMFAKoSG+8uBQghBwbHVngx6xcIAYUATLBSbPFlj6Ahn0AMtpF8sfUmDLPyDJgED7AAYAilAEgQEI9Fkw7UTQgQFA1yDIAc8W9ADJ7VQBcrCOI4IQZHN0coMesXCAGFAFywVQA88WI/oCE8tqyx/LP8mAQPsAkl8D4gIBIAkQAgEgCg8CAVgLDAA9sp37UTQgQFA1yH0BDACyMoHy//J0AGBAQj0Cm+hMYAIBIA0OABmtznaiaEAga5Drhf/AABmvHfaiaEAQa5DrhY/AABG4yX7UTQ1wsfgAWb0kK29qJoQICga5D6AhhHDUCAhHpJN9KZEM5pA+n/mDeBKAG3gQFImHFZ8xhAT48oMI1xgg0x/TH9MfAvgju/Jk7UTQ0x/TH9P/9ATRUUO68qFRUbryogX5AVQQZPkQ8qP4ACSkyMsfUkDLH1Iwy/9SEPQAye1U+A8B0wchwACfbFGTINdKltMH1AL7AOgw4CHAAeMAIcAC4wABwAORMOMNA6TIyx8Syx/L/xITFBUAbtIH+gDU1CL5AAXIygcVy//J0Hd0gBjIywXLAiLPFlAF+gIUy2sSzMzJc/sAyEAUgQEI9FHypwIAcIEBCNcY+gDTP8hUIEeBAQj0UfKnghBub3RlcHSAGMjLBcsCUAbPFlAE+gIUy2oSyx/LP8lz+wACAGyBAQjXGPoA0z8wUiSBAQj0WfKnghBkc3RycHSAGMjLBcsCUAXPFlAD+gITy2rLHxLLP8lz+wAACvQAye1UAFEAAAAAKamjF9NTAQHUHhbX00VGZ3d2r8hbJxuz7PaxmuCOJ6kgckppQAFmQgABT9LR3Iqffskp0J9gWYO8Azlnb33BCMj8FqIUIGxGOZpiWgAAAAAAAAAAAAAAAAABGAGuD4p+pQAAAAAAAAAAQ7msoAgA/BGdBi/R01erquxJOvPgGKclBawUs3MAi0/IdctKQz8AKpDN/KDRxn7y32/ZigeYUXEbmOCO9BFNnbJ4OM7nPllGHoSBGQAkAAAAAGpldHRvbiB0ZXN0aW5nwHtw7A=="; + + /// Demonstrates a bug in `tlbits` 0.7.3 `Remainder` adapter that prevents + /// `JettonTransfer::::parse` from working on messages with text comments. + /// + /// Root cause: `Remainder::unpack_as` for byte-oriented types (`Cow<[u8]>`, + /// `Vec`, `Cow`, `String`) passes `bits_left()` (a value in bits) to + /// `BorrowCow` which expects the argument in bytes. This causes `BorrowCow` to + /// attempt reading `bits_left * 8` bits, resulting in an "EOF" error. + /// + /// File: `tlbits-0.7.3/src/as/remainder.rs` lines 40-51 and 65-76. + /// Upstream: https://github.com/mitinarseny/toner + /// + /// Until this is fixed upstream, we parse jetton transfer fields manually in + /// `parse_jetton_transfer_body` / `parse_forward_payload_memo`. + #[test] + fn test_tlbits_remainder_bug_prevents_crate_jetton_parse() { + let bytes = STANDARD.decode(TOKEN_TX).unwrap(); + let tx = Transaction::from_bytes(&bytes).unwrap(); + let sign_body = tx.sign_body(); + + let actions = match &sign_body.op { + WalletV4R2Op::Send(actions) => actions, + _ => panic!("expected Send op"), + }; + let body = &actions[0].message.body; + let ref_cell = &*body.references[0]; + + // BorrowCow with correct byte count works + use tlb_ton::bits::de::BitReaderExt; + let mut p1 = ref_cell.parser(); + let _: u32 = p1.unpack(()).unwrap(); // skip 0x00000000 comment prefix + let bytes_left = p1.bits_left() / 8; + let ok: Result, _> = + p1.unpack_as::<_, tlb_ton::bits::BorrowCow>(bytes_left); + assert_eq!(ok.unwrap().as_ref(), "jetton testing"); + + // Remainder passes bits_left() (112) to BorrowCow which expects bytes (14) + let mut p2 = ref_cell.parser(); + let _: u32 = p2.unpack(()).unwrap(); + let err = p2 + .unpack_as::(()) + .unwrap_err(); + assert_eq!(err.to_string(), "EOF"); + + // JettonTransfer::::parse fails due to the same Remainder bug + let mut parser = body.parser(); + let err = JettonTransfer::::parse(&mut parser, ()).unwrap_err(); + assert!( + err.to_string().contains("EOF"), + "expected EOF error, got: {err}" + ); + } + + #[test] + fn test_jetton_transfer_memo_from_ref_cell() { + // Verifies that memos stored as ref cells (forward_payload bit=1) are correctly extracted. + let bytes = STANDARD.decode(TOKEN_TX).unwrap(); + let tx = Transaction::from_bytes(&bytes).unwrap(); + let parsed = parse_from_transaction(&tx).unwrap(); + assert_eq!(parsed.transaction_type, TransactionType::TokenTransfer); + let action = &parsed.send_actions[0]; + assert!(action.jetton_transfer.is_some()); + assert_eq!(action.memo.as_deref(), Some("jetton testing")); + assert_eq!( + action.jetton_transfer.as_ref().unwrap().amount, + 1_000_000_000 + ); + } +}