Skip to content

Commit 9d290b2

Browse files
committed
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
1 parent e5b0770 commit 9d290b2

5 files changed

Lines changed: 159 additions & 18 deletions

File tree

packages/wasm-ton/js/parser.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface ParsedSendAction {
2727
bodyOpcode?: number;
2828
memo?: string;
2929
jettonTransfer?: JettonTransferFields;
30+
/** Withdraw amount from the message body (SingleNominator/Whales withdrawal types). */
31+
withdrawAmount?: bigint;
3032
}
3133

3234
/** A fully parsed TON transaction */

packages/wasm-ton/src/parser.rs

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@ use ton_contracts::wallet::v4r2::{WalletV4R2Op, WalletV4R2SignBody};
55
use crate::error::WasmTonError;
66
use crate::transaction::Transaction;
77

8-
/// Body parse result: (opcode, memo, jetton_transfer)
9-
type BodyParseResult = (Option<u32>, Option<String>, Option<JettonTransferFields>);
8+
/// Parsed result of a message body.
9+
#[derive(Debug, Default)]
10+
struct BodyParseResult {
11+
opcode: Option<u32>,
12+
memo: Option<String>,
13+
jetton_transfer: Option<JettonTransferFields>,
14+
withdraw_amount: Option<u64>,
15+
}
1016

1117
/// Transaction type enum
1218
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -65,6 +71,8 @@ pub struct ParsedSendAction {
6571
pub state_init: bool,
6672
pub memo: Option<String>,
6773
pub jetton_transfer: Option<JettonTransferFields>,
74+
/// Withdraw amount encoded in the message body (SingleNominator/Whales withdrawal types).
75+
pub withdraw_amount: Option<u64>,
6876
}
6977

7078
/// A fully parsed TON transaction
@@ -143,7 +151,7 @@ fn parse_sign_body_actions(
143151
let state_init = msg.init.is_some();
144152

145153
// Parse body
146-
let (body_opcode, memo, jetton_transfer) = parse_message_body(&msg.body)?;
154+
let body = parse_message_body(&msg.body)?;
147155

148156
parsed.push(ParsedSendAction {
149157
mode: action.mode,
@@ -155,10 +163,11 @@ fn parse_sign_body_actions(
155163
destination_bounceable: bounceable_str,
156164
amount,
157165
bounce,
158-
body_opcode,
166+
body_opcode: body.opcode,
159167
state_init,
160-
memo,
161-
jetton_transfer,
168+
memo: body.memo,
169+
jetton_transfer: body.jetton_transfer,
170+
withdraw_amount: body.withdraw_amount,
162171
});
163172
}
164173
Ok(parsed)
@@ -173,12 +182,12 @@ fn parse_message_body(body: &Cell) -> Result<BodyParseResult, WasmTonError> {
173182

174183
// Empty body
175184
if bits_left == 0 {
176-
return Ok((None, None, None));
185+
return Ok(BodyParseResult::default());
177186
}
178187

179188
// Need at least 32 bits for opcode
180189
if bits_left < 32 {
181-
return Ok((None, None, None));
190+
return Ok(BodyParseResult::default());
182191
}
183192

184193
let opcode: u32 = parser
@@ -196,16 +205,49 @@ fn parse_message_body(body: &Cell) -> Result<BodyParseResult, WasmTonError> {
196205
};
197206
}
198207
let memo = String::from_utf8_lossy(&bytes).to_string();
199-
return Ok((Some(0), Some(memo), None));
208+
return Ok(BodyParseResult {
209+
opcode: Some(0),
210+
memo: Some(memo),
211+
..Default::default()
212+
});
200213
}
201214

202215
if opcode == JETTON_TRANSFER_OPCODE {
203216
let jetton = parse_jetton_transfer_body(&mut parser)?;
204-
return Ok((Some(opcode), None, Some(jetton)));
217+
return Ok(BodyParseResult {
218+
opcode: Some(opcode),
219+
jetton_transfer: Some(jetton),
220+
..Default::default()
221+
});
222+
}
223+
224+
if opcode == WHALES_WITHDRAW_OPCODE || opcode == SINGLE_NOMINATOR_WITHDRAW_OPCODE {
225+
let amount = parse_withdraw_amount_body(&mut parser)?;
226+
return Ok(BodyParseResult {
227+
opcode: Some(opcode),
228+
withdraw_amount: Some(amount),
229+
..Default::default()
230+
});
205231
}
206232

207233
// Other known opcodes
208-
Ok((Some(opcode), None, None))
234+
Ok(BodyParseResult {
235+
opcode: Some(opcode),
236+
..Default::default()
237+
})
238+
}
239+
240+
/// Parse query_id + amount from a withdrawal message body (Whales or SingleNominator).
241+
fn parse_withdraw_amount_body(
242+
parser: &mut tlb_ton::de::CellParser<'_>,
243+
) -> Result<u64, WasmTonError> {
244+
let _query_id: u64 = parser
245+
.unpack(())
246+
.map_err(|e| WasmTonError::new(&format!("withdraw: failed to read query_id: {e}")))?;
247+
let amount_big: BigUint = parser
248+
.unpack_as::<_, Grams>(())
249+
.map_err(|e| WasmTonError::new(&format!("withdraw: failed to read amount: {e}")))?;
250+
Ok(biguint_to_u64(&amount_big))
209251
}
210252

211253
fn parse_jetton_transfer_body(

packages/wasm-ton/src/transaction.rs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use base64::{engine::general_purpose::STANDARD, Engine};
2-
use sha2::{Digest, Sha256};
32
use tlb_ton::{
43
message::{CommonMsgInfo, Message},
54
ser::CellSerializeExt,
@@ -16,6 +15,9 @@ pub struct Transaction {
1615
boc_bytes: Vec<u8>,
1716
/// The parsed external message
1817
pub message: Message<WalletV4R2ExternalBody>,
18+
/// The root cell hash, computed from the original parsed cell (not re-serialized).
19+
/// This matches the standard TON cell representation hash that TonWeb and explorers compute.
20+
root_cell_hash: [u8; 32],
1921
}
2022

2123
const BOC_ARGS: BagOfCellsArgs = BagOfCellsArgs {
@@ -31,12 +33,17 @@ impl Transaction {
3133
let root = boc
3234
.single_root()
3335
.ok_or_else(|| WasmTonError::new("BOC must have exactly one root cell"))?;
36+
// Compute the cell hash from the original parsed cell BEFORE any re-serialization.
37+
// This preserves the exact bit layout of the original BOC, including inner message
38+
// bodies that may not round-trip perfectly through tlb-ton's typed serialization.
39+
let root_cell_hash = root.hash();
3440
let message: Message<WalletV4R2ExternalBody> = root
3541
.parse_fully(())
3642
.map_err(|e| WasmTonError::new(&format!("failed to parse message: {e}")))?;
3743
Ok(Transaction {
3844
boc_bytes: bytes.to_vec(),
3945
message,
46+
root_cell_hash,
4047
})
4148
}
4249

@@ -91,8 +98,9 @@ impl Transaction {
9198
let mut sig = [0u8; 64];
9299
sig.copy_from_slice(signature);
93100
self.message.body.signature = sig;
94-
// Re-serialize
101+
// Re-serialize and update the root cell hash from the new BOC
95102
self.boc_bytes = self.serialize_boc()?;
103+
self.root_cell_hash = self.compute_root_cell_hash()?;
96104
Ok(())
97105
}
98106

@@ -106,10 +114,22 @@ impl Transaction {
106114
Ok(STANDARD.encode(&self.boc_bytes))
107115
}
108116

109-
/// Get the transaction ID (SHA-256 hash of the BOC, base64url encoded).
117+
/// Get the transaction ID (cell hash of the root message cell, base64url encoded).
118+
///
119+
/// Uses the standard TON cell representation hash, computed from the original parsed cell
120+
/// (not re-serialized). This matches what TonWeb and TON explorers compute.
110121
pub fn id(&self) -> String {
111-
let hash = Sha256::digest(&self.boc_bytes);
112-
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
122+
base64::engine::general_purpose::URL_SAFE.encode(self.root_cell_hash)
123+
}
124+
125+
/// Compute the root cell hash from the current BOC bytes.
126+
fn compute_root_cell_hash(&self) -> Result<[u8; 32], WasmTonError> {
127+
let boc = BagOfCells::deserialize(&self.boc_bytes)
128+
.map_err(|e| WasmTonError::new(&format!("failed to parse BOC for hash: {e}")))?;
129+
let root = boc
130+
.single_root()
131+
.ok_or_else(|| WasmTonError::new("BOC must have exactly one root cell"))?;
132+
Ok(root.hash())
113133
}
114134

115135
fn serialize_boc(&self) -> Result<Vec<u8>, WasmTonError> {
@@ -122,3 +142,56 @@ impl Transaction {
122142
.map_err(|e| WasmTonError::new(&format!("failed to serialize BOC: {e}")))
123143
}
124144
}
145+
146+
#[cfg(test)]
147+
mod tests {
148+
use super::*;
149+
150+
/// Simple send BOC from BitGoJS test fixtures.
151+
const SIMPLE_SEND_BOC: &str = "te6cckEBAgEAqQAB4YgBJAxo7vqHF++LJ4bC/kJ8A1uVRskrKlrKJZ8rIB0tF+gCadlSX+hPo2mmhZyi0p3zTVUYVRkcmrCm97cSUFSa2vzvCArM3APg+ww92r3IcklNjnzfKOgysJVQXiCvj9SAaU1NGLsotvRwAAAAMAAcAQBmQgAaRefBOjTi/hwqDjv+7I6nGj9WEAe3ls/rFuBEQvggr5zEtAAAAAAAAAAAAAAAAAAAdfZO7w==";
152+
153+
/// SingleNominator withdraw BOC from BitGoJS test fixtures.
154+
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==";
155+
156+
#[test]
157+
fn test_simple_send_id() {
158+
let tx = Transaction::from_base64(SIMPLE_SEND_BOC).unwrap();
159+
assert_eq!(tx.id(), "tuyOkyFUMv_neV_FeNBH24Nd4cML2jUgDP4zjGkuOFI=");
160+
}
161+
162+
#[test]
163+
fn test_single_nominator_withdraw_id() {
164+
let tx = Transaction::from_base64(SINGLE_NOMINATOR_WITHDRAW_BOC).unwrap();
165+
assert_eq!(tx.id(), "n1rr-QL61WZ7UJN7ESH2iPQO7toTy9WLqXoSIG1JtXg=");
166+
}
167+
168+
#[test]
169+
fn test_id_uses_original_cell_hash_not_reserialized() {
170+
// Verify that the ID comes from the original parsed cell, not from re-serialization.
171+
// The SingleNominator withdraw BOC has an inner message body that doesn't round-trip
172+
// through tlb-ton's typed serialization, so re-serialized hash would differ.
173+
let tx = Transaction::from_base64(SINGLE_NOMINATOR_WITHDRAW_BOC).unwrap();
174+
175+
// The re-serialized cell hash (what the old buggy code computed)
176+
let reserialized_hash = tx
177+
.message
178+
.to_cell(())
179+
.map(|cell| base64::engine::general_purpose::URL_SAFE.encode(cell.hash()))
180+
.unwrap();
181+
182+
// The original cell hash (what we now use)
183+
let original_hash = tx.id();
184+
185+
// For the SingleNominator withdraw, these should differ, proving the fix matters
186+
assert_ne!(
187+
reserialized_hash, original_hash,
188+
"re-serialized hash should differ from original for SingleNominator withdraw"
189+
);
190+
191+
// The original hash matches the expected legacy ID
192+
assert_eq!(
193+
original_hash,
194+
"n1rr-QL61WZ7UJN7ESH2iPQO7toTy9WLqXoSIG1JtXg="
195+
);
196+
}
197+
}

packages/wasm-ton/src/wasm/try_into_js_value.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,15 @@ impl TryIntoJsValue for ParsedSendAction {
209209
.map_err(|_| JsConversionError::new("Failed to set jettonTransfer"))?;
210210
}
211211

212+
if let Some(withdraw_amount) = self.withdraw_amount {
213+
js_sys::Reflect::set(
214+
&obj,
215+
&JsValue::from_str("withdrawAmount"),
216+
&TryIntoJsValue::try_to_js_value(&withdraw_amount)?,
217+
)
218+
.map_err(|_| JsConversionError::new("Failed to set withdrawAmount"))?;
219+
}
220+
212221
Ok(obj.into())
213222
}
214223
}

packages/wasm-ton/test/transaction.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,21 @@ describe("Transaction", () => {
2020
it("should deserialize a signed send transaction", () => {
2121
const tx = fromBase64(signedSendTx);
2222
assert.ok(tx);
23-
assert.ok(tx.id);
2423
assert.ok(tx.destination);
2524
});
2625

26+
it("should compute cell hash as transaction id", () => {
27+
const tx = fromBase64(signedSendTx);
28+
// Cell hash of the root external message cell, base64url with padding
29+
assert.equal(tx.id, "tuyOkyFUMv_neV_FeNBH24Nd4cML2jUgDP4zjGkuOFI=");
30+
});
31+
32+
it("should compute correct id for single nominator withdraw", () => {
33+
const tx = fromBase64(singleNominatorWithdrawTx);
34+
// Must match legacy TonWeb hash, not the re-serialized cell hash
35+
assert.equal(tx.id, "n1rr-QL61WZ7UJN7ESH2iPQO7toTy9WLqXoSIG1JtXg=");
36+
});
37+
2738
it("should get signable payload", () => {
2839
const tx = fromBase64(signedSendTx);
2940
const payload = tx.signablePayload();
@@ -45,20 +56,24 @@ describe("Transaction", () => {
4556
assert.ok(parsed.sendActions.length > 0);
4657
assert.ok(parsed.sender);
4758
assert.ok(parsed.signature);
59+
assert.equal(parsed.sendActions[0].withdrawAmount, undefined);
4860
});
4961

5062
it("should parse a whales deposit transaction", () => {
5163
const tx = fromBase64(whalesDepositTx);
5264
const parsed = parseTransaction(tx);
5365
assert.equal(parsed.transactionType, "WhalesDeposit");
5466
assert.ok(parsed.sendActions.length > 0);
67+
assert.equal(parsed.sendActions[0].withdrawAmount, undefined);
5568
});
5669

57-
it("should parse a single nominator withdraw transaction", () => {
70+
it("should parse a single nominator withdraw transaction with withdrawAmount", () => {
5871
const tx = fromBase64(singleNominatorWithdrawTx);
5972
const parsed = parseTransaction(tx);
6073
assert.equal(parsed.transactionType, "SingleNominatorWithdraw");
6174
assert.ok(parsed.sendActions.length > 0);
75+
assert.equal(typeof parsed.sendActions[0].withdrawAmount, "bigint");
76+
assert.equal(parsed.sendActions[0].withdrawAmount, 932178112330000n);
6277
});
6378
});
6479
});

0 commit comments

Comments
 (0)