Compare commits

...

4 Commits

11 changed files with 316 additions and 35 deletions
+85
View File
@@ -0,0 +1,85 @@
package reconciliation
import (
"context"
"fmt"
"gl/domain/ledger"
)
// Evidence is a normalized record supplied by Wallet, Kuknos, or another
// authoritative settlement source. Adapters remain outside the GL domain.
type Evidence struct {
SourceService string
SourceTransactionID string
EventVersion uint32
BlockchainNetwork string
TransactionHash string
}
type EvidenceReport struct {
Compared int
Missing []Evidence
Duplicates []Evidence
Mismatched []EvidenceMismatch
}
type EvidenceMismatch struct {
Evidence Evidence
JournalID string
Field string
Expected string
Actual string
}
// CompareEvidence compares normalized external evidence with immutable GL
// journals. It is read-only and safe to run repeatedly after outages.
func CompareEvidence(ctx context.Context, repository Repository, evidence []Evidence, pageSize int) (EvidenceReport, error) {
if repository == nil {
return EvidenceReport{}, fmt.Errorf("reconciliation repository is required")
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 100
}
journals := make(map[string]ledger.Journal)
for offset := 0; ; offset += pageSize {
page, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
if err != nil {
return EvidenceReport{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
}
for _, journal := range page {
journals[evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)] = journal
}
if len(page) < pageSize {
break
}
}
report := EvidenceReport{Missing: make([]Evidence, 0), Duplicates: make([]Evidence, 0), Mismatched: make([]EvidenceMismatch, 0)}
seen := make(map[string]struct{}, len(evidence))
for _, item := range evidence {
report.Compared++
key := evidenceKey(item.SourceService, item.SourceTransactionID, item.EventVersion)
if _, exists := seen[key]; exists {
report.Duplicates = append(report.Duplicates, item)
continue
}
seen[key] = struct{}{}
journal, exists := journals[key]
if !exists {
report.Missing = append(report.Missing, item)
continue
}
if item.BlockchainNetwork != "" && item.BlockchainNetwork != journal.Blockchain.Network {
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "blockchain_network", Expected: item.BlockchainNetwork, Actual: journal.Blockchain.Network})
}
if item.TransactionHash != "" && item.TransactionHash != journal.Blockchain.TransactionHash {
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "transaction_hash", Expected: item.TransactionHash, Actual: journal.Blockchain.TransactionHash})
}
}
return report, nil
}
func evidenceKey(sourceService, sourceTransactionID string, eventVersion uint32) string {
return sourceService + "\x00" + sourceTransactionID + "\x00" + fmt.Sprint(eventVersion)
}
@@ -0,0 +1,39 @@
package reconciliation
import (
"context"
"testing"
"gl/domain/ledger"
)
func TestCompareEvidenceReportsMissingDuplicateAndBlockchainMismatch(t *testing.T) {
journal := validJournal()
journal.Blockchain = ledger.BlockchainReference{Network: "kuknos", TransactionHash: "hash-1"}
matching := Evidence{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "hash-1"}
evidence := []Evidence{
matching,
matching,
{SourceService: "wallet", SourceTransactionID: "missing", EventVersion: 1},
{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "wrong"},
}
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, evidence, 10)
if err != nil {
t.Fatal(err)
}
if report.Compared != 4 || len(report.Missing) != 1 || len(report.Duplicates) != 2 || len(report.Mismatched) != 0 {
t.Fatalf("unexpected report: %+v", report)
}
}
func TestCompareEvidenceReportsHashMismatch(t *testing.T) {
journal := validJournal()
journal.Blockchain.TransactionHash = "actual"
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, []Evidence{{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, TransactionHash: "expected"}}, 10)
if err != nil {
t.Fatal(err)
}
if len(report.Mismatched) != 1 || report.Mismatched[0].Field != "transaction_hash" {
t.Fatalf("unexpected report: %+v", report)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package reconciliation provides read-only integrity checks for the GL.
package reconciliation
import (
"context"
"fmt"
"gl/domain/ledger"
)
type Repository interface {
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
}
type Report struct {
Scanned int
Valid int
Invalid []Issue
}
type Issue struct {
JournalID string
Error string
}
// Run scans all posted journals without mutating the ledger.
func Run(ctx context.Context, repository Repository, pageSize int) (Report, error) {
if repository == nil {
return Report{}, fmt.Errorf("reconciliation repository is required")
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 100
}
report := Report{Invalid: make([]Issue, 0)}
seen := make(map[string]string)
for offset := 0; ; offset += pageSize {
journals, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
if err != nil {
return Report{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
}
for _, journal := range journals {
report.Scanned++
if err := journal.Validate(); err != nil {
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()})
continue
}
key := evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)
if previous, exists := seen[key]; exists {
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)})
continue
}
seen[key] = journal.ID
report.Valid++
}
if len(journals) < pageSize {
break
}
}
return report, nil
}
@@ -0,0 +1,56 @@
package reconciliation
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
type repositoryStub struct {
pages [][]ledger.Journal
}
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
index := filter.Offset / filter.Limit
if index >= len(r.pages) {
return nil, nil
}
return r.pages[index], nil
}
func validJournal() ledger.Journal {
return ledger.Journal{ID: "j1", SourceService: "wallet", IdempotencyKey: "k1", SourceTransactionID: "t1", EffectKind: "transfer", EventVersion: 1, OccurredAt: ledgerTestTime(), PayloadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Entries: []ledger.Entry{{LineNumber: 1, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("-1")}, {LineNumber: 2, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("1")}}}
}
func ledgerTestTime() (t time.Time) { return time.Unix(1, 0).UTC() }
func mustAmount(value string) ledger.Amount { amount, _ := ledger.ParseAmount(value); return amount }
func TestRunScansAndReportsInvalidJournals(t *testing.T) {
valid := validJournal()
invalid := valid
invalid.Entries = append([]ledger.Entry(nil), valid.Entries...)
invalid.ID = "bad"
invalid.Entries[1].Amount = mustAmount("2")
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{valid}, {invalid}}}, 1)
if err != nil {
t.Fatal(err)
}
if report.Scanned != 2 || report.Valid != 1 || len(report.Invalid) != 1 || report.Invalid[0].JournalID != "bad" {
t.Fatalf("unexpected report: %+v", report)
}
}
func TestRunReportsDuplicateTransactionVersions(t *testing.T) {
first := validJournal()
second := validJournal()
second.ID = "j2"
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{first, second}}}, 10)
if err != nil {
t.Fatal(err)
}
if report.Valid != 1 || len(report.Invalid) != 1 {
t.Fatalf("unexpected report: %+v", report)
}
}
+2
View File
@@ -10,11 +10,13 @@ import (
"gl/application/explorer"
"gl/infrastructure/config"
"gl/infrastructure/observability"
"gl/infrastructure/postgres"
webadapter "gl/interface/web"
)
func main() {
observability.Configure("gl-dashboard", slog.LevelInfo)
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
flag.Parse()
+2
View File
@@ -11,11 +11,13 @@ import (
"gl/application/health"
applicationledger "gl/application/ledger"
"gl/infrastructure/config"
"gl/infrastructure/observability"
"gl/infrastructure/postgres"
grpcadapter "gl/interface/grpc"
)
func main() {
observability.Configure("gl", slog.LevelInfo)
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
flag.Parse()
+1 -1
View File
@@ -2,7 +2,7 @@ environment = "local"
[http]
host = "0.0.0.0"
port = 8080
port = 8601
read-header-timeout = "5s"
shutdown-timeout = "10s"
-10
View File
@@ -13,30 +13,20 @@ require (
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
)
-23
View File
@@ -1,18 +1,8 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
@@ -35,17 +25,10 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -57,20 +40,14 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
+30
View File
@@ -0,0 +1,30 @@
package observability
import (
"log/slog"
"os"
)
// Configure installs the process-wide JSON logger used by every GL adapter.
func Configure(service string, level slog.Leveler) *slog.Logger {
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case slog.TimeKey:
attr.Key = "timestamp"
case slog.LevelKey:
attr.Key = "severity"
case slog.MessageKey:
attr.Key = "message"
case slog.SourceKey:
attr.Key = "source"
}
return attr
},
})
logger := slog.New(handler).With("service", service)
slog.SetDefault(logger)
return logger
}
+41 -1
View File
@@ -4,13 +4,17 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"runtime/debug"
"time"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
)
type ServerConfig struct {
@@ -30,7 +34,7 @@ func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerSe
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
defer listener.Close()
server := grpc.NewServer()
server := grpc.NewServer(grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
reflection.Register(server)
@@ -67,3 +71,39 @@ func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listene
}
return nil
}
func structuredUnaryLogger() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
started := time.Now()
response, err := handler(ctx, req)
code := status.Code(err)
level := slog.LevelInfo
if code != codes.OK {
level = slog.LevelError
}
slog.Log(ctx, level, "grpc call finished",
"component", "grpc_server",
"grpc_method", info.FullMethod,
"grpc_code", code.String(),
"duration_ms", float64(time.Since(started).Microseconds())/1000,
)
return response, err
}
}
func panicRecovery() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
defer func() {
if recovered := recover(); recovered != nil {
slog.ErrorContext(ctx, "grpc panic recovered",
"component", "grpc_server",
"grpc_method", info.FullMethod,
"panic", recovered,
"stack", string(debug.Stack()),
)
err = status.Error(codes.Internal, "internal server error")
}
}()
return handler(ctx, req)
}
}