Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/wasm-ton/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 15 additions & 53 deletions packages/wasm-ton/src/builder/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -294,58 +293,21 @@ fn build_jetton_transfer_body(
forward_ton_amount: u64,
memo: Option<&str>,
) -> Result<Cell, WasmTonError> {
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::<Cell> {
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<Cell, WasmTonError> {
Expand Down
171 changes: 156 additions & 15 deletions packages/wasm-ton/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,14 @@ 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,
pub amount: u64,
pub destination: String,
pub response_destination: String,
pub forward_ton_amount: u64,
pub forward_payload: Option<Vec<u8>>,
}

/// A single send action parsed from the transaction
Expand Down Expand Up @@ -200,17 +199,24 @@ fn parse_message_body(body: &Cell) -> Result<BodyParseResult, WasmTonError> {
}

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::<Cell>::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<JettonTransferFields, WasmTonError> {
body: &Cell,
) -> Result<(JettonTransferFields, Option<String>), WasmTonError> {
// query_id: uint64
let query_id: u64 = parser
.unpack(())
Expand Down Expand Up @@ -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();
}

Expand All @@ -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<String> {
// 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<String> {
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::<u8>(()) {
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 {
Expand Down Expand Up @@ -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::<Cell>::parse` from working on messages with text comments.
///
/// Root cause: `Remainder::unpack_as` for byte-oriented types (`Cow<[u8]>`,
/// `Vec<u8>`, `Cow<str>`, `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<std::borrow::Cow<str>, _> =
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::<String, tlb_ton::bits::Remainder>(())
.unwrap_err();
assert_eq!(err.to_string(), "EOF");

// JettonTransfer::<Cell>::parse fails due to the same Remainder bug
let mut parser = body.parser();
let err = JettonTransfer::<Cell>::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
);
}
}
Loading