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: 2 additions & 0 deletions packages/wasm-ton/js/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
65 changes: 54 additions & 11 deletions packages/wasm-ton/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>, Option<String>, Option<JettonTransferFields>);
/// Parsed result of a message body.
#[derive(Debug, Default)]
struct BodyParseResult {
opcode: Option<u32>,
memo: Option<String>,
jetton_transfer: Option<JettonTransferFields>,
withdraw_amount: Option<u64>,
}

/// Transaction type enum
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -64,6 +70,8 @@ pub struct ParsedSendAction {
pub state_init: bool,
pub memo: Option<String>,
pub jetton_transfer: Option<JettonTransferFields>,
/// Withdraw amount encoded in the message body (SingleNominator/Whales withdrawal types).
pub withdraw_amount: Option<u64>,
}

/// A fully parsed TON transaction
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -172,12 +181,12 @@ fn parse_message_body(body: &Cell) -> Result<BodyParseResult, WasmTonError> {

// 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
Expand All @@ -195,16 +204,50 @@ fn parse_message_body(body: &Cell) -> Result<BodyParseResult, WasmTonError> {
};
}
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<u64, WasmTonError> {
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.
Expand Down
85 changes: 80 additions & 5 deletions packages/wasm-ton/src/transaction.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use sha2::{Digest, Sha256};
use tlb_ton::{
message::{CommonMsgInfo, Message},
ser::CellSerializeExt,
Expand All @@ -16,6 +15,9 @@ pub struct Transaction {
boc_bytes: Vec<u8>,
/// The parsed external message
pub message: Message<WalletV4R2ExternalBody>,
/// 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 {
Expand All @@ -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<WalletV4R2ExternalBody> = 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,
})
}

Expand Down Expand Up @@ -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(())
}

Expand All @@ -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<Vec<u8>, WasmTonError> {
Expand All @@ -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="
);
}
}
9 changes: 9 additions & 0 deletions packages/wasm-ton/src/wasm/try_into_js_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Expand Down
19 changes: 17 additions & 2 deletions packages/wasm-ton/test/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -45,20 +56,24 @@ 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", () => {
const tx = fromBase64(whalesDepositTx);
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);
});
});
});
Loading