Determinism in Chaincode
This is the topic people stumble over most often in Fabric, and they stumble painfully — because the code
passes tests, works on one machine, and then in the network starts behaving as if transactions were
getting lost on their own. The cause is almost always the same: the chaincode turned out to be
non-deterministic. Here we will unpack where that comes from and which deterministic sources hlf-core gives
you instead of the tempting but forbidden Date.now() and Math.random().
Why It Is a Requirement at All
In Fabric a transaction is first executed on several endorsing peers independently of one another and only then makes it into a block. Each peer runs the same chaincode on the same input and forms its own read-write set: what it read from state and what it is going to write. These sets are then compared, and if they don't match byte for byte, endorsement is not gathered and the transaction never reaches the ledger. In other words, the chaincode has no right to its own opinion: on one and the same request it must return one and the same result on any peer and at any moment — otherwise the network simply won't be able to agree on what happened.
Hence a simple rule, out of which everything else grows: nothing in chaincode must depend on the peer's local time, on randomness, on the outside world, or on any order that may turn out to be different on different peers.
Where Non-Determinism Comes From
Let me list the sources in decreasing order of treachery — the first two look the most harmless and break things most often:
- Local time —
Date.now(),new Date()without an argument, any timer. On two peers these calls will return different values, and the read-write set will diverge. - Randomness —
Math.random(), the system random number generator,crypto.randomUUID(). Randomness by definition is not reproducible, and chaincode must be reproducible. - Calls to the outside world — an HTTP request, reading a file, an environment variable that depends on the node. Each peer has its own outside world, and there is no place for it in a transaction.
- Iteration order — iterating over
Object.keys,Map,Setwhere the keys themselves were collected non-deterministically (for example, arrived from the network in an arbitrary order). The result depends on the order, and the order — on chance. - Floating-point numbers —
0.1 + 0.2,parseFloat, division. The discrepancy here is not so much between peers as between expectation and reality, but in financial logic it is just as unacceptable.
Deterministic Sources: Time and Entropy
The main thing to remember — time and "uniqueness" in chaincode are taken not from the system, but from the
transaction itself. Fabric puts its timestamp and identifier into the transaction proposal, identical for
all peers, and hlf-core exposes them through stub.transaction:
// holder.stub.transaction — identical on all endorsing peers
const date = holder.stub.transaction.date; // deterministic "now"
const hash = holder.stub.transaction.hash; // deterministic transaction identifier
stub.transaction.date is the "now" for chaincode. Any domain logic that needs time (a record's creation
date, a validity period, an "has it expired" check) must take it from here, not from Date. Both the date
we put into the entity and the "has the period expired" comparison equally rely on the transaction's time,
not the node's:
// the creation date is taken from the transaction and is the same on all peers
item.created = holder.stub.transaction.date;
// the period is checked against that same time, not the local Date
if (item.expired < holder.stub.transaction.date) {
throw new EntityExpiredError(item.uid);
}
stub.transaction.hash covers the second need — when uniqueness is required. Where outside the blockchain
a random UUID would suggest itself, in chaincode the transaction hash is taken: it is unique, but at the
same time identical on all peers, because it is computed from the proposal itself rather than from a random
number generator. It is also convenient for building derived identifiers of child records created within a
single transaction.
Money — Strings Only, Through MathUtil
Financial precision cannot be trusted to floating-point numbers, and the point here is not even so much
determinism between peers as the fact that rounding inevitably accumulates a discrepancy. So all amounts in
hlf-core are stored as strings, and arithmetic goes through MathUtil on top of decimal.js:
import { MathUtil } from '@ts-core/common';
const after = MathUtil.add(balance.inUse, value); // not balance.inUse + value
const isEnough = MathUtil.greaterOrEqualThan(after, value);
More on the balance model and converting rubles into kopecks — in the Tokens section.
Iteration Order
With collections the subtlety is that iterating over a Map or an array is itself deterministic — insertion
order is preserved. The danger arises when the source of keys is non-deterministic: if we collected a set of
identifiers from what arrived over the network, and the order on input is not fixed, then the order of
writing into state will drift along with it. This is cured by sorting before iteration — we bring the set to
a canonical order before writing anything:
const uids = Object.keys(request.items).sort(); // canonical order before writing
for (const uid of uids) {
await manager.save(request.items[uid]);
}
The same logic, by the way, is behind entity serialization: on write the object's keys are sorted
(isSortKeys: true) so that one and the same object always yields one and the same string in the ledger.
MVCC and Nonce: Determinism over Time
Determinism is not only the agreement of two peers at a single moment. It also concerns what happens when
two transactions touch the same state. Fabric allows this optimistically, through MVCC: along with each
read, the key's version is recorded, and if by commit time the version has already been changed by another
transaction, ours gets MVCC_READ_CONFLICT and is rejected. Nothing is corrupted, but the transaction
needs to be retried.
In hlf-core this manifests predictably and deliberately. Replay protection is built on a strictly increasing per-user nonce, and the nonce is a shared state key. This means two parallel commands from one user inevitably conflict on it and are serialized: the second one either waits for the first or is retried. For an accounting system such behavior is more often desirable than not: it guarantees a strict order of a user's operations, but it is worth keeping in mind when planning high concurrency. Bulk operations go into batch mode for exactly this reason, where many commands are carried out in a single transaction and do not contend on shared keys.
StateProxy stands apart — an in-transaction cache with deferred commit. Within a single transaction a
repeated read of the same key comes from the cache, and writes are deferred to the end, so the logic inside
a single command sees its own changes consistently and does not spawn extra accesses to state. This does not
weaken MVCC between transactions, but puts things in order within one.
A Short Checklist
Before putting chaincode into the network, it is worth going through the list of what is forbidden — nearly all floating determinism bugs are caught by it:
- No
Date.now(),new Date()without an argument, timers — time only fromstub.transaction.date. - No
Math.random()and no system randomness — uniqueness fromstub.transaction.hash. - No calls to the outside world from
execute— no network, no files, no node-dependent variables. - Amounts — strings through
MathUtil, no arithmetic onnumber. - Any iteration over a set with a non-deterministic source of keys — after sorting.
How these rules look in assembling a working command in full — in the tutorial, and common techniques such as your own crypto manager or moving an operation into a batch are collected in the cookbook.