Skip to content

Commit cd95209

Browse files
committed
feat: use ton-contracts JettonTransfer for building; fix forward_payload memo parsing
- Builder: replace manual bit-by-bit jetton transfer body construction with ton-contracts JettonTransfer, ensuring correct TEP-74 serialization - Enable jetton feature in ton-contracts dependency - Parser: add forward_payload memo extraction from both inline (bit=0) and ref cell (bit=1) storage, previously memo was silently dropped - Remove unused forward_payload field from JettonTransferFields BTC-3249
1 parent 56f1797 commit cd95209

3 files changed

Lines changed: 115 additions & 69 deletions

File tree

packages/wasm-ton/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ crc = "3"
2525
sha2 = "0.10"
2626
num-bigint = "0.4"
2727
tlb-ton = { version = "0.7.3", features = ["sha2"] }
28-
ton-contracts = { version = "0.7.3", features = ["wallet"] }
28+
ton-contracts = { version = "0.7.3", features = ["wallet", "jetton"] }
2929

3030
[dev-dependencies]
3131
wasm-bindgen-test = "0.3"

packages/wasm-ton/src/builder/build.rs

Lines changed: 15 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use tlb_ton::{
88
ser::CellSerializeExt,
99
BagOfCells, BagOfCellsArgs, Cell, MsgAddress,
1010
};
11+
use ton_contracts::jetton::{ForwardPayload, ForwardPayloadComment, JettonTransfer};
1112
use ton_contracts::wallet::v4r2::{WalletV4R2ExternalBody, WalletV4R2SignBody, V4R2};
1213
use ton_contracts::wallet::WalletVersion;
1314

@@ -18,8 +19,6 @@ use crate::error::WasmTonError;
1819
const WHALES_DEPOSIT_OPCODE: u32 = 0x7bcd1fef;
1920
const WHALES_WITHDRAW_OPCODE: u32 = 0xda803efd;
2021
const SINGLE_NOMINATOR_WITHDRAW_OPCODE: u32 = 0x00001000;
21-
// Jetton transfer opcode
22-
const JETTON_TRANSFER_OPCODE: u32 = 0x0f8a7ea5;
2322

2423
const BOC_ARGS: BagOfCellsArgs = BagOfCellsArgs {
2524
has_idx: false,
@@ -294,58 +293,21 @@ fn build_jetton_transfer_body(
294293
forward_ton_amount: u64,
295294
memo: Option<&str>,
296295
) -> Result<Cell, WasmTonError> {
297-
let mut builder = Cell::builder();
298-
builder
299-
.pack(JETTON_TRANSFER_OPCODE, ())
300-
.map_err(|e| WasmTonError::new(&format!("jetton: failed to write opcode: {e}")))?;
301-
builder
302-
.pack(query_id, ())
303-
.map_err(|e| WasmTonError::new(&format!("jetton: failed to write query_id: {e}")))?;
304-
builder
305-
.pack_as::<_, &Grams>(&BigUint::from(amount), ())
306-
.map_err(|e| WasmTonError::new(&format!("jetton: failed to write amount: {e}")))?;
307-
builder
308-
.pack(destination, ())
309-
.map_err(|e| WasmTonError::new(&format!("jetton: failed to write destination: {e}")))?;
310-
builder.pack(response_destination, ()).map_err(|e| {
311-
WasmTonError::new(&format!(
312-
"jetton: failed to write response_destination: {e}"
313-
))
314-
})?;
315-
// custom_payload: Maybe ^Cell = None
316-
builder.pack(false, ()).map_err(|e| {
317-
WasmTonError::new(&format!("jetton: failed to write custom_payload flag: {e}"))
318-
})?;
319-
builder
320-
.pack_as::<_, &Grams>(&BigUint::from(forward_ton_amount), ())
321-
.map_err(|e| {
322-
WasmTonError::new(&format!("jetton: failed to write forward_ton_amount: {e}"))
323-
})?;
324-
325-
// forward_payload: Either Cell ^Cell
326-
if let Some(text) = memo {
327-
builder.pack(false, ()).map_err(|e| {
328-
WasmTonError::new(&format!(
329-
"jetton: failed to write forward_payload flag: {e}"
330-
))
331-
})?;
332-
builder
333-
.pack(0u32, ())
334-
.map_err(|e| WasmTonError::new(&format!("jetton: failed to write memo opcode: {e}")))?;
335-
for byte in text.as_bytes() {
336-
builder.pack(*byte, ()).map_err(|e| {
337-
WasmTonError::new(&format!("jetton: failed to write memo byte: {e}"))
338-
})?;
339-
}
340-
} else {
341-
builder.pack(false, ()).map_err(|e| {
342-
WasmTonError::new(&format!(
343-
"jetton: failed to write forward_payload flag: {e}"
344-
))
345-
})?;
296+
let forward_payload = match memo {
297+
Some(text) => ForwardPayload::Comment(ForwardPayloadComment::Text(text.to_string())),
298+
None => ForwardPayload::Data(Cell::default()),
299+
};
300+
JettonTransfer::<Cell> {
301+
query_id,
302+
amount: BigUint::from(amount),
303+
dst: destination,
304+
response_dst: response_destination,
305+
custom_payload: None,
306+
forward_ton_amount: BigUint::from(forward_ton_amount),
307+
forward_payload,
346308
}
347-
348-
Ok(builder.into_cell())
309+
.to_cell(())
310+
.map_err(|e| WasmTonError::new(&format!("jetton: failed to serialize transfer: {e}")))
349311
}
350312

351313
fn build_whales_deposit_body(query_id: u64) -> Result<Cell, WasmTonError> {

packages/wasm-ton/src/parser.rs

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,14 @@ const WHALES_DEPOSIT_OPCODE: u32 = 0x7bcd1fef;
4242
const WHALES_WITHDRAW_OPCODE: u32 = 0xda803efd;
4343
const SINGLE_NOMINATOR_WITHDRAW_OPCODE: u32 = 0x00001000; // 4096
4444

45-
/// Parsed jetton transfer fields (manually parsed, not using ton_contracts JettonTransfer)
45+
/// Parsed jetton transfer fields exposed to the JS layer.
4646
#[derive(Debug, Clone)]
4747
pub struct JettonTransferFields {
4848
pub query_id: u64,
4949
pub amount: u64,
5050
pub destination: String,
5151
pub response_destination: String,
5252
pub forward_ton_amount: u64,
53-
pub forward_payload: Option<Vec<u8>>,
5453
}
5554

5655
/// A single send action parsed from the transaction
@@ -200,17 +199,23 @@ fn parse_message_body(body: &Cell) -> Result<BodyParseResult, WasmTonError> {
200199
}
201200

202201
if opcode == JETTON_TRANSFER_OPCODE {
203-
let jetton = parse_jetton_transfer_body(&mut parser)?;
204-
return Ok((Some(opcode), None, Some(jetton)));
202+
let (jetton, memo) = parse_jetton_transfer_body(&mut parser, body)?;
203+
return Ok((Some(opcode), memo, Some(jetton)));
205204
}
206205

207206
// Other known opcodes
208207
Ok((Some(opcode), None, None))
209208
}
210209

210+
/// Parse a jetton transfer body, returning the parsed fields and any text memo.
211+
///
212+
/// Parses TEP-74 fields manually for robustness across different wallet implementations.
213+
/// Uses `ton_contracts::jetton::ForwardPayload` to extract the memo from `forward_payload`,
214+
/// handling both inline (Either bit=0) and ref (Either bit=1) cases.
211215
fn parse_jetton_transfer_body(
212216
parser: &mut tlb_ton::de::CellParser<'_>,
213-
) -> Result<JettonTransferFields, WasmTonError> {
217+
body: &Cell,
218+
) -> Result<(JettonTransferFields, Option<String>), WasmTonError> {
214219
// query_id: uint64
215220
let query_id: u64 = parser
216221
.unpack(())
@@ -238,10 +243,9 @@ fn parse_jetton_transfer_body(
238243
response_dst.to_base64_url_flags(false, false)
239244
};
240245

241-
// custom_payload: Maybe ^Cell (skip it)
246+
// custom_payload: Maybe ^Cell skip if present
242247
let has_custom_payload: bool = parser.unpack(()).unwrap_or(false);
243248
if has_custom_payload {
244-
// Skip the ref
245249
let _: Cell = parser.parse_as::<_, tlb_ton::Ref>(()).unwrap_or_default();
246250
}
247251

@@ -251,14 +255,67 @@ fn parse_jetton_transfer_body(
251255
})?;
252256
let forward_ton_amount = biguint_to_u64(&forward_big);
253257

254-
Ok(JettonTransferFields {
255-
query_id,
256-
amount,
257-
destination,
258-
response_destination,
259-
forward_ton_amount,
260-
forward_payload: None,
261-
})
258+
// forward_payload: Either Cell ^Cell — extract text memo if present
259+
let memo = parse_forward_payload_memo(parser, body);
260+
261+
Ok((
262+
JettonTransferFields {
263+
query_id,
264+
amount,
265+
destination,
266+
response_destination,
267+
forward_ton_amount,
268+
},
269+
memo,
270+
))
271+
}
272+
273+
/// Extract a text memo from `forward_payload:(Either Cell ^Cell)`.
274+
///
275+
/// Handles both inline (bit=0) and ref (bit=1) forward_payload storage.
276+
/// Returns `None` on any parse error or if the payload is not a text comment.
277+
fn parse_forward_payload_memo(
278+
parser: &mut tlb_ton::de::CellParser<'_>,
279+
_body: &Cell,
280+
) -> Option<String> {
281+
// Read the Either bit: 0 = inline, 1 = ref
282+
let is_ref: bool = parser.unpack(()).ok()?;
283+
284+
if is_ref {
285+
// Payload is in the next ref cell — read the ref and parse from there
286+
let ref_cell: Cell = parser.parse_as::<_, tlb_ton::Ref>(()).ok()?;
287+
read_text_comment(&mut ref_cell.parser())
288+
} else {
289+
// Payload is inline in the remaining bits
290+
read_text_comment(parser)
291+
}
292+
}
293+
294+
/// Read a TEP-74 text comment: `0x00000000` prefix followed by UTF-8 bytes.
295+
/// Returns `None` if the data doesn't start with the comment prefix or is not valid text.
296+
fn read_text_comment(parser: &mut tlb_ton::de::CellParser<'_>) -> Option<String> {
297+
const COMMENT_PREFIX: u32 = 0x0000_0000;
298+
if parser.bits_left() < 32 {
299+
return None;
300+
}
301+
let prefix: u32 = parser.unpack(()).ok()?;
302+
if prefix != COMMENT_PREFIX {
303+
return None;
304+
}
305+
let remaining = parser.bits_left() / 8;
306+
let mut bytes = Vec::with_capacity(remaining);
307+
for _ in 0..remaining {
308+
match parser.unpack::<u8>(()) {
309+
Ok(b) => bytes.push(b),
310+
Err(_) => break,
311+
}
312+
}
313+
let text = String::from_utf8_lossy(&bytes).into_owned();
314+
if text.is_empty() {
315+
None
316+
} else {
317+
Some(text)
318+
}
262319
}
263320

264321
fn determine_transaction_type(actions: &[ParsedSendAction]) -> TransactionType {
@@ -313,3 +370,30 @@ fn biguint_to_u64(v: &BigUint) -> u64 {
313370
v.to_u64_digits().first().copied().unwrap_or(0)
314371
}
315372
}
373+
374+
#[cfg(test)]
375+
mod parser_tests {
376+
use super::*;
377+
use crate::transaction::Transaction;
378+
use base64::{engine::general_purpose::STANDARD, Engine};
379+
380+
/// signedTokenSendTransaction.tx from sdk-coin-ton fixtures.
381+
/// forward_payload is stored as a ref cell (Either bit=1) with memo "jetton testing".
382+
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==";
383+
384+
#[test]
385+
fn test_jetton_transfer_memo_from_ref_cell() {
386+
// Verifies that memos stored as ref cells (forward_payload bit=1) are correctly extracted.
387+
let bytes = STANDARD.decode(TOKEN_TX).unwrap();
388+
let tx = Transaction::from_bytes(&bytes).unwrap();
389+
let parsed = parse_from_transaction(&tx).unwrap();
390+
assert_eq!(parsed.transaction_type, TransactionType::TokenTransfer);
391+
let action = &parsed.send_actions[0];
392+
assert!(action.jetton_transfer.is_some());
393+
assert_eq!(action.memo.as_deref(), Some("jetton testing"));
394+
assert_eq!(
395+
action.jetton_transfer.as_ref().unwrap().amount,
396+
1_000_000_000
397+
);
398+
}
399+
}

0 commit comments

Comments
 (0)