From 079eda9f4e85ad7d3b83e649fb673d9ff985c09c Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Wed, 1 Apr 2026 11:27:00 -0700 Subject: [PATCH] fix: wasm-ton parser gaps and tx id computation - Parse withdraw amount from SingleNominator and Whales withdrawal message bodies, exposed as withdrawAmount on ParsedSendAction - Fix Transaction.id() to use cell hash instead of SHA-256 of BOC bytes, matching TON blockchain convention and TonWeb behavior - Expose withdrawAmount in JS via try_into_js_value and parser.ts types - Strengthen transaction tests to assert exact id and withdrawAmount values BTC-3246 --- packages/wasm-ton/js/parser.ts | 2 + packages/wasm-ton/src/parser.rs | 65 +++++++++++--- packages/wasm-ton/src/transaction.rs | 85 +++++++++++++++++-- .../wasm-ton/src/wasm/try_into_js_value.rs | 9 ++ packages/wasm-ton/test/transaction.ts | 19 ++++- 5 files changed, 162 insertions(+), 18 deletions(-) diff --git a/packages/wasm-ton/js/parser.ts b/packages/wasm-ton/js/parser.ts index 92a14fee5b7..936c4672d74 100644 --- a/packages/wasm-ton/js/parser.ts +++ b/packages/wasm-ton/js/parser.ts @@ -27,6 +27,8 @@ export interface ParsedSendAction { bodyOpcode?: number; memo?: string; jettonTransfer?: JettonTransferFields; + /** Withdraw amount from the message body (SingleNominator/Whales withdrawal types). */ + withdrawAmount?: bigint; } /** A fully parsed TON transaction */ diff --git a/packages/wasm-ton/src/parser.rs b/packages/wasm-ton/src/parser.rs index aad108c178d..a76f4077e63 100644 --- a/packages/wasm-ton/src/parser.rs +++ b/packages/wasm-ton/src/parser.rs @@ -5,8 +5,14 @@ use ton_contracts::wallet::v4r2::{WalletV4R2Op, WalletV4R2SignBody}; use crate::error::WasmTonError; use crate::transaction::Transaction; -/// Body parse result: (opcode, memo, jetton_transfer) -type BodyParseResult = (Option, Option, Option); +/// Parsed result of a message body. +#[derive(Debug, Default)] +struct BodyParseResult { + opcode: Option, + memo: Option, + jetton_transfer: Option, + withdraw_amount: Option, +} /// Transaction type enum #[derive(Debug, Clone, PartialEq, Eq)] @@ -64,6 +70,8 @@ pub struct ParsedSendAction { pub state_init: bool, pub memo: Option, pub jetton_transfer: Option, + /// Withdraw amount encoded in the message body (SingleNominator/Whales withdrawal types). + pub withdraw_amount: Option, } /// A fully parsed TON transaction @@ -142,7 +150,7 @@ fn parse_sign_body_actions( let state_init = msg.init.is_some(); // Parse body - let (body_opcode, memo, jetton_transfer) = parse_message_body(&msg.body)?; + let body = parse_message_body(&msg.body)?; parsed.push(ParsedSendAction { mode: action.mode, @@ -154,10 +162,11 @@ fn parse_sign_body_actions( destination_bounceable: bounceable_str, amount, bounce, - body_opcode, + body_opcode: body.opcode, state_init, - memo, - jetton_transfer, + memo: body.memo, + jetton_transfer: body.jetton_transfer, + withdraw_amount: body.withdraw_amount, }); } Ok(parsed) @@ -172,12 +181,12 @@ fn parse_message_body(body: &Cell) -> Result { // Empty body if bits_left == 0 { - return Ok((None, None, None)); + return Ok(BodyParseResult::default()); } // Need at least 32 bits for opcode if bits_left < 32 { - return Ok((None, None, None)); + return Ok(BodyParseResult::default()); } let opcode: u32 = parser @@ -195,16 +204,50 @@ fn parse_message_body(body: &Cell) -> Result { }; } let memo = String::from_utf8_lossy(&bytes).to_string(); - return Ok((Some(0), Some(memo), None)); + return Ok(BodyParseResult { + opcode: Some(0), + memo: Some(memo), + ..Default::default() + }); } if opcode == JETTON_TRANSFER_OPCODE { let (jetton, memo) = parse_jetton_transfer_body(&mut parser, body)?; - return Ok((Some(opcode), memo, Some(jetton))); + return Ok(BodyParseResult { + opcode: Some(opcode), + memo, + jetton_transfer: Some(jetton), + ..Default::default() + }); + } + + if opcode == WHALES_WITHDRAW_OPCODE || opcode == SINGLE_NOMINATOR_WITHDRAW_OPCODE { + let amount = parse_withdraw_amount_body(&mut parser)?; + return Ok(BodyParseResult { + opcode: Some(opcode), + withdraw_amount: Some(amount), + ..Default::default() + }); } // Other known opcodes - Ok((Some(opcode), None, None)) + Ok(BodyParseResult { + opcode: Some(opcode), + ..Default::default() + }) +} + +/// Parse query_id + amount from a withdrawal message body (Whales or SingleNominator). +fn parse_withdraw_amount_body( + parser: &mut tlb_ton::de::CellParser<'_>, +) -> Result { + let _query_id: u64 = parser + .unpack(()) + .map_err(|e| WasmTonError::new(&format!("withdraw: failed to read query_id: {e}")))?; + let amount_big: BigUint = parser + .unpack_as::<_, Grams>(()) + .map_err(|e| WasmTonError::new(&format!("withdraw: failed to read amount: {e}")))?; + Ok(biguint_to_u64(&amount_big)) } /// Parse a jetton transfer body, returning the parsed fields and any text memo. diff --git a/packages/wasm-ton/src/transaction.rs b/packages/wasm-ton/src/transaction.rs index ae11f2f03cb..7c053c7167d 100644 --- a/packages/wasm-ton/src/transaction.rs +++ b/packages/wasm-ton/src/transaction.rs @@ -1,5 +1,4 @@ use base64::{engine::general_purpose::STANDARD, Engine}; -use sha2::{Digest, Sha256}; use tlb_ton::{ message::{CommonMsgInfo, Message}, ser::CellSerializeExt, @@ -16,6 +15,9 @@ pub struct Transaction { boc_bytes: Vec, /// The parsed external message pub message: Message, + /// The root cell hash, computed from the original parsed cell (not re-serialized). + /// This matches the standard TON cell representation hash that TonWeb and explorers compute. + root_cell_hash: [u8; 32], } const BOC_ARGS: BagOfCellsArgs = BagOfCellsArgs { @@ -31,12 +33,17 @@ impl Transaction { let root = boc .single_root() .ok_or_else(|| WasmTonError::new("BOC must have exactly one root cell"))?; + // Compute the cell hash from the original parsed cell BEFORE any re-serialization. + // This preserves the exact bit layout of the original BOC, including inner message + // bodies that may not round-trip perfectly through tlb-ton's typed serialization. + let root_cell_hash = root.hash(); let message: Message = root .parse_fully(()) .map_err(|e| WasmTonError::new(&format!("failed to parse message: {e}")))?; Ok(Transaction { boc_bytes: bytes.to_vec(), message, + root_cell_hash, }) } @@ -91,8 +98,9 @@ impl Transaction { let mut sig = [0u8; 64]; sig.copy_from_slice(signature); self.message.body.signature = sig; - // Re-serialize + // Re-serialize and update the root cell hash from the new BOC self.boc_bytes = self.serialize_boc()?; + self.root_cell_hash = self.compute_root_cell_hash()?; Ok(()) } @@ -106,10 +114,22 @@ impl Transaction { Ok(STANDARD.encode(&self.boc_bytes)) } - /// Get the transaction ID (SHA-256 hash of the BOC, base64url encoded). + /// Get the transaction ID (cell hash of the root message cell, base64url encoded). + /// + /// Uses the standard TON cell representation hash, computed from the original parsed cell + /// (not re-serialized). This matches what TonWeb and TON explorers compute. pub fn id(&self) -> String { - let hash = Sha256::digest(&self.boc_bytes); - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash) + base64::engine::general_purpose::URL_SAFE.encode(self.root_cell_hash) + } + + /// Compute the root cell hash from the current BOC bytes. + fn compute_root_cell_hash(&self) -> Result<[u8; 32], WasmTonError> { + let boc = BagOfCells::deserialize(&self.boc_bytes) + .map_err(|e| WasmTonError::new(&format!("failed to parse BOC for hash: {e}")))?; + let root = boc + .single_root() + .ok_or_else(|| WasmTonError::new("BOC must have exactly one root cell"))?; + Ok(root.hash()) } fn serialize_boc(&self) -> Result, WasmTonError> { @@ -122,3 +142,58 @@ impl Transaction { .map_err(|e| WasmTonError::new(&format!("failed to serialize BOC: {e}"))) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Simple send BOC from BitGoJS test fixtures. + const SIMPLE_SEND_BOC: &str = "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w=="; + + /// SingleNominator withdraw BOC from BitGoJS test fixtures. + const SINGLE_NOMINATOR_WITHDRAW_BOC: &str = "te6cckECGAEAA8MAAuGIADZN0H0n1tz6xkYgWqJSRmkURKYajjEgXeawBo9cifPIGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmpoxdJlgLSAAAAAAADgEXAgE0AhYBFP8A9KQT9LzyyAsDAgEgBBECAUgFCALm0AHQ0wMhcbCSXwTgItdJwSCSXwTgAtMfIYIQcGx1Z70ighBkc3RyvbCSXwXgA/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+wAACvQAye1UAFEAAAAAKamjF8DDudwJkyEh7jUbJEjFCjriVxsSlRJFyF872V1eegb4QACPQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr6A613oAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAHA0/PoUC5EIEyWuPg=="; + + #[test] + fn test_simple_send_id() { + let tx = Transaction::from_base64(SIMPLE_SEND_BOC).unwrap(); + assert_eq!(tx.id(), "tuyOkyFUMv_neV_FeNBH24Nd4cML2jUgDP4zjGkuOFI="); + } + + #[test] + fn test_single_nominator_withdraw_id() { + let tx = Transaction::from_base64(SINGLE_NOMINATOR_WITHDRAW_BOC).unwrap(); + assert_eq!(tx.id(), "n1rr-QL61WZ7UJN7ESH2iPQO7toTy9WLqXoSIG1JtXg="); + } + + #[test] + fn test_id_uses_original_cell_hash_not_reserialized() { + // Verify that the ID comes from the original parsed cell, not from re-serialization. + // The SingleNominator withdraw BOC has an inner message body that doesn't round-trip + // through tlb-ton's typed serialization, so re-serialized hash would differ. + let tx = Transaction::from_base64(SINGLE_NOMINATOR_WITHDRAW_BOC).unwrap(); + + // Alternative implementation: cell hash of the re-serialized message struct. + // This differs from the original cell hash because tlb-ton's typed serialization + // does not perfectly reconstruct all inner message body bits for complex transactions. + let reserialized_hash = tx + .message + .to_cell(()) + .map(|cell| base64::engine::general_purpose::URL_SAFE.encode(cell.hash())) + .unwrap(); + + // The original cell hash (what we now use) + let original_hash = tx.id(); + + // For the SingleNominator withdraw, these should differ, proving the fix matters + assert_ne!( + reserialized_hash, original_hash, + "re-serialized hash should differ from original for SingleNominator withdraw" + ); + + // The original hash matches the expected legacy ID + assert_eq!( + original_hash, + "n1rr-QL61WZ7UJN7ESH2iPQO7toTy9WLqXoSIG1JtXg=" + ); + } +} diff --git a/packages/wasm-ton/src/wasm/try_into_js_value.rs b/packages/wasm-ton/src/wasm/try_into_js_value.rs index 660e5b8bd6e..c1577767066 100644 --- a/packages/wasm-ton/src/wasm/try_into_js_value.rs +++ b/packages/wasm-ton/src/wasm/try_into_js_value.rs @@ -209,6 +209,15 @@ impl TryIntoJsValue for ParsedSendAction { .map_err(|_| JsConversionError::new("Failed to set jettonTransfer"))?; } + if let Some(withdraw_amount) = self.withdraw_amount { + js_sys::Reflect::set( + &obj, + &JsValue::from_str("withdrawAmount"), + &TryIntoJsValue::try_to_js_value(&withdraw_amount)?, + ) + .map_err(|_| JsConversionError::new("Failed to set withdrawAmount"))?; + } + Ok(obj.into()) } } diff --git a/packages/wasm-ton/test/transaction.ts b/packages/wasm-ton/test/transaction.ts index 434e128bff2..38a28a4ad40 100644 --- a/packages/wasm-ton/test/transaction.ts +++ b/packages/wasm-ton/test/transaction.ts @@ -20,10 +20,21 @@ describe("Transaction", () => { it("should deserialize a signed send transaction", () => { const tx = fromBase64(signedSendTx); assert.ok(tx); - assert.ok(tx.id); assert.ok(tx.destination); }); + it("should compute cell hash as transaction id", () => { + const tx = fromBase64(signedSendTx); + // Cell hash of the root external message cell, base64url with padding + assert.equal(tx.id, "tuyOkyFUMv_neV_FeNBH24Nd4cML2jUgDP4zjGkuOFI="); + }); + + it("should compute correct id for single nominator withdraw", () => { + const tx = fromBase64(singleNominatorWithdrawTx); + // Must match legacy TonWeb hash, not the re-serialized cell hash + assert.equal(tx.id, "n1rr-QL61WZ7UJN7ESH2iPQO7toTy9WLqXoSIG1JtXg="); + }); + it("should get signable payload", () => { const tx = fromBase64(signedSendTx); const payload = tx.signablePayload(); @@ -45,6 +56,7 @@ describe("Transaction", () => { assert.ok(parsed.sendActions.length > 0); assert.ok(parsed.sender); assert.ok(parsed.signature); + assert.equal(parsed.sendActions[0].withdrawAmount, undefined); }); it("should parse a whales deposit transaction", () => { @@ -52,13 +64,16 @@ describe("Transaction", () => { const parsed = parseTransaction(tx); assert.equal(parsed.transactionType, "WhalesDeposit"); assert.ok(parsed.sendActions.length > 0); + assert.equal(parsed.sendActions[0].withdrawAmount, undefined); }); - it("should parse a single nominator withdraw transaction", () => { + it("should parse a single nominator withdraw transaction with withdrawAmount", () => { const tx = fromBase64(singleNominatorWithdrawTx); const parsed = parseTransaction(tx); assert.equal(parsed.transactionType, "SingleNominatorWithdraw"); assert.ok(parsed.sendActions.length > 0); + assert.equal(typeof parsed.sendActions[0].withdrawAmount, "bigint"); + assert.equal(parsed.sendActions[0].withdrawAmount, 932178112330000n); }); }); });