11use base64:: { engine:: general_purpose:: STANDARD , Engine } ;
2- use sha2:: { Digest , Sha256 } ;
32use 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
2123const 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+ }
0 commit comments