SolidusExplorer
⌘K
FINALITY
BLOCK HEIGHT
ACTIVE VALIDATORS

How a Transaction Is Signed: Four Fields, a Strict Verifier That Closes a Real Attack, and a Signature Over JSON

The envelope, from a real transaction

Fetch any transaction and you get four fields.

the sender's public key, thirty-two bytes a nonce, the sender's own counter a payload, naming the operation a signature, sixty-four bytes

that is exactly what the type declares and exactly what came back, with nothing else in the response.

What actually gets signed

Not the transaction. A hash of three things joined together: the public key, the nonce written out as eight little-endian bytes, and the payload rendered as JSON.

The signature is an ordinary Ed25519 signature over that hash.

And the transaction's identifier is a separate hash, over the whole signed object, so the identifier only exists once the signature does.

Your address is derived, not assigned

Take the public key, hash it, keep the first twenty bytes, encode in base58.

Nobody issues you an address. Anybody holding your public key can compute it, and nobody can go the other way.

Note that it is a truncation. Twenty bytes of a thirty-two-byte hash is a deliberate size trade-off, and it is the same choice most chains make.

The verifier is the strict one, and this is the best thing on the page

Ed25519 libraries usually offer two verification functions. This code uses the strict one, and the comment beside it says exactly why.

Under non-strict verification, an all-zeros public key together with an all-zeros signature verifies against anything. The equation is vacuously satisfied. On a chain, that means somebody submits a transaction claiming a sender key of all zeros and it passes.

Strict verification rejects it, along with other small-order keys and with malleable signatures in general.

Somebody thought about this, chose correctly, and wrote down the reason for the next reader. That is worth more than the choice on its own, and this estate reports it as loudly as it reports gaps.

It has a second benefit: because malleable signatures are refused, a given intent from an honest signer has one signature and therefore one transaction identifier.

And the pre-image is a JSON rendering

The payload is serialized to JSON, and the bytes of that JSON are what get hashed.

So a signature verifies only if two independent JSON encoders produce identical bytes. The chain uses one language's serializer. The client library uses another language's.

This is not hypothetical, and our own code shows it. The client carries a test that pins the field ORDER of one payload type, and a separate guard that re-checks a key in the envelope against the same key inside the payload. Both exist because the exact bytes matter.

The practical trap is large integers. A whole-number amount beyond the range a browser number represents exactly renders differently, and the signature silently stops matching. A canonical byte encoding rather than a JSON rendering removes the whole class, and that is a signing-format change rather than a fix.

Replay protection is the nonce, and only the nonce

Every account carries a counter. A transaction names the counter value it expects, and the chain refuses it otherwise.

The check happens before anything is taken. A transaction with the wrong counter produces a failed receipt without the fee being charged, which is the right order.

And the counter is the whole of the protection. The signed message names no chain, so it is the per-chain counter, and nothing in the signature itself, that keeps a transaction where it was meant to go.

What you can check yourself

solidus_getBlock at a height with a transaction, then solidus_getTransaction on the hash

Count the fields. Then look for one naming the chain, or a timestamp, or an expiry. There are none, and the counter is doing all of that work alone.

Keep reading

How a Transaction Is Signed: Four Fields, a Strict Verifier That Closes a Real Attack, and a Signature Over JSON · Solidus — Solidus Explorer