@@ -32,4 +32,176 @@ internal final class ChunkedTransactionUnitTests: HieroUnitTestCase {
3232 XCTAssertEqual ( transaction. message, " Hello, world! " . data ( using: . utf8) !)
3333 XCTAssertEqual ( transaction. transactionId, transactionId)
3434 }
35+
36+ // MARK: - Cross-group forgery rejection tests
37+
38+ /// Non-chunked types (e.g. CryptoTransfer) with multiple TransactionId groups must be rejected,
39+ /// even when the bodies are identical across groups.
40+ internal func test_fromBytes_rejectMultiGroupNonChunked( ) throws {
41+ let txId1 = TransactionId . withValidStart (
42+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_542 , subSecondNanos: 0 ) )
43+ let txId2 = TransactionId . withValidStart (
44+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_543 , subSecondNanos: 0 ) )
45+
46+ let transfer = Proto_CryptoTransferTransactionBody . with { proto in
47+ proto. transfers = Proto_TransferList . with { list in
48+ list. accountAmounts = [
49+ Proto_AccountAmount . with { amt in
50+ amt. accountID = AccountId ( num: 100 ) . toProtobuf ( )
51+ amt. amount = - 100
52+ } ,
53+ Proto_AccountAmount . with { amt in
54+ amt. accountID = AccountId ( num: 200 ) . toProtobuf ( )
55+ amt. amount = 100
56+ } ,
57+ ]
58+ }
59+ }
60+
61+ let tx1 = try makeSignedTx ( txId: txId1) { $0. cryptoTransfer = transfer }
62+ let tx2 = try makeSignedTx ( txId: txId2) { $0. cryptoTransfer = transfer }
63+
64+ var list = Proto_TransactionList ( )
65+ list. transactionList = [ tx1, tx2]
66+ let bytes = try list. serializedData ( )
67+
68+ XCTAssertThrowsError ( try Transaction . fromBytes ( bytes) )
69+ }
70+
71+ /// A consensusSubmitMessage list with 2 actual groups but chunkInfo.total = 1 must be rejected.
72+ internal func test_fromBytes_rejectChunkCountMismatch_totalTooLow( ) throws {
73+ let txId1 = TransactionId . withValidStart (
74+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_542 , subSecondNanos: 0 ) )
75+ let txId2 = TransactionId . withValidStart (
76+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_543 , subSecondNanos: 0 ) )
77+ let topicId = TopicId ( num: 1 )
78+
79+ // Both groups declare total=1, but there are 2 actual groups.
80+ let tx1 = try makeSignedTx ( txId: txId1) { body in
81+ body. consensusSubmitMessage = Proto_ConsensusSubmitMessageTransactionBody . with { proto in
82+ proto. topicID = topicId. toProtobuf ( )
83+ proto. message = Data ( " chunk1 " . utf8)
84+ proto. chunkInfo = Proto_ConsensusMessageChunkInfo . with { info in
85+ info. initialTransactionID = txId1. toProtobuf ( )
86+ info. total = 1
87+ info. number = 1
88+ }
89+ }
90+ }
91+ let tx2 = try makeSignedTx ( txId: txId2) { body in
92+ body. consensusSubmitMessage = Proto_ConsensusSubmitMessageTransactionBody . with { proto in
93+ proto. topicID = topicId. toProtobuf ( )
94+ proto. message = Data ( " chunk2 " . utf8)
95+ proto. chunkInfo = Proto_ConsensusMessageChunkInfo . with { info in
96+ info. initialTransactionID = txId1. toProtobuf ( )
97+ info. total = 1
98+ info. number = 1
99+ }
100+ }
101+ }
102+
103+ var list = Proto_TransactionList ( )
104+ list. transactionList = [ tx1, tx2]
105+ let bytes = try list. serializedData ( )
106+
107+ XCTAssertThrowsError ( try Transaction . fromBytes ( bytes) )
108+ }
109+
110+ /// A consensusSubmitMessage list with 2 actual groups but chunkInfo.total = 3 must be rejected.
111+ internal func test_fromBytes_rejectChunkCountMismatch_totalTooHigh( ) throws {
112+ let txId1 = TransactionId . withValidStart (
113+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_542 , subSecondNanos: 0 ) )
114+ let txId2 = TransactionId . withValidStart (
115+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_543 , subSecondNanos: 0 ) )
116+ let topicId = TopicId ( num: 1 )
117+
118+ // Both groups declare total=3, but there are only 2 actual groups.
119+ let tx1 = try makeSignedTx ( txId: txId1) { body in
120+ body. consensusSubmitMessage = Proto_ConsensusSubmitMessageTransactionBody . with { proto in
121+ proto. topicID = topicId. toProtobuf ( )
122+ proto. message = Data ( " chunk1 " . utf8)
123+ proto. chunkInfo = Proto_ConsensusMessageChunkInfo . with { info in
124+ info. initialTransactionID = txId1. toProtobuf ( )
125+ info. total = 3
126+ info. number = 1
127+ }
128+ }
129+ }
130+ let tx2 = try makeSignedTx ( txId: txId2) { body in
131+ body. consensusSubmitMessage = Proto_ConsensusSubmitMessageTransactionBody . with { proto in
132+ proto. topicID = topicId. toProtobuf ( )
133+ proto. message = Data ( " chunk2 " . utf8)
134+ proto. chunkInfo = Proto_ConsensusMessageChunkInfo . with { info in
135+ info. initialTransactionID = txId1. toProtobuf ( )
136+ info. total = 3
137+ info. number = 2
138+ }
139+ }
140+ }
141+
142+ var list = Proto_TransactionList ( )
143+ list. transactionList = [ tx1, tx2]
144+ let bytes = try list. serializedData ( )
145+
146+ XCTAssertThrowsError ( try Transaction . fromBytes ( bytes) )
147+ }
148+
149+ /// A consensusSubmitMessage list with swapped chunk numbers must be rejected.
150+ internal func test_fromBytes_rejectOutOfOrderChunkNumbers( ) throws {
151+ let txId1 = TransactionId . withValidStart (
152+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_542 , subSecondNanos: 0 ) )
153+ let txId2 = TransactionId . withValidStart (
154+ AccountId ( num: 100 ) , Timestamp ( seconds: 1_554_158_543 , subSecondNanos: 0 ) )
155+ let topicId = TopicId ( num: 1 )
156+
157+ // Group appearing first declares number=2; group appearing second declares number=1.
158+ let tx1 = try makeSignedTx ( txId: txId1) { body in
159+ body. consensusSubmitMessage = Proto_ConsensusSubmitMessageTransactionBody . with { proto in
160+ proto. topicID = topicId. toProtobuf ( )
161+ proto. message = Data ( " chunk2 " . utf8)
162+ proto. chunkInfo = Proto_ConsensusMessageChunkInfo . with { info in
163+ info. initialTransactionID = txId1. toProtobuf ( )
164+ info. total = 2
165+ info. number = 2
166+ }
167+ }
168+ }
169+ let tx2 = try makeSignedTx ( txId: txId2) { body in
170+ body. consensusSubmitMessage = Proto_ConsensusSubmitMessageTransactionBody . with { proto in
171+ proto. topicID = topicId. toProtobuf ( )
172+ proto. message = Data ( " chunk1 " . utf8)
173+ proto. chunkInfo = Proto_ConsensusMessageChunkInfo . with { info in
174+ info. initialTransactionID = txId1. toProtobuf ( )
175+ info. total = 2
176+ info. number = 1
177+ }
178+ }
179+ }
180+
181+ var list = Proto_TransactionList ( )
182+ list. transactionList = [ tx1, tx2]
183+ let bytes = try list. serializedData ( )
184+
185+ XCTAssertThrowsError ( try Transaction . fromBytes ( bytes) )
186+ }
187+
188+ // MARK: - Helpers
189+
190+ private func makeSignedTx(
191+ txId: TransactionId ,
192+ nodeId: AccountId = AccountId ( num: 3 ) ,
193+ configure: ( inout Proto_TransactionBody ) -> Void
194+ ) throws -> Proto_Transaction {
195+ var body = Proto_TransactionBody ( )
196+ body. transactionID = txId. toProtobuf ( )
197+ body. nodeAccountID = nodeId. toProtobuf ( )
198+ configure ( & body)
199+
200+ var signedTx = Proto_SignedTransaction ( )
201+ signedTx. bodyBytes = try body. serializedData ( )
202+
203+ var tx = Proto_Transaction ( )
204+ tx. signedTransactionBytes = try signedTx. serializedData ( )
205+ return tx
206+ }
35207}
0 commit comments