1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
//! Transactions: building, digesting and signing.
//!
//! # Signing needs no network
//!
//! A Hive transaction needs exactly two things from outside itself: the **chain id**
//! and a **block reference** (TaPoS). The chain id is a compile-time constant — see
//! [`crate::chains`]. The block reference is derived from any recent block and stays
//! valid far longer than a single submit, so it can be fetched in the background and
//! reused.
//!
//! Neither belongs in the critical path, and here neither is. Producing a signature is
//! a pure CPU operation.
//!
//! beem could not do this. `blockchaininstance.py:496-549` calls `get_config` over
//! JSON-RPC on the way to every signature, to fetch chain parameters that are mostly
//! constant. When nodes are slow, signing is slow — and signing sits inside whatever
//! deadline the caller is working against.
//!
//! # What gets signed
//!
//! ```text
//! digest = sha256( chain_id || ref_block_num (u16 LE)
//! || ref_block_prefix (u32 LE)
//! || expiration (u32 LE)
//! || operations (varint count, then each)
//! || extensions (varint count) )
//! ```
//!
//! Signatures are **not** part of the signed bytes, and are not part of the
//! transaction id either.
use crate::chains::{Chain, ChainId};
use crate::error::{Error, Result};
use crate::keys::{PrivateKey, PublicKey};
use crate::operations::Operation;
use crate::reader::Reader;
use crate::sign::{self, Signature};
use crate::types::{
write_array, write_u16, write_u32, write_varint32, GrapheneSerialize, PointInTime,
};
use sha2::{Digest, Sha256};
/// Default seconds until a transaction expires.
///
/// hived caps expiration at one hour past head-block time; a minute is the usual
/// working value and leaves room for a retry.
pub const DEFAULT_EXPIRATION_SECS: u32 = 60;
/// hived's hard limit on how far in the future an expiration may be set.
pub const MAX_EXPIRATION_SECS: u32 = 3600;
/// A reference to a recent block, for transaction-as-proof-of-stake.
///
/// TaPoS binds a transaction to a fork: if the referenced block is not in the chain
/// the node is building on, the transaction is invalid there. That is what stops a
/// transaction from being replayed onto a competing fork.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BlockRef {
/// Low 16 bits of the referenced block number.
pub ref_block_num: u16,
/// Bytes 4..8 of the block id, read as a little-endian `u32`.
pub ref_block_prefix: u32,
/// The full block number the reference came from, kept for staleness checks.
pub block_num: u32,
}
impl BlockRef {
/// Derive a reference from a block number and its 20-byte block id.
///
/// The block id is hived's `block_id_type`: a 160-bit hash whose **first four
/// bytes are the big-endian block number**, with the remaining 16 bytes the hash.
/// The prefix is taken from bytes 4..8 — that is, the first four bytes *after* the
/// embedded block number — read little-endian.
pub fn from_block_id(block_id_hex: &str) -> Result<Self> {
let hex = block_id_hex.trim();
if hex.len() != 40 {
return Err(Error::field(format!(
"block id must be 40 hex characters, got {}",
hex.len()
)));
}
let mut bytes = [0u8; 20];
crate::hex::decode_exact(hex, &mut bytes)
.map_err(|_| Error::field("block id is not valid hex"))?;
let block_num = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let ref_block_prefix = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
Ok(BlockRef {
ref_block_num: (block_num & 0xffff) as u16,
ref_block_prefix,
block_num,
})
}
}
/// An unsigned transaction.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transaction {
/// Low 16 bits of the referenced block number (TaPoS).
pub ref_block_num: u16,
/// Bytes 4..8 of that block's id, read little-endian (TaPoS).
pub ref_block_prefix: u32,
/// When the chain stops accepting this transaction. hived allows at most one hour.
pub expiration: PointInTime,
/// Applied in order, all-or-nothing.
pub operations: Vec<Operation>,
}
impl Transaction {
/// Build a transaction against a block reference, expiring `expiration_secs` from
/// now.
pub fn new(
block_ref: BlockRef,
operations: Vec<Operation>,
expiration_secs: u32,
) -> Result<Self> {
if operations.is_empty() {
return Err(Error::field(
"a transaction must contain at least one operation",
));
}
if expiration_secs == 0 || expiration_secs > MAX_EXPIRATION_SECS {
return Err(Error::field(format!(
"expiration of {expiration_secs}s is outside hived's 1..={MAX_EXPIRATION_SECS}s window"
)));
}
Ok(Transaction {
ref_block_num: block_ref.ref_block_num,
ref_block_prefix: block_ref.ref_block_prefix,
expiration: PointInTime::now_plus(expiration_secs)?,
operations,
})
}
/// Serialize the transaction body — everything the digest covers.
///
/// Signatures are excluded. beem achieved this by mutating `self.data`, computing
/// the bytes, and putting the signatures back; a failure in between left the
/// object without its signatures. Here the body simply never contains them.
pub fn body_bytes(&self) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(64 + self.operations.len() * 64);
write_u16(&mut out, self.ref_block_num);
write_u32(&mut out, self.ref_block_prefix);
self.expiration.append_to(&mut out)?;
write_array(&mut out, &self.operations)?;
write_varint32(&mut out, 0); // extensions: always empty for a client transaction
Ok(out)
}
/// # The one size limit this cannot check
///
/// A transaction also has a maximum serialized size, and it is **not** the constant
/// it looks like. `HIVE_MAX_TRANSACTION_SIZE` is 65536 and is not what hived
/// enforces; `database::process_non_fast_confirm_transaction` computes
///
/// ```text
/// trx_size_limit = get_dynamic_global_properties().maximum_block_size - 256
/// FC_ASSERT( trx_size <= trx_size_limit, "Transaction too large - size = ..." )
/// ```
///
/// `maximum_block_size` is voted on by witnesses, so the bound moves without a
/// hardfork and cannot be compiled in. It was 65536 on 2026-08-24, making the
/// effective limit **65280** — close enough to the fixed constant to look like it
/// and not be it.
///
/// So nothing here rejects an oversized transaction: doing so would need the value
/// from a node, and a signing path that cannot work offline is a worse trade than
/// letting the node refuse it. A caller batching near the limit should read
/// `maximum_block_size` from
/// [`dynamic_global_properties`](crate::rpc::NodeClient::dynamic_global_properties)
/// rather than assume 65536.
///
/// Refuse a transaction that is certain to exceed hived's per-block custom-op limit.
///
/// `database::limit_custom_op_count` counts `custom`, `custom_json` and
/// `custom_binary` operations **per impacted account, across a whole block**, and
/// asserts the count stays within
/// [`MAX_CUSTOM_OPS_PER_BLOCK`](crate::operations::MAX_CUSTOM_OPS_PER_BLOCK):
///
/// ```text
/// Account ${a} already submitted ${n} custom json operation(s) this block.
/// ```
///
/// # What this catches, and what it cannot
///
/// One transaction carrying more than the limit for a single account is a
/// **guaranteed** failure, and it is caught here before anything is signed. The
/// whole transaction is refused by the chain, so every other operation batched
/// alongside goes with it.
///
/// What no library can check is the rest of the block. hived adds the count from
/// transactions already pending, so a transaction well within the limit on its own
/// can still be refused because the same account sent others into the same block.
/// **Passing this check is not a guarantee of acceptance**, only the removal of one
/// certain failure — which is worth stating plainly, because a check that quietly
/// implies more than it verifies is worse than no check.
///
/// # The budget belongs to the account, so the application has to own it
///
/// This is the part the check cannot help with, and the part that is easy to read
/// past. The limit is per **account per block**, not per transaction and not per
/// process. An application with several independent things that broadcast — a
/// trading path, a background repricer, a transfer queue, each on its own timer —
/// shares one budget of five between all of them, and nothing in a signing library
/// can coordinate them. They cannot see each other, and neither can this.
///
/// Two consequences worth knowing before relying on the check above:
///
/// * **Splitting an oversized payload can make things worse.** Chunking a large
/// `custom_json` into several operations and broadcasting them back to back puts
/// them all in one block and consumes the whole account budget, starving whatever
/// else broadcasts on that account in that block. The obvious remedy for the size
/// limit is a good way to trip the rate limit.
/// * **Spread across blocks, not across operations.** Blocks are three seconds; two
/// broadcasters firing on half-second timers can collide without either being
/// wrong on its own.
///
/// If the account is shared, the budget needs an owner — a queue, a token bucket,
/// anything that all the broadcasters go through. That is an application concern and
/// this crate deliberately does not pretend otherwise.
/// Check everything hived's `validate()` would, without signing anything.
///
/// [`Self::sign`] calls this, so the ordinary path is checked before a signature
/// exists. It is public because the other paths are not: serializing a transaction,
/// taking its digest or reading one off the wire all leave it alone, so a caller
/// doing any of those and then broadcasting by some other route can ask.
///
/// Deliberately **not** called by [`Self::body_bytes`] or [`Self::digest`]. hived
/// separates deserializing from validating, and folding them together here meant a
/// transaction that parsed could fail to serialize back -- a `custom_json` with no
/// auths, an `update_proposal_votes` with no ids -- so the bytes a digest is taken
/// over were not recoverable from the value they had been parsed into.
pub fn validate(&self) -> Result<()> {
for op in &self.operations {
op.validate()?;
}
self.check_custom_op_budget()
}
fn check_custom_op_budget(&self) -> Result<()> {
// This runs inside `body_bytes`, so it is on the signing path of every
// transaction. Counting first is a tag comparison per operation and no
// allocation, and it answers the question outright in the two cases that cover
// very nearly all real traffic.
//
// No custom operations at all -- a transfer, a vote, a comment -- and there is
// nothing to tally. Nor is there if the whole transaction names at most
// `MAX_CUSTOM_OPS_PER_BLOCK` accounts across all of them: one account's share of
// that total cannot exceed the total, so if the transaction is within the budget
// then every account in it is too. Only a transaction that could actually breach
// the limit pays for the tally below.
//
// The bound counts *named accounts*, not operations. A single `custom_json` may
// name the same account in both its auth lists, which tallies twice below, so
// operations would not be a safe bound -- five of those would short-circuit here
// while the tally would have refused them.
let named: usize = self
.operations
.iter()
.map(|op| op.custom_op_accounts_iter().count())
.sum();
if named <= crate::operations::MAX_CUSTOM_OPS_PER_BLOCK {
return Ok(());
}
// A linear scan rather than a `HashMap`. The budget is five per account, so a
// transaction that passes this check has at most a handful of distinct names in
// the tally, and comparing short strings that many times costs less than
// hashing them -- let alone building the map.
let mut per_account: Vec<(&str, usize)> = Vec::new();
for op in &self.operations {
for account in op.custom_op_accounts_iter() {
let n = match per_account.iter_mut().find(|(name, _)| *name == account) {
Some((_, n)) => n,
None => {
per_account.push((account, 0));
&mut per_account.last_mut().expect("just pushed").1
}
};
*n += 1;
if *n > crate::operations::MAX_CUSTOM_OPS_PER_BLOCK {
return Err(Error::field(format!(
"this transaction carries {n} custom operations for {account}; \
hived allows at most {} per account per block and refuses the \
whole transaction beyond that. Spread them across blocks rather \
than across operations",
crate::operations::MAX_CUSTOM_OPS_PER_BLOCK
)));
}
}
}
Ok(())
}
/// The digest that gets signed: `sha256(chain_id || body)`.
///
/// Refuses the all-zero chain id. That is the value beem fell back to inside a
/// bare `except:`, and signing against it yields a signature the chain rejects
/// with no indication that signing was the problem.
pub fn digest(&self, chain: Chain) -> Result<[u8; 32]> {
self.digest_with_chain_id(chain.chain_id())
}
/// The digest against an explicit chain id, for testnets and forks.
pub fn digest_with_chain_id(&self, chain_id: ChainId) -> Result<[u8; 32]> {
if chain_id.is_all_zero() {
return Err(Error::Chain(
"refusing to sign against the all-zero chain id: it is the pre-HF24 \
value and produces a signature Hive rejects"
.into(),
));
}
let mut hasher = Sha256::new();
hasher.update(chain_id.as_bytes());
hasher.update(self.body_bytes()?);
Ok(hasher.finalize().into())
}
/// The transaction id: the first 20 bytes of `sha256(body)`, as hex.
///
/// Note that this does **not** include the chain id, and does not include
/// signatures.
pub fn id(&self) -> Result<String> {
let digest = Sha256::digest(self.body_bytes()?);
Ok(digest[..20].iter().map(|b| format!("{b:02x}")).collect())
}
/// Sign with one or more keys.
///
/// Duplicate keys are collapsed, since a second signature from the same key adds
/// nothing and hived rejects a transaction carrying a redundant signature.
pub fn sign(self, keys: &[PrivateKey], chain: Chain) -> Result<SignedTransaction> {
if keys.is_empty() {
return Err(Error::field("no signing keys were provided"));
}
// Before anything is signed, not after: a signature over a transaction hived
// will refuse is worse than an error, because it looks like it worked.
self.validate()?;
let digest = self.digest(chain)?;
let mut unique: Vec<&PrivateKey> = Vec::with_capacity(keys.len());
for key in keys {
if !unique.contains(&key) {
unique.push(key);
}
}
let signatures = unique
.iter()
.map(|key| sign::sign_digest(&digest, key))
.collect::<Result<Vec<_>>>()?;
Ok(SignedTransaction {
transaction: self,
signatures,
})
}
}
/// A transaction with its signatures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedTransaction {
/// The transaction the signatures cover. Changing it invalidates them.
pub transaction: Transaction,
/// Canonical 65-byte compact signatures. hived accepts more than it needs, so an
/// extra one is harmless; a missing or wrong one is not.
pub signatures: Vec<Signature>,
}
impl SignedTransaction {
/// The signing keys, recovered and verified.
///
/// Every signature must verify; one that does not is an error rather than a
/// skipped entry. beem's equivalent looped over all four recovery parameters and
/// appended every candidate that did not raise, so its result could hold four
/// unrelated keys for a single signature.
pub fn signers(&self, chain: Chain) -> Result<Vec<PublicKey>> {
let digest = self.transaction.digest(chain)?;
self.signatures
.iter()
.map(|sig| sign::recover(&digest, sig))
.collect()
}
/// Check that the transaction is signed by every one of `required`.
pub fn verify(&self, required: &[PublicKey], chain: Chain) -> Result<()> {
let found = self.signers(chain)?;
for key in required {
if !found.contains(key) {
return Err(Error::sig(format!(
"transaction is not signed by {}",
key.to_prefixed(chain.prefix())
)));
}
}
Ok(())
}
/// The JSON form a node's `network_broadcast_api` expects.
pub fn to_json(&self) -> Result<serde_json::Value> {
let ops: Result<Vec<serde_json::Value>> = self
.transaction
.operations
.iter()
.map(operation_to_json)
.collect();
Ok(serde_json::json!({
"ref_block_num": self.transaction.ref_block_num,
"ref_block_prefix": self.transaction.ref_block_prefix,
"expiration": self.transaction.expiration.to_iso()?,
"operations": ops?,
"extensions": Vec::<serde_json::Value>::new(),
"signatures": self.signatures.iter().map(|s| s.to_hex()).collect::<Vec<_>>(),
}))
}
}
/// Render an operation in Hive's `[name, {fields}]` JSON form.
fn operation_to_json(op: &Operation) -> Result<serde_json::Value> {
let value = serde_json::to_value(op)
.map_err(|e| Error::ser(format!("could not render operation as JSON: {e}")))?;
Ok(serde_json::json!([op.id().name(), value]))
}
impl Transaction {
/// Decode a transaction body from the Graphene wire format.
///
/// This is the inverse of [`Transaction::body_bytes`] and reads the same field set
/// — signatures are not part of it.
pub fn from_body_bytes(bytes: &[u8], chain: Chain) -> Result<Self> {
let mut r = Reader::new(bytes, chain);
let tx = Self::read_body(&mut r)?;
r.expect_end()?;
Ok(tx)
}
fn read_body(r: &mut Reader<'_>) -> Result<Self> {
let ref_block_num = r.u16()?;
let ref_block_prefix = r.u32()?;
let expiration = r.point_in_time()?;
let operations: Vec<Operation> = r.array()?;
let extension_count = r.varint32()?;
if extension_count != 0 {
return Err(Error::ser(format!(
"transaction carries {extension_count} extension(s), which this build does not model"
)));
}
if operations.is_empty() {
return Err(Error::ser("transaction contains no operations"));
}
Ok(Transaction {
ref_block_num,
ref_block_prefix,
expiration,
operations,
})
}
}
impl SignedTransaction {
/// Serialize the full transaction including its signatures.
///
/// This is the form used for peer-to-peer transmission and for storing a
/// transaction in a block. It is **not** what gets hashed for the digest — see
/// [`Transaction::body_bytes`].
pub fn to_wire(&self) -> Result<Vec<u8>> {
let mut out = self.transaction.body_bytes()?;
write_varint32(
&mut out,
u32::try_from(self.signatures.len()).map_err(|_| {
Error::ser("transaction carries an implausible number of signatures")
})?,
);
for sig in &self.signatures {
out.extend_from_slice(sig.as_bytes());
}
Ok(out)
}
/// Decode a full signed transaction.
pub fn from_wire(bytes: &[u8], chain: Chain) -> Result<Self> {
let mut r = Reader::new(bytes, chain);
let transaction = Transaction::read_body(&mut r)?;
let count = r.varint32()? as usize;
// Each signature is 65 bytes; refuse a count the buffer cannot hold before
// allocating for it.
if count.saturating_mul(crate::sign::SIGNATURE_LEN) > r.remaining() {
return Err(Error::ser(format!(
"transaction claims {count} signatures but only {} bytes remain",
r.remaining()
)));
}
let mut signatures = Vec::with_capacity(count);
for _ in 0..count {
let raw = r.raw(crate::sign::SIGNATURE_LEN)?;
signatures.push(Signature::from_bytes(&raw)?);
}
r.expect_end()?;
Ok(SignedTransaction {
transaction,
signatures,
})
}
}
#[cfg(test)]
mod tests {
/// One transaction carrying more than the per-block limit for a single account is a
/// certain failure, and is refused before anything is signed.
#[test]
fn an_account_named_twice_by_one_operation_counts_twice() {
use crate::operations::{CustomJson, MAX_CUSTOM_OPS_PER_BLOCK};
// The budget check short-circuits when the transaction names few enough
// accounts to be within the limit whatever their distribution. That bound has
// to count *named accounts* rather than operations, because one `custom_json`
// can name the same account in both auth lists and so consume two of its five.
// Three such operations are only three operations but six of alice's budget,
// and the check has to see that.
let op = |i: usize| {
Operation::CustomJson(CustomJson {
required_auths: vec!["alice".into()],
required_posting_auths: vec!["alice".into()],
id: "my_app".into(),
json: format!(r#"{{"n":{i}}}"#),
})
};
let block_ref =
BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd").unwrap();
// The point of the test is that three operations are inside the limit, so a
// bound counting operations would wave this through. Asserted at compile time
// so that raising the limit cannot quietly turn this into a test of nothing.
const _: () = assert!(3 <= MAX_CUSTOM_OPS_PER_BLOCK);
let over = Transaction::new(block_ref, (0..3).map(op).collect(), 600).unwrap();
let err = over
.validate()
.expect_err("six of alice's five must be refused");
assert!(
err.to_string().contains("alice"),
"the error should name the account: {err}"
);
}
/// Signing validates, because nothing else on the path does any more.
///
/// `body_bytes` and `digest` are structural now -- that is what lets a transaction
/// read off the wire be written back unchanged. The whole guard therefore rests on
/// `sign` calling `validate`, so that is asserted directly rather than inferred
/// from the two being adjacent in the source.
#[test]
fn signing_refuses_a_transaction_hived_would_reject() {
use crate::operations::CustomJson;
let invalid = Operation::CustomJson(CustomJson {
required_auths: vec![],
required_posting_auths: vec![],
id: "my_app".into(),
json: "{}".into(),
});
let block_ref =
BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd").unwrap();
let tx = Transaction::new(block_ref, vec![invalid], 600).unwrap();
// It serializes and digests happily -- that is the point of the split.
assert!(tx.body_bytes().is_ok(), "serializing stays structural");
assert!(tx.digest(Chain::Hive).is_ok(), "and so does the digest");
let key =
PrivateKey::from_wif("5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3").unwrap();
let err = tx
.sign(&[key], Chain::Hive)
.expect_err("but signing must refuse it");
assert!(
err.to_string().contains("required_auths"),
"and say why: {err}"
);
}
#[test]
fn too_many_custom_ops_for_one_account_is_refused() {
use crate::operations::{CustomJson, MAX_CUSTOM_OPS_PER_BLOCK};
let op = |i: usize| {
Operation::CustomJson(CustomJson {
required_auths: vec![],
required_posting_auths: vec!["alice".into()],
id: "my_app".into(),
json: format!(r#"{{"n":{i}}}"#),
})
};
let block_ref =
BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd").unwrap();
let ok = Transaction::new(
block_ref,
(0..MAX_CUSTOM_OPS_PER_BLOCK).map(op).collect(),
600,
)
.unwrap();
assert!(
ok.validate().is_ok(),
"exactly the limit is allowed, not one less"
);
let over = Transaction::new(
block_ref,
(0..MAX_CUSTOM_OPS_PER_BLOCK + 1).map(op).collect(),
600,
)
.unwrap();
let err = over
.validate()
.expect_err("one past the limit must be refused");
let text = err.to_string();
assert!(
text.contains("alice"),
"the error should name the account: {text}"
);
assert!(
text.contains("whole transaction"),
"and say the whole transaction goes: {text}"
);
}
/// The limit is per account, so spreading the same operations across accounts is
/// fine — counting operations rather than accounts would wrongly refuse this.
#[test]
fn the_custom_op_limit_is_per_account_not_per_transaction() {
use crate::operations::{CustomJson, MAX_CUSTOM_OPS_PER_BLOCK};
let op = |who: &str| {
Operation::CustomJson(CustomJson {
required_auths: vec![],
required_posting_auths: vec![who.to_string()],
id: "my_app".into(),
json: "{}".into(),
})
};
let block_ref =
BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd").unwrap();
// Well past the limit in total, but never past it for any one account.
let mut ops = Vec::new();
for who in ["alice", "bob", "carol"] {
for _ in 0..MAX_CUSTOM_OPS_PER_BLOCK {
ops.push(op(who));
}
}
let tx = Transaction::new(block_ref, ops, 600).unwrap();
assert!(
tx.validate().is_ok(),
"{} operations across three accounts is within the per-account limit",
3 * MAX_CUSTOM_OPS_PER_BLOCK
);
}
/// Operations that are not custom ops do not count towards it at all.
#[test]
fn ordinary_operations_do_not_count_towards_the_custom_op_limit() {
use crate::operations::Transfer;
let block_ref =
BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd").unwrap();
let ops: Vec<Operation> = (0..50)
.map(|i| {
Operation::Transfer(Transfer {
from: "alice".into(),
to: "bob".into(),
amount: Amount::parse("1.000 HIVE", Chain::Hive).unwrap(),
memo: format!("m{i}"),
})
})
.collect();
let tx = Transaction::new(block_ref, ops, 600).unwrap();
assert!(tx.validate().is_ok(), "transfers are not custom operations");
}
use super::*;
use crate::asset::Amount;
use crate::operations::{CustomJson, Vote};
/// A fixed key used throughout these tests.
///
/// It is published here on purpose and must never hold value. Checked against
/// `account_by_key_api.get_key_references` on 2026-08-22: **no Hive account uses
/// it.** Do not fund it, and do not copy it into anything that will.
const TEST_WIF: &str = "5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3";
fn block_ref() -> BlockRef {
BlockRef {
ref_block_num: 0x1234,
ref_block_prefix: 0xdeadbeef,
block_num: 0x51234,
}
}
fn a_vote() -> Operation {
Operation::Vote(Vote {
voter: "alice".into(),
author: "bob".into(),
permlink: "a-post".into(),
weight: 10_000,
})
}
fn fixed_tx() -> Transaction {
Transaction {
ref_block_num: 0x1234,
ref_block_prefix: 0xdeadbeef,
expiration: PointInTime::from_unix(1_700_000_000).unwrap(),
operations: vec![a_vote()],
}
}
#[test]
fn transaction_body_round_trips() {
let tx = fixed_tx();
let bytes = tx.body_bytes().unwrap();
let back = Transaction::from_body_bytes(&bytes, Chain::Hive).unwrap();
assert_eq!(back, tx);
assert_eq!(
back.digest(Chain::Hive).unwrap(),
tx.digest(Chain::Hive).unwrap()
);
assert_eq!(back.id().unwrap(), tx.id().unwrap());
}
#[test]
fn signed_transaction_round_trips_with_its_signatures() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let other = PrivateKey::generate();
let signed = fixed_tx()
.sign(&[key.clone(), other.clone()], Chain::Hive)
.unwrap();
let bytes = signed.to_wire().unwrap();
let back = SignedTransaction::from_wire(&bytes, Chain::Hive).unwrap();
assert_eq!(back, signed);
// ...and the recovered signatures still verify.
back.verify(&[key.public_key(), other.public_key()], Chain::Hive)
.unwrap();
}
#[test]
fn signatures_are_not_part_of_the_digest() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let tx = fixed_tx();
let unsigned_digest = tx.digest(Chain::Hive).unwrap();
let signed = tx.clone().sign(&[key], Chain::Hive).unwrap();
assert_eq!(
signed.transaction.digest(Chain::Hive).unwrap(),
unsigned_digest
);
// The full wire form is longer than the body by exactly the signature block.
assert_eq!(
signed.to_wire().unwrap().len(),
tx.body_bytes().unwrap().len() + 1 + 65
);
}
#[test]
fn a_truncated_transaction_errors_at_every_cut() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let signed = fixed_tx().sign(&[key], Chain::Hive).unwrap();
let bytes = signed.to_wire().unwrap();
for cut in 0..bytes.len() {
assert!(
SignedTransaction::from_wire(&bytes[..cut], Chain::Hive).is_err(),
"truncating to {cut} bytes should fail"
);
}
assert!(SignedTransaction::from_wire(&bytes, Chain::Hive).is_ok());
}
#[test]
fn an_implausible_signature_count_is_refused_before_allocating() {
let mut bytes = fixed_tx().body_bytes().unwrap();
write_varint32(&mut bytes, 4_000_000_000);
let err = SignedTransaction::from_wire(&bytes, Chain::Hive).unwrap_err();
assert!(format!("{err}").contains("only"));
}
#[test]
fn a_transaction_with_no_operations_is_refused_on_read() {
let mut bytes = Vec::new();
write_u16(&mut bytes, 1);
write_u32(&mut bytes, 2);
PointInTime::from_unix(1_700_000_000)
.unwrap()
.append_to(&mut bytes)
.unwrap();
write_varint32(&mut bytes, 0); // zero operations
write_varint32(&mut bytes, 0); // no extensions
assert!(Transaction::from_body_bytes(&bytes, Chain::Hive).is_err());
}
#[test]
fn block_ref_derivation() {
// Block 5, with a synthetic id whose first four bytes are the block number.
let id = "00000005aabbccdd00000000000000000000abcd";
let r = BlockRef::from_block_id(id).unwrap();
assert_eq!(r.block_num, 5);
assert_eq!(r.ref_block_num, 5);
// bytes 4..8 = aa bb cc dd, little-endian
assert_eq!(r.ref_block_prefix, 0xddccbbaa);
}
#[test]
fn ref_block_num_takes_the_low_sixteen_bits() {
// Block 0x00012345 -> ref_block_num 0x2345.
let id = "00012345aabbccdd00000000000000000000abcd";
let r = BlockRef::from_block_id(id).unwrap();
assert_eq!(r.block_num, 0x12345);
assert_eq!(r.ref_block_num, 0x2345);
}
#[test]
fn block_ref_rejects_malformed_ids() {
assert!(BlockRef::from_block_id("abc").is_err());
assert!(BlockRef::from_block_id(&"z".repeat(40)).is_err());
}
#[test]
fn body_layout_is_exactly_the_signed_bytes() {
let tx = fixed_tx();
let body = tx.body_bytes().unwrap();
assert_eq!(&body[0..2], &0x1234u16.to_le_bytes());
assert_eq!(&body[2..6], &0xdeadbeefu32.to_le_bytes());
assert_eq!(&body[6..10], &1_700_000_000u32.to_le_bytes());
assert_eq!(body[10], 1, "one operation");
assert_eq!(*body.last().unwrap(), 0, "empty extensions array");
}
#[test]
fn digest_is_chain_id_prefixed() {
let tx = fixed_tx();
let digest = tx.digest(Chain::Hive).unwrap();
let mut expected = Sha256::new();
expected.update(crate::chains::HIVE_CHAIN_ID.as_bytes());
expected.update(tx.body_bytes().unwrap());
assert_eq!(digest, <[u8; 32]>::from(expected.finalize()));
}
#[test]
fn a_different_chain_gives_a_different_digest() {
let tx = fixed_tx();
assert_ne!(
tx.digest(Chain::Hive).unwrap(),
tx.digest(Chain::HiveTestnet).unwrap()
);
}
#[test]
fn the_all_zero_chain_id_is_refused() {
// beem fell back to exactly this value inside a bare `except:`.
let tx = fixed_tx();
let err = tx.digest(Chain::SteemLegacy).unwrap_err();
assert!(format!("{err}").contains("all-zero"));
assert!(tx
.digest_with_chain_id(crate::chains::ZERO_CHAIN_ID)
.is_err());
}
#[test]
fn transaction_id_is_twenty_bytes_of_sha256_over_the_body() {
let tx = fixed_tx();
let id = tx.id().unwrap();
assert_eq!(id.len(), 40);
let expected = Sha256::digest(tx.body_bytes().unwrap());
let expected_hex: String = expected[..20].iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(id, expected_hex);
}
#[test]
fn the_cross_binding_vector_is_stable() {
// A pinned digest and transaction id shared with the Python and Node test
// suites. Each asserts it independently, so a drift in any one binding is
// caught without needing all three runtimes in one place.
let tx = Transaction {
ref_block_num: BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd")
.unwrap()
.ref_block_num,
ref_block_prefix: BlockRef::from_block_id("00000005aabbccdd00000000000000000000abcd")
.unwrap()
.ref_block_prefix,
expiration: PointInTime::parse("2026-08-22T14:30:00").unwrap(),
operations: vec![Operation::CustomJson(CustomJson {
required_auths: vec![],
required_posting_auths: vec!["alice".into()],
id: "my_app".into(),
json: r#"{"a":1}"#.into(),
})],
};
let digest: String = tx
.digest(Chain::Hive)
.unwrap()
.iter()
.map(|b| format!("{b:02x}"))
.collect();
assert_eq!(
digest,
"cef35a5b34e7ee9297de5153b363668245793c8ba719762ccacdde9fd85ad3d6"
);
assert_eq!(tx.id().unwrap(), "8e4d2bb0d665a855512abf702c2b8e1ad9f6719e");
}
#[test]
fn signing_round_trips() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let signed = fixed_tx()
.sign(std::slice::from_ref(&key), Chain::Hive)
.unwrap();
assert_eq!(signed.signatures.len(), 1);
assert_eq!(signed.signers(Chain::Hive).unwrap(), vec![key.public_key()]);
signed.verify(&[key.public_key()], Chain::Hive).unwrap();
}
#[test]
fn verification_rejects_a_key_that_did_not_sign() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let other = PrivateKey::generate();
let signed = fixed_tx().sign(&[key], Chain::Hive).unwrap();
assert!(signed.verify(&[other.public_key()], Chain::Hive).is_err());
}
#[test]
fn duplicate_keys_produce_one_signature() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let signed = fixed_tx()
.sign(&[key.clone(), key.clone(), key], Chain::Hive)
.unwrap();
assert_eq!(signed.signatures.len(), 1);
}
#[test]
fn multiple_distinct_keys_each_sign() {
let a = PrivateKey::from_wif(TEST_WIF).unwrap();
let b = PrivateKey::generate();
let signed = fixed_tx()
.sign(&[a.clone(), b.clone()], Chain::Hive)
.unwrap();
assert_eq!(signed.signatures.len(), 2);
let signers = signed.signers(Chain::Hive).unwrap();
assert!(signers.contains(&a.public_key()));
assert!(signers.contains(&b.public_key()));
}
#[test]
fn signing_is_reproducible() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let a = fixed_tx()
.sign(std::slice::from_ref(&key), Chain::Hive)
.unwrap();
let b = fixed_tx().sign(&[key], Chain::Hive).unwrap();
assert_eq!(a.signatures, b.signatures);
}
#[test]
fn tampering_with_the_body_invalidates_the_signature() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let mut signed = fixed_tx()
.sign(std::slice::from_ref(&key), Chain::Hive)
.unwrap();
signed.transaction.ref_block_num ^= 1;
assert!(signed.verify(&[key.public_key()], Chain::Hive).is_err());
}
#[test]
fn empty_transactions_and_bad_expirations_are_refused() {
assert!(Transaction::new(block_ref(), vec![], 60).is_err());
assert!(Transaction::new(block_ref(), vec![a_vote()], 0).is_err());
assert!(Transaction::new(block_ref(), vec![a_vote()], 7200).is_err());
assert!(Transaction::new(block_ref(), vec![a_vote()], 60).is_ok());
}
#[test]
fn signing_without_keys_is_refused() {
assert!(fixed_tx().sign(&[], Chain::Hive).is_err());
}
#[test]
fn json_form_matches_what_a_node_expects() {
let key = PrivateKey::from_wif(TEST_WIF).unwrap();
let tx = Transaction {
ref_block_num: 1,
ref_block_prefix: 2,
expiration: PointInTime::from_unix(1_700_000_000).unwrap(),
operations: vec![Operation::CustomJson(CustomJson {
required_auths: vec![],
required_posting_auths: vec!["alice".into()],
id: "test".into(),
json: "{}".into(),
})],
};
let json = tx.sign(&[key], Chain::Hive).unwrap().to_json().unwrap();
assert_eq!(json["ref_block_num"], 1);
assert_eq!(json["expiration"], "2023-11-14T22:13:20");
assert_eq!(json["operations"][0][0], "custom_json");
assert_eq!(json["operations"][0][1]["id"], "test");
assert_eq!(json["signatures"].as_array().unwrap().len(), 1);
assert!(json["extensions"].as_array().unwrap().is_empty());
}
#[test]
fn amounts_survive_the_round_trip_into_a_transaction() {
let tx = Transaction {
ref_block_num: 1,
ref_block_prefix: 2,
expiration: PointInTime::from_unix(1_700_000_000).unwrap(),
operations: vec![Operation::Transfer(crate::operations::Transfer {
from: "alice".into(),
to: "bob".into(),
amount: Amount::parse("0.001 HIVE", Chain::Hive).unwrap(),
memo: String::new(),
})],
};
let body = tx.body_bytes().unwrap();
// The amount's 8-byte unit count must be exactly 1, not 0 or 2.
assert!(body.windows(8).any(|w| w == 1i64.to_le_bytes()));
}
}