What we want to do now is just "store the transaction", but I'm afraid it's not quite as simple as that.
Right now, at the end of transaction resolution, we just return a dummy StoredTransaction:
if complete {
return &records.StoredTransaction{}, nil
}
STORE_PENDING_TXS_BY_ID:internal/clienthandler/resolver.go
This needs to have the relevant fields filled in, and then (in recordstorage.go) we need to store it persistently.Filling out the StoredTransaction
We can easily write a test that checks that the stored transaction has all the same fields as the resolved transaction:func TestTheReturnedTxHasAllTheFields(t *testing.T) {
setup()
tx1 := maketx("https://test.com/msg1", "hash", "https://user1.com/", true, "https://user2.com/")
r.ResolveTx(tx1)
tx2 := maketx("https://test.com/msg1", "hash", "https://user1.com/", "https://user2.com/", true)
stx, _ := r.ResolveTx(tx2)
if stx == nil {
t.Fatalf("a stored transaction was not returned after both parties had submitted a signed copy")
}
if stx.ContentLink == nil {
t.Fatalf("the stored transaction did not have the ContentLink")
}
if *stx.ContentLink != *tx1.ContentLink {
t.Fatalf("the stored transaction ContentLink did not match")
}
if !bytes.Equal(stx.ContentHash, tx1.ContentHash) {
t.Fatalf("the stored transaction ContentHash did not match")
}
if len(stx.Signatories) != len(tx1.Signatories) {
t.Fatalf("the stored transaction did not have the correct number of signatories (%d not %d)", len(stx.Signatories), len(tx1.Signatories))
}
checkSignature(t, 0, stx.Signatories, tx1.Signatories)
checkSignature(t, 1, stx.Signatories, tx2.Signatories)
}
STORED_TX_INITIALIZED_TEST:internal/clienthandler/resolver_test.go
Delegating the task of checking individual signatures to checkSignature:func checkSignature(t *testing.T, which int, blockA []*types.Signatory, blockB []*types.Signatory) {
sigA := blockA[which]
sigB := blockB[which]
if sigA.Signer.String() != sigB.Signer.String() {
t.Fatalf("Signer for %d did not match: %s not %s", which, sigA.Signer.String(), sigB.Signer.String())
}
if !bytes.Equal(*sigA.Signature, *sigB.Signature) {
t.Fatalf("Signature for %d did not match: %x not %x", which, sigA.Signature, sigB.Signature)
}
}
STORED_TX_INITIALIZED_TEST:internal/clienthandler/resolver_test.go
And, as always with such tests, it fails precisely because we know we haven't written the code yet.=== RUN TestTheReturnedTxHasAllTheFieldsLet's fix that. First off, we can replace the current "just build one" code with a call to a pseudo-copy-constructor, which we will call CreateStoredTransaction and pass it the resolved transaction:
resolver_test.go:89: the stored transaction did not have the ContentLink
--- FAIL: TestTheReturnedTxHasAllTheFields (0.33s)
if complete {
return records.CreateStoredTransaction(curr), nil
}
CREATING_STORED_TX:internal/clienthandler/resolver.go
and implement that over in records.StoredTransaction:type StoredTransaction struct {
TxID types.Hash
WhenReceived types.Timestamp
ContentLink *url.URL
ContentHash types.Hash
Signatories []*types.Signatory
NodeSig *types.Signature
}
func CreateStoredTransaction(tx *api.Transaction) *StoredTransaction {
copyLink := *tx.ContentLink
ret := StoredTransaction{ContentLink: ©Link, ContentHash: bytes.Clone(tx.ContentHash), Signatories: make([]*types.Signatory, len(tx.Signatories))}
for i, v := range tx.Signatories {
copySigner := *v.Signer
copySig := types.Signature(bytes.Clone(*v.Signature))
signatory := types.Signatory{Signer: ©Signer, Signature: ©Sig}
ret.Signatories[i] = &signatory
}
return &ret
}
CREATING_STORED_TX:internal/records/storedtransaction.go
(the alert among you will notice that the members of StoredTransaction have changed subtly since I first presented it).This is enough to make our current test pass:
=== RUN TestTheReturnedTxHasAllTheFieldsSo far, so good. But looking at the definition of StoredTransaction, it's clear that we have three fields we still haven't initialized. These are different in character to the fields above, because they are not copied from the resolved transaction. At first glance, it might seem like TxID is our old friend Transaction.ID(), but this actually isn't the case: that was used to match up partial transactions, and deliberately did not include the signatures (only one of which would usually be present). When we store the ID for all time, we want to include the signatures in our hash so that they can never be changed. The hash also wants to include the timestamp "when we received the message" or, more accurately, "when we created this StoredTransaction". Finally, we want the node itself to sign off on the whole thing and store its signature at the bottom. Specifically, it is going to sign the TxID hash. So the chain of custody is: all of the fields except TxID and NodeSig are included in the hash TxID. That is then signed and the signature is stored in NodeSig. Nothing can be changed without having the private signing key for this node.
--- PASS: TestTheReturnedTxHasAllTheFields (0.14s)
How do we do this, and how do we test it? Time is one of those annoying things that changes all the time (pun may not have been intended), but we have to get it from somewhere. If we provide a Clock interface, during testing we can use a test double, and then in the "live" environment we can use a trivial implementation which calls through to the real time clock. As with most things of this ilk, I almost deny that it is possible to test this, since its whole existence is predicated on the fact that you can't test without it. So I'm just going to write it and present it:
package helpers
import (
"fmt"
"time"
"github.com/gmmapowell/ChainLedger/internal/types"
)
type Clock interface {
Time() types.Timestamp
}
type ClockDouble struct {
Times []types.Timestamp
next int
}
func ClockDoubleIsoTimes(isoTimes ...string) ClockDouble {
ts := make([]types.Timestamp, len(isoTimes))
for i, s := range isoTimes {
ts[i], _ = types.ParseTimestamp(s)
}
return ClockDouble{Times: ts, next: 0}
}
func ClockDoubleSameDay(isoDate string, times ...string) ClockDouble {
ts := make([]types.Timestamp, len(times))
for i, s := range times {
ts[i], _ = types.ParseTimestamp(isoDate + "_" + s)
}
return ClockDouble{Times: ts, next: 0}
}
func ClockDoubleSameMinute(isoDateHM string, seconds ...string) ClockDouble {
ts := make([]types.Timestamp, len(seconds))
for i, s := range seconds {
ts[i], _ = types.ParseTimestamp(isoDateHM + ":" + s)
}
return ClockDouble{Times: ts, next: 0}
}
func (clock *ClockDouble) Time() types.Timestamp {
if clock.next > len(clock.Times) {
panic("more timestamps requested than provided")
}
r := clock.Times[clock.next]
clock.next++
return r
}
type ClockLive struct {
}
func (clock *ClockLive) Time() types.Timestamp {
gotime := time.Now().UnixMilli()
fmt.Printf("%d", gotime)
return types.Timestamp(gotime)
}
CLOCK_DOUBLE:internal/helpers/clock.go
The three constructors for the double give us flexibility in how we write our tests, especially since we are going to generally be working with times in and around the same second. Quite a bit of the work here has been delegated to the TimeStamp type:package types
import (
"time"
)
type Timestamp int64
const IsoFormat = "2006-01-02_15:04:05.999"
func ParseTimestamp(iso string) (Timestamp, error) {
ts, err := time.Parse(IsoFormat, iso)
if err != nil {
return Timestamp(0), err
}
return Timestamp(ts.UnixMilli()), nil
}
func (ts Timestamp) IsoTime() string {
return time.UnixMilli(int64(ts)).Format(IsoFormat)
}
CLOCK_DOUBLE:internal/types/timestamp.go
We are now in a position to write another test to drive the setting of the time the message was "received":func TestTheReturnedTxHasATimestamp(t *testing.T) {
setup()
clock := helpers.ClockDoubleIsoTimes("2024-12-25_03:00:00.121")
tx1 := maketx("https://test.com/msg1", "hash", "https://user1.com/", true, "https://user2.com/")
r.ResolveTx(tx1)
tx2 := maketx("https://test.com/msg1", "hash", "https://user1.com/", "https://user2.com/", true)
stx, _ := r.ResolveTx(tx2)
if stx == nil {
t.Fatalf("a stored transaction was not returned after both parties had submitted a signed copy")
}
if stx.WhenReceived != clock.Times[0] {
t.Fatalf("the stored transaction was received at %s not %s", stx.WhenReceived.IsoTime(), clock.Times[0].IsoTime())
}
}
RESOLVED_TX_TIMESTAMP_TEST:internal/clienthandler/resolver_test.go
This creates one of our ClockDouble instances and then checks at the end that the WhenReceived field has the first such timestamp. It doesn't, of course.Making this work is a "one-line change" plus a whole bunch of plumbing to get the Clock where it needs to be, both for this test and for the live case (in cmd/chainledger). Take a deep breath and ...
The "one-line change" (WhenReceived is initialized in the creation of StoredTransaction):
func CreateStoredTransaction(clock helpers.Clock, tx *api.Transaction) *StoredTransaction {
copyLink := *tx.ContentLink
ret := StoredTransaction{WhenReceived: clock.Time(), ContentLink: ©Link, ContentHash: bytes.Clone(tx.ContentHash), Signatories: make([]*types.Signatory, len(tx.Signatories))}
for i, v := range tx.Signatories {
copySigner := *v.Signer
copySig := types.Signature(bytes.Clone(*v.Signature))
signatory := types.Signatory{Signer: ©Signer, Signature: ©Sig}
ret.Signatories[i] = &signatory
}
return &ret
}
STORE_TX_TIMESTAMP:internal/records/storedtransaction.go
This requires a clock to be passed in from the resolver:if complete {In turn, this needs a clock to be defined in the struct and passed to its constructor, NewResolver:
return records.CreateStoredTransaction(r.clock, curr), nil
}
type TxResolver struct {...
clock helpers.Clock
store storage.PendingStorage
}
func NewResolver(clock helpers.Clock, store storage.PendingStorage) Resolver {
return &TxResolver{clock: clock, store: store}
}
STORE_TX_TIMESTAMP:internal/clienthandler/resolver.go
Since NewResolver is called from the setup method in the resolver tests, that also needs a Clock passed in, and then all the tests need to pass that. Some of them can just pass "nil" without difficulty and are not shown, but there are three (the ones where the transaction resolves) which now need a timestamp. They are all much the same, but here is the one for the test we are just writing:func TestTheReturnedTxHasATimestamp(t *testing.T) {
clock := helpers.ClockDoubleIsoTimes("2024-12-25_03:00:00.121")
setup(&clock)
STORE_TX_TIMESTAMP:internal/clienthandler/resolver_test.go
And, finally, the chainledger/main.go file needs to pass in a Clock, but it wants to pass in the "real thing": an instance of ClockLive:func main() {
log.Println("starting chainledger")
pending := storage.NewMemoryPendingStorage()
resolver := clienthandler.NewResolver(&helpers.ClockLive{}, pending)
STORE_TX_TIMESTAMP:cmd/chainledger/main.go
And, with that, we can rerun the tests and they all pass:=== RUN TestTheReturnedTxHasATimestamp
--- PASS: TestTheReturnedTxHasATimestamp (0.23s)
Hashing and Signing
Time to move on to those last two fields. We need to create a hash of everything we have so far and store that in TxID, and then sign that with our (as yet non-existent) node-specific private key and store that in the NodeSig field. Then we'll be ready to actually store the transaction.As I commented with Transaction.ID(), it's hard to accurately test a hashing function; you are generally just asserting something you don't understand or know. So this time, I'm not going to bother for now. If I later realize the error of my ways, I'll write a "regression test" that checks that I'm protected against whatever went wrong in the future. I will, however, test that there is something in TxID at the same time as testing that there is a timestamp:
func TestTheReturnedTxHasATimestamp(t *testing.T) {
clock := helpers.ClockDoubleIsoTimes("2024-12-25_03:00:00.121")
setup(&clock)
tx1 := maketx("https://test.com/msg1", "hash", "https://user1.com/", true, "https://user2.com/")
r.ResolveTx(tx1)
tx2 := maketx("https://test.com/msg1", "hash", "https://user1.com/", "https://user2.com/", true)
stx, _ := r.ResolveTx(tx2)
if stx == nil {
t.Fatalf("a stored transaction was not returned after both parties had submitted a signed copy")
}
if stx.WhenReceived != clock.Times[0] {
t.Fatalf("the stored transaction was received at %s not %s", stx.WhenReceived.IsoTime(), clock.Times[0].IsoTime())
}
if stx.TxID == nil {
t.Fatalf("the stored transaction did not have a TxID")
}
}
HASH_STORED_TX:internal/clienthandler/resolver_test.go
Again, this will fail until we store something there. In the create function I have mixed the hashing code in with the copying code for simplicity. If you prefer to go the other way, it would obviously be possible to duplicate the traversal logic and separate the hashing into its own step:func CreateStoredTransaction(clock helpers.Clock, tx *api.Transaction) *StoredTransaction {
copyLink := *tx.ContentLink
ret := StoredTransaction{WhenReceived: clock.Time(), ContentLink: ©Link, ContentHash: bytes.Clone(tx.ContentHash), Signatories: make([]*types.Signatory, len(tx.Signatories))}
hasher := sha512.New()
binary.Write(hasher, binary.LittleEndian, ret.WhenReceived)
hasher.Write([]byte(ret.ContentLink.String()))
hasher.Write([]byte("\n"))
hasher.Write(tx.ContentHash)
for i, v := range tx.Signatories {
copySigner := *v.Signer
hasher.Write([]byte(copySigner.String()))
hasher.Write([]byte("\n"))
copySig := types.Signature(bytes.Clone(*v.Signature))
hasher.Write(copySig)
signatory := types.Signatory{Signer: ©Signer, Signature: ©Sig}
ret.Signatories[i] = &signatory
}
ret.TxID = hasher.Sum(nil)
return &ret
}
HASH_STORED_TX:internal/records/storedtransaction.go
We have already seen how easy it is to sign things in Go. With signatures, we can at least write a test that the signature is correct. Because the resolver isn't actually necessary to call CreateStoredTransaction, I'm not going to use it in this test, but I'm still going to put it in resolver_test.go because I want to take advantage of the scaffolding that is there. The test starts like this:func TestTheReturnedTxIsSigned(t *testing.T) {
clock := helpers.ClockDoubleIsoTimes("2024-12-25_03:00:00.121")
setup(&clock)
tx := maketx("https://test.com/msg1", "hash", "https://user1.com/", true, "https://user2.com/", true)
stx := records.CreateStoredTransaction(&clock, tx)
if stx.NodeSig == nil {
t.Fatalf("the stored transaction was not signed")
}
}
TEST_TX_SIGNED:internal/clienthandler/resolver_test.go
and, of course (say it with me), that fails. The transaction was not signed.Superficially, the signing code looks fairly simple, but there are a few catches:
func CreateStoredTransaction(clock helpers.Clock, nodeKey *rsa.PrivateKey, tx *api.Transaction) (*StoredTransaction, error) {
copyLink := *tx.ContentLink
ret := StoredTransaction{WhenReceived: clock.Time(), ContentLink: ©Link, ContentHash: bytes.Clone(tx.ContentHash), Signatories: make([]*types.Signatory, len(tx.Signatories))}
hasher := sha512.New()
binary.Write(hasher, binary.LittleEndian, ret.WhenReceived)
hasher.Write([]byte(ret.ContentLink.String()))
hasher.Write([]byte("\n"))
hasher.Write(tx.ContentHash)
for i, v := range tx.Signatories {
copySigner := *v.Signer
hasher.Write([]byte(copySigner.String()))
hasher.Write([]byte("\n"))
copySig := types.Signature(bytes.Clone(*v.Signature))
hasher.Write(copySig)
signatory := types.Signatory{Signer: ©Signer, Signature: ©Sig}
ret.Signatories[i] = &signatory
}
ret.TxID = hasher.Sum(nil)
sig, err := rsa.SignPSS(rand.Reader, nodeKey, crypto.SHA512, ret.TxID, nil)
if err != nil {
return nil, err
}
sig1 := types.Signature(sig)
ret.NodeSig = &sig1
return &ret, nil
}
SIGN_STORED_TX:internal/records/storedtransaction.go
First off, because signing can fail, we need to return an error, which changes the signature and will have repercussions. At the same time, we need to pass in a private key, so that changes the signature as well. So, we need to go everywhere this method is called and make the relevant changes ...First, we need to update the test we just wrote:
func TestTheReturnedTxIsSigned(t *testing.T) {
clock := helpers.ClockDoubleIsoTimes("2024-12-25_03:00:00.121")
setup(&clock)
tx := maketx("https://test.com/msg1", "hash", "https://user1.com/", true, "https://user2.com/", true)
stx, _ := records.CreateStoredTransaction(&clock, nodeKey, tx)
if stx.NodeSig == nil {
t.Fatalf("the stored transaction was not signed")
}
}
SIGN_STORED_TX:internal/clienthandler/resolver_test.go
That now requires a nodeKey, so we need to declare that and define it in setupvar repo client.ClientRepository
var nodeKey *rsa.PrivateKey
var s storage.PendingStorage
var r clienthandler.Resolver
func setup(clock helpers.Clock) {
repo, _ = client.MakeMemoryRepo()
nodeKey, _ = rsa.GenerateKey(rand.Reader, 2048)
s = storage.NewMemoryPendingStorage()
r = clienthandler.NewResolver(clock, nodeKey, s)
}
SIGN_STORED_TX:internal/clienthandler/resolver_test.go
The resolver obviously needs to pass the private key:if complete {
return records.CreateStoredTransaction(r.clock, r.nodeKey, curr)
}
SIGN_STORED_TX:internal/clienthandler/resolver.go
Which means it needs to track this in the TxResolver and be passed one in its constructor:type TxResolver struct {...
clock helpers.Clock
nodeKey *rsa.PrivateKey
store storage.PendingStorage
}
func NewResolver(clock helpers.Clock, nodeKey *rsa.PrivateKey, store storage.PendingStorage) Resolver {
return &TxResolver{clock: clock, nodeKey: nodeKey, store: store}
}
SIGN_STORED_TX:internal/clienthandler/resolver.go
And, finally, we need one in main(). Sadly, this is slightly more complicated, because we need to have a private key there. At the moment, we are just hacking everything together, but in the fulness of time we expect everything to be repeatable. So the node's private key needs to be extracted from some kind of node configuration. What kind we don't exactly know yet, but it seems that at the very least we should have something. So let's do this in main():func main() {
log.Println("starting chainledger")
config, err := config.ReadNodeConfig()
if err != nil {
fmt.Printf("error reading config: %s\n", err)
return
}
pending := storage.NewMemoryPendingStorage()
resolver := clienthandler.NewResolver(&helpers.ClockLive{}, config.NodeKey, pending)
SIGN_STORED_TX:cmd/chainledger/main.go
Now we just need an implementation of ReadNodeConfig. We will put this is internal/config/config.go:package configSo, yes, this is just generating one on the fly.
import (
"crypto/rand"
"crypto/rsa"
)
type NodeConfig struct {
NodeKey *rsa.PrivateKey
}
func ReadNodeConfig() (*NodeConfig, error) {
pk, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
return &NodeConfig{NodeKey: pk}, nil
}
The tests all pass at the moment, but I don't feel the most recent one matches the high expectations I have of it, so I am going to go back and verify that the signature we have is a valid signature based on the public key for the hash we stored in TxID:
func TestTheReturnedTxIsSigned(t *testing.T) {
clock := helpers.ClockDoubleIsoTimes("2024-12-25_03:00:00.121")
setup(&clock)
tx := maketx("https://test.com/msg1", "hash", "https://user1.com/", true, "https://user2.com/", true)
stx, _ := records.CreateStoredTransaction(&clock, nodeKey, tx)
if stx.NodeSig == nil {
t.Fatalf("the stored transaction was not signed")
}
err := rsa.VerifyPSS(&nodeKey.PublicKey, crypto.SHA512, stx.TxID, *stx.NodeSig, nil)
if err != nil {
t.Fatalf("signature verification failed")
}
}
VERIFY_TX_SIG:internal/clienthandler/resolver_test.go
I always dread running "those kind" of tests because I know that, if it fails, I am probably going to be here for hours figuring out what exactly I did wrong. Fortunately, this one worked first time. Great. We now have what we want to store.Journalling
The pending transaction logic is the ONLY place in this code where we are going to allow ANYTHING to be updated. As we will see in a little while, updating objects is fraught with complications, and I don't want complications in my life. While it would be possible to implement the pending transaction store without updates, everything is a trade-off, and I made the decision I did for the simple reason that once a pending transaction has been fully resolved, it is "promoted" to a StoredTransaction and the pending transaction is thrown away. The fact that it is inherently transient pushed me in the direction I went. It is left as an exercise to the reader to rewrite the transaction resolution without using in-place updates. You may then want to compare your experience with the code we will need to write in a while to protect the updates from race conditions."Back in the day", as we old fogeys like to say, we called something that was recording everything permanently a "logger". What is now generally called "logging" we called "tracing". To make it clear what I'm talking about here, I am going to describe what we do from now on as "journaling", which I believe is also a commonly accepted term to describe: "storing something permanently and never changing it or deleting it". Now, in life, there are never any guarantees, and anything could happen from your hard drive crashing to a nuclear strike on your cloud centre. But what I mean here is that none of our code is ever going to rewrite history. I will try to stick with this terminology, but if from time to time I still call it "logging transactions", nod silently and bear with me.
If you have a good memory, you may remember that a long time ago, we had this code in the request handler for /store:
if stx, err := r.resolver.ResolveTx(&tx); stx != nil {
// TODO: move the transaction on to the next stage
log.Printf("TODO: move it next stage")
} else if err != nil {
VERIFY_TX_SIG:internal/clienthandler/recordstorage.go
and this is what we want to now complete. This code is "untested" by dint of being directly in the request handler, so we'll delegate very quickly so that we can eyeball it an know there is nothing wrong:if stx, err := r.resolver.ResolveTx(&tx); stx != nil {
r.journal.RecordTx(stx)
} else if err != nil {
INTRODUCE_JOURNALLER:internal/clienthandler/recordstorage.go
Where did r.journal come from? Ah, I was hoping you wouldn't ask. Like everything else, there is a chain here of getting this where it needs to be. We need to update the RecordStorage type and constructor:type RecordStorage struct {
resolver Resolver
journal storage.Journaller
}
func NewRecordStorage(r Resolver, j storage.Journaller) RecordStorage {
return RecordStorage{resolver: r, journal: j}
}
INTRODUCE_JOURNALLER:internal/clienthandler/recordstorage.go
and then we need to create a Journaller in main and pass it in:func main() {
log.Println("starting chainledger")
config, err := config.ReadNodeConfig()
if err != nil {
fmt.Printf("error reading config: %s\n", err)
return
}
pending := storage.NewMemoryPendingStorage()
resolver := clienthandler.NewResolver(&helpers.ClockLive{}, config.NodeKey, pending)
journaller := storage.NewJournaller()
storeRecord := clienthandler.NewRecordStorage(resolver, journaller)
INTRODUCE_JOURNALLER:cmd/chainledger/main.go
which only really leaves us the Journaller implementation itself to concern ourselves with:package storage
import (
"fmt"
"github.com/gmmapowell/ChainLedger/internal/records"
)
type Journaller interface {
RecordTx(tx *records.StoredTransaction) error
}
type DummyJournaller struct {
}
// RecordTx implements Journaller.
func (d *DummyJournaller) RecordTx(tx *records.StoredTransaction) error {
fmt.Printf("Recording tx with id %v\n", tx.TxID)
return nil
}
func NewJournaller() Journaller {
return &DummyJournaller{}
}
INTRODUCE_JOURNALLER:internal/storage/journal.go
There are those who will object that this journaller is not really living up to its contract to store anything, let alone "permanently and forever". But you have to start somewhere, and until somebody actually wants to read what you've written, does it really matter?