From 1863de1f3be0118ea98d41e81f1f9f785b0da2c4eb915fb5867a05264c718603 Mon Sep 17 00:00:00 2001 From: nfel Date: Fri, 14 Aug 2026 18:45:02 +0330 Subject: [PATCH 01/20] docs(gl): define ledger architecture and invariants --- DESIGN.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 5 ++ 2 files changed, 167 insertions(+) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..941c331 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,162 @@ +# General Ledger service design + +Status: accepted for the first implementation slice on 2026-08-14. + +## Purpose and ownership + +`GL` is Darano's durable financial journal. It records every committed wallet +value movement independently of Stellar so balances and transaction history can +be reconstructed during a blockchain or provider outage. + +GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does +not write GL tables directly, and GL does not write wallet tables. In normal +operation Wallet remains the transaction orchestrator and Stellar remains the +external settlement network. Promoting GL from a mirror to an operational +fallback is an explicit, audited mode change; an outage must never make a failed +blockchain operation appear successful automatically. + +## Non-negotiable invariants + +1. Every monetary journal is double entry. Signed entries sum to zero for each + asset in a journal. +2. Journals and entries are append-only. Corrections append a full reversal and + a replacement; update and delete operations are not exposed. +3. Amounts cross the API as canonical decimal strings and are stored as + `numeric(38,18)`. Floating-point values are rejected at the GL boundary. +4. A source idempotency key identifies one immutable payload. Replaying the + same key and payload succeeds with the original result; reusing the key for + different content fails. +5. Account, asset, source transaction, correlation, actor, occurrence time, and + recording time are retained on every applicable record. +6. A journal commits atomically with all its entries or not at all. +7. A financial effect is journaled only when Wallet has committed that effect. + Pending and failed lifecycle events may be retained as audit events but do + not create monetary entries. +8. Derived balances are rebuildable solely by ordering and summing immutable + entries. Cached balances are disposable projections, never source data. + +## Data model + +### `ledger_accounts` + +A stable account is scoped to an asset and optional owner. Initial account +classes are `USER_AVAILABLE`, `USER_FROZEN`, `EXTERNAL_BLOCKCHAIN`, +`TREASURY`, `MARKET_CLEARING`, `IPG_CLEARING`, and `COMMISSION_REVENUE`. +The natural identity `(class, owner_type, owner_id, asset_id)` is unique. + +### `journals` + +Each journal contains a generated UUID, source service, unique idempotency key, +source transaction ID and tracking code, effect kind and version, optional +reversal target, occurred/recorded timestamps, correlation and actor IDs, +blockchain network/hash/sequence references, and JSON metadata. A canonical +payload hash detects conflicting reuse of an idempotency key. + +### `entries` + +Each entry contains its journal ID, line number, ledger account, asset ID, +signed decimal amount, and optional description. `(journal_id, line_number)` is +unique. Positive means credit and negative means debit. Zero entries are +invalid. Database constraints and the application transaction jointly enforce +precision, immutability, account/asset agreement, and per-asset balance. + +### `transaction_events` + +Lifecycle observations such as `CREATED`, `PENDING_TRX`, `PENDING_ADMIN`, +`FAILED`, and `SUCCESSFUL` are append-only events keyed idempotently by source +transaction and event version. They preserve error and blockchain metadata but +are separate from monetary journals. + +## Wallet-to-ledger mapping + +Every row below balances independently per asset. Where one business operation +moves two assets, the entries belong to one correlated journal containing two +balanced asset groups. + +| Wallet effect | Debit | Credit | +|---|---|---| +| Internal transfer | sender `USER_AVAILABLE` | recipient `USER_AVAILABLE` | +| External deposit | `EXTERNAL_BLOCKCHAIN` | recipient `USER_AVAILABLE` | +| External withdrawal | sender `USER_AVAILABLE` | `EXTERNAL_BLOCKCHAIN` | +| Freeze/lock | user `USER_AVAILABLE` | user `USER_FROZEN` | +| Release/unlock | user `USER_FROZEN` | user `USER_AVAILABLE` | +| IRT/IPG deposit | `IPG_CLEARING` | user `USER_AVAILABLE` | +| IRT withdrawal | user `USER_AVAILABLE` | `IPG_CLEARING` | +| Commission | payer or originating clearing account | `COMMISSION_REVENUE` | +| Buy/ICO | buyer IRT; treasury asset inventory | `TREASURY` IRT; buyer asset | +| Sell/redeem | seller asset; `TREASURY` IRT | treasury asset inventory; seller IRT | +| Market trade | buyer quote and seller base | seller quote and buyer base | + +Trustline creation is non-monetary and is recorded only as a transaction event. +The adapter must derive accounts from stable IDs, not display names or public +keys. Before integration, each existing Wallet call site must confirm its exact +counterparty and fee legs; missing information is a validation failure, not an +implicit suspense posting. + +## Delivery and ordering + +Wallet owns a durable `ledger_outbox` table. The wallet state change, wallet +transaction update, and outbox insert occur in the same PostgreSQL transaction. +A dispatcher delivers events to GL with at-least-once semantics. It retries with +backoff and never drops a record; poison records are quarantined and alerted but +remain replayable. + +The idempotency key format is +`wallet:::v`. A transaction hash is +metadata, not an identity, because it can be absent or replaced. GL serializes +posts that affect the same accounts, records both source and receipt ordering, +and returns the existing journal for identical duplicates. Together, the +transactional outbox and GL idempotency provide effectively-once posting. + +Wallet must not synchronously dual-write its database and GL. GL/network +unavailability therefore cannot lose an already committed wallet effect and +does not hold a wallet database transaction open. + +## Operating modes and failure semantics + +- `MIRROR`: normal mode. Wallet follows its existing settlement policy and GL + asynchronously records every committed effect. +- `DEGRADED_LEDGER`: explicitly enabled by an authorized operator. Eligible + internal operations may settle against GL while blockchain-bound operations + remain pending. The initial implementation does not activate this mode. +- `RECONCILE`: outbound posting is paused or restricted while tooling compares + Wallet, GL, and blockchain state and appends approved reversals/corrections. + +GL rejects unbalanced journals, invalid precision, unknown account/asset +combinations, duplicate line numbers, missing source identity, conflicting +idempotency payloads, and invalid reversals. Identical retries are successful. +If Wallet cannot write its outbox in the local transaction, that wallet +transaction rolls back. If dispatch fails, Wallet records retry state without +changing the committed financial result. + +## Internal service boundary + +The first `ledger.v1.GeneralLedgerService` contract provides: + +- `AppendJournal` for one atomic, idempotent balanced journal; +- `AppendTransactionEvent` for non-monetary lifecycle history; +- `GetJournal` and `ListEntries` for audit and replay inspection; +- `GetBalance` for a rebuildable account/asset projection; +- `Health` for readiness and database health. + +Wallet depends on its own `Ledger` port. Its gRPC implementation translates +wallet domain values to this contract; Wallet never imports GL persistence +types. Deadlines, retry classification, authentication, and correlation metadata +are adapter concerns. + +## Implementation sequence + +1. Add backward-compatible `ledger/v1` protobuf messages and service methods. +2. Scaffold GL with domain, application, infrastructure, and interface layers. +3. Add explicit SQL migrations and a transactional PostgreSQL repository. +4. Implement invariant validation, idempotency, queries, and reversals. +5. Add Wallet's ledger port, gRPC adapter, transactional outbox, and dispatcher. +6. Map each Wallet transaction path and add outage/replay/reconciliation tests. + +## Initial non-goals + +- Replacing Stellar automatically on health-check failure. +- Editing or deleting posted journals. +- Storing binary floats or using Wallet's mutable transaction table as GL. +- Sharing a database schema between Wallet and GL. +- Migrating UI, documentation-site, or DevOps repositories. diff --git a/README.md b/README.md index a6a9bdf..c6eb249 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # GL +Darano's independent, append-only general-ledger service. It preserves every +committed wallet value movement so financial state can be reconstructed when a +blockchain or provider is unavailable. + +The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md). From b1e0b0159848bbc0038ba05e714b8f20c6008b7e3d461dd62f19af8fadd3353e Mon Sep 17 00:00:00 2001 From: nfel Date: Fri, 14 Aug 2026 18:52:30 +0330 Subject: [PATCH 02/20] feat(gl): scaffold general ledger grpc service --- Makefile | 10 + README.md | 10 + application/health/service.go | 29 + application/health/service_test.go | 30 + buf.gen.yaml | 16 + cmd/gl/main.go | 40 + gen/base/v1/msg.pb.go | 368 ++++++ gen/ledger/v1/msg.pb.go | 1767 ++++++++++++++++++++++++++ gen/ledger/v1/srv.pb.go | 102 ++ gen/ledger/v1/srv_grpc.pb.go | 348 +++++ gl.cfg.toml | 14 + go.mod | 24 + go.sum | 40 + infrastructure/config/config.go | 81 ++ infrastructure/config/config_test.go | 46 + interface/grpc/health.go | 26 + interface/grpc/health_test.go | 19 + interface/grpc/server.go | 69 + interface/grpc/server_test.go | 26 + 19 files changed, 3065 insertions(+) create mode 100644 Makefile create mode 100644 application/health/service.go create mode 100644 application/health/service_test.go create mode 100644 buf.gen.yaml create mode 100644 cmd/gl/main.go create mode 100644 gen/base/v1/msg.pb.go create mode 100644 gen/ledger/v1/msg.pb.go create mode 100644 gen/ledger/v1/srv.pb.go create mode 100644 gen/ledger/v1/srv_grpc.pb.go create mode 100644 gl.cfg.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infrastructure/config/config.go create mode 100644 infrastructure/config/config_test.go create mode 100644 interface/grpc/health.go create mode 100644 interface/grpc/health_test.go create mode 100644 interface/grpc/server.go create mode 100644 interface/grpc/server_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0aae601 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: build generate test + +generate: + buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1 + +build: generate + go build ./... + +test: + go test ./... diff --git a/README.md b/README.md index c6eb249..0e473a7 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,13 @@ committed wallet value movement so financial state can be reconstructed when a blockchain or provider is unavailable. The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md). + +Generate protobufs, test, and build with: + +```bash +make generate +make test +make build +``` + +Run locally with `go run ./cmd/gl -conf ./gl.cfg.toml`. diff --git a/application/health/service.go b/application/health/service.go new file mode 100644 index 0000000..34f383d --- /dev/null +++ b/application/health/service.go @@ -0,0 +1,29 @@ +// Package health provides the service readiness use case. +package health + +import "context" + +type Database interface { + Ping(context.Context) error +} + +type Status struct { + Serving bool + DatabaseReady bool +} + +type Service struct { + database Database +} + +func NewService(database Database) *Service { + return &Service{database: database} +} + +func (s *Service) Check(ctx context.Context) Status { + status := Status{Serving: true} + if s.database != nil { + status.DatabaseReady = s.database.Ping(ctx) == nil + } + return status +} diff --git a/application/health/service_test.go b/application/health/service_test.go new file mode 100644 index 0000000..26134c4 --- /dev/null +++ b/application/health/service_test.go @@ -0,0 +1,30 @@ +package health + +import ( + "context" + "errors" + "testing" +) + +type databaseStub struct{ err error } + +func (s databaseStub) Ping(context.Context) error { return s.err } + +func TestCheckReportsDatabaseReadiness(t *testing.T) { + for _, tc := range []struct { + name string + db Database + ready bool + }{ + {name: "not connected", db: nil, ready: false}, + {name: "ready", db: databaseStub{}, ready: true}, + {name: "unavailable", db: databaseStub{err: errors.New("down")}, ready: false}, + } { + t.Run(tc.name, func(t *testing.T) { + got := NewService(tc.db).Check(context.Background()) + if !got.Serving || got.DatabaseReady != tc.ready { + t.Fatalf("unexpected status: %+v", got) + } + }) + } +} diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..a2081fa --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,16 @@ +version: v2 +clean: true +managed: + enabled: true + override: + - file_option: go_package_prefix + value: gl/gen +plugins: + - local: protoc-gen-go + out: gen + opt: paths=source_relative + - local: protoc-gen-go-grpc + out: gen + opt: + - paths=source_relative + - require_unimplemented_servers=false diff --git a/cmd/gl/main.go b/cmd/gl/main.go new file mode 100644 index 0000000..7e13b62 --- /dev/null +++ b/cmd/gl/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "gl/application/health" + "gl/infrastructure/config" + grpcadapter "gl/interface/grpc" +) + +func main() { + configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("load configuration", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + handler := grpcadapter.NewHealthHandler(health.NewService(nil)) + slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) + serverConfig := grpcadapter.ServerConfig{ + Host: cfg.GRPC.Host, + Port: cfg.GRPC.Port, + ShutdownTimeout: cfg.GRPC.ShutdownTimeout, + } + if err := grpcadapter.Run(ctx, serverConfig, handler); err != nil { + slog.Error("GL service stopped", "error", err) + os.Exit(1) + } +} diff --git a/gen/base/v1/msg.pb.go b/gen/base/v1/msg.pb.go new file mode 100644 index 0000000..c90445a --- /dev/null +++ b/gen/base/v1/msg.pb.go @@ -0,0 +1,368 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: base/v1/msg.proto + +package basev1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_base_v1_msg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{0} +} + +type StatusRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusRes) Reset() { + *x = StatusRes{} + mi := &file_base_v1_msg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusRes) ProtoMessage() {} + +func (x *StatusRes) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusRes.ProtoReflect.Descriptor instead. +func (*StatusRes) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{1} +} + +func (x *StatusRes) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +type IdRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IdRes) Reset() { + *x = IdRes{} + mi := &file_base_v1_msg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IdRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IdRes) ProtoMessage() {} + +func (x *IdRes) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IdRes.ProtoReflect.Descriptor instead. +func (*IdRes) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{2} +} + +func (x *IdRes) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type IdReq struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IdReq) Reset() { + *x = IdReq{} + mi := &file_base_v1_msg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IdReq) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IdReq) ProtoMessage() {} + +func (x *IdReq) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IdReq.ProtoReflect.Descriptor instead. +func (*IdReq) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{3} +} + +func (x *IdReq) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +type YesNoRes struct { + state protoimpl.MessageState `protogen:"open.v1"` + Yes bool `protobuf:"varint,1,opt,name=yes,proto3" json:"yes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *YesNoRes) Reset() { + *x = YesNoRes{} + mi := &file_base_v1_msg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *YesNoRes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*YesNoRes) ProtoMessage() {} + +func (x *YesNoRes) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use YesNoRes.ProtoReflect.Descriptor instead. +func (*YesNoRes) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{4} +} + +func (x *YesNoRes) GetYes() bool { + if x != nil { + return x.Yes + } + return false +} + +type PaginationRespSample struct { + state protoimpl.MessageState `protogen:"open.v1"` + PageNo uint32 `protobuf:"varint,2,opt,name=page_no,json=pageNo,proto3" json:"page_no,omitempty"` + PageSize uint32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + TotalCount uint32 `protobuf:"varint,4,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PaginationRespSample) Reset() { + *x = PaginationRespSample{} + mi := &file_base_v1_msg_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PaginationRespSample) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PaginationRespSample) ProtoMessage() {} + +func (x *PaginationRespSample) ProtoReflect() protoreflect.Message { + mi := &file_base_v1_msg_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PaginationRespSample.ProtoReflect.Descriptor instead. +func (*PaginationRespSample) Descriptor() ([]byte, []int) { + return file_base_v1_msg_proto_rawDescGZIP(), []int{5} +} + +func (x *PaginationRespSample) GetPageNo() uint32 { + if x != nil { + return x.PageNo + } + return 0 +} + +func (x *PaginationRespSample) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *PaginationRespSample) GetTotalCount() uint32 { + if x != nil { + return x.TotalCount + } + return 0 +} + +var File_base_v1_msg_proto protoreflect.FileDescriptor + +const file_base_v1_msg_proto_rawDesc = "" + + "\n" + + "\x11base/v1/msg.proto\x12\abase.v1\"\a\n" + + "\x05Empty\"%\n" + + "\tStatusRes\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\"\x17\n" + + "\x05IdRes\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"\x17\n" + + "\x05IdReq\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\"\x1c\n" + + "\bYesNoRes\x12\x10\n" + + "\x03yes\x18\x01 \x01(\bR\x03yes\"m\n" + + "\x14PaginationRespSample\x12\x17\n" + + "\apage_no\x18\x02 \x01(\rR\x06pageNo\x12\x1b\n" + + "\tpage_size\x18\x03 \x01(\rR\bpageSize\x12\x1f\n" + + "\vtotal_count\x18\x04 \x01(\rR\n" + + "totalCountBk\n" + + "\vcom.base.v1B\bMsgProtoP\x01Z\x15gl/gen/base/v1;basev1\xa2\x02\x03BXX\xaa\x02\aBase.V1\xca\x02\aBase\\V1\xe2\x02\x13Base\\V1\\GPBMetadata\xea\x02\bBase::V1b\x06proto3" + +var ( + file_base_v1_msg_proto_rawDescOnce sync.Once + file_base_v1_msg_proto_rawDescData []byte +) + +func file_base_v1_msg_proto_rawDescGZIP() []byte { + file_base_v1_msg_proto_rawDescOnce.Do(func() { + file_base_v1_msg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_base_v1_msg_proto_rawDesc), len(file_base_v1_msg_proto_rawDesc))) + }) + return file_base_v1_msg_proto_rawDescData +} + +var file_base_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_base_v1_msg_proto_goTypes = []any{ + (*Empty)(nil), // 0: base.v1.Empty + (*StatusRes)(nil), // 1: base.v1.StatusRes + (*IdRes)(nil), // 2: base.v1.IdRes + (*IdReq)(nil), // 3: base.v1.IdReq + (*YesNoRes)(nil), // 4: base.v1.YesNoRes + (*PaginationRespSample)(nil), // 5: base.v1.PaginationRespSample +} +var file_base_v1_msg_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_base_v1_msg_proto_init() } +func file_base_v1_msg_proto_init() { + if File_base_v1_msg_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_base_v1_msg_proto_rawDesc), len(file_base_v1_msg_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_base_v1_msg_proto_goTypes, + DependencyIndexes: file_base_v1_msg_proto_depIdxs, + MessageInfos: file_base_v1_msg_proto_msgTypes, + }.Build() + File_base_v1_msg_proto = out.File + file_base_v1_msg_proto_goTypes = nil + file_base_v1_msg_proto_depIdxs = nil +} diff --git a/gen/ledger/v1/msg.pb.go b/gen/ledger/v1/msg.pb.go new file mode 100644 index 0000000..2c39792 --- /dev/null +++ b/gen/ledger/v1/msg.pb.go @@ -0,0 +1,1767 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: ledger/v1/msg.proto + +package ledgerv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AccountClass int32 + +const ( + AccountClass_ACCOUNT_CLASS_UNSPECIFIED AccountClass = 0 + AccountClass_ACCOUNT_CLASS_USER_AVAILABLE AccountClass = 1 + AccountClass_ACCOUNT_CLASS_USER_FROZEN AccountClass = 2 + AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN AccountClass = 3 + AccountClass_ACCOUNT_CLASS_TREASURY AccountClass = 4 + AccountClass_ACCOUNT_CLASS_MARKET_CLEARING AccountClass = 5 + AccountClass_ACCOUNT_CLASS_IPG_CLEARING AccountClass = 6 + AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE AccountClass = 7 +) + +// Enum value maps for AccountClass. +var ( + AccountClass_name = map[int32]string{ + 0: "ACCOUNT_CLASS_UNSPECIFIED", + 1: "ACCOUNT_CLASS_USER_AVAILABLE", + 2: "ACCOUNT_CLASS_USER_FROZEN", + 3: "ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN", + 4: "ACCOUNT_CLASS_TREASURY", + 5: "ACCOUNT_CLASS_MARKET_CLEARING", + 6: "ACCOUNT_CLASS_IPG_CLEARING", + 7: "ACCOUNT_CLASS_COMMISSION_REVENUE", + } + AccountClass_value = map[string]int32{ + "ACCOUNT_CLASS_UNSPECIFIED": 0, + "ACCOUNT_CLASS_USER_AVAILABLE": 1, + "ACCOUNT_CLASS_USER_FROZEN": 2, + "ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN": 3, + "ACCOUNT_CLASS_TREASURY": 4, + "ACCOUNT_CLASS_MARKET_CLEARING": 5, + "ACCOUNT_CLASS_IPG_CLEARING": 6, + "ACCOUNT_CLASS_COMMISSION_REVENUE": 7, + } +) + +func (x AccountClass) Enum() *AccountClass { + p := new(AccountClass) + *p = x + return p +} + +func (x AccountClass) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AccountClass) Descriptor() protoreflect.EnumDescriptor { + return file_ledger_v1_msg_proto_enumTypes[0].Descriptor() +} + +func (AccountClass) Type() protoreflect.EnumType { + return &file_ledger_v1_msg_proto_enumTypes[0] +} + +func (x AccountClass) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AccountClass.Descriptor instead. +func (AccountClass) EnumDescriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{0} +} + +type TransactionState int32 + +const ( + TransactionState_TRANSACTION_STATE_UNSPECIFIED TransactionState = 0 + TransactionState_TRANSACTION_STATE_CREATED TransactionState = 1 + TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION TransactionState = 2 + TransactionState_TRANSACTION_STATE_PENDING_ADMIN TransactionState = 3 + TransactionState_TRANSACTION_STATE_SUCCESSFUL TransactionState = 4 + TransactionState_TRANSACTION_STATE_FAILED TransactionState = 5 + TransactionState_TRANSACTION_STATE_SUSPENDED TransactionState = 6 +) + +// Enum value maps for TransactionState. +var ( + TransactionState_name = map[int32]string{ + 0: "TRANSACTION_STATE_UNSPECIFIED", + 1: "TRANSACTION_STATE_CREATED", + 2: "TRANSACTION_STATE_PENDING_TRANSACTION", + 3: "TRANSACTION_STATE_PENDING_ADMIN", + 4: "TRANSACTION_STATE_SUCCESSFUL", + 5: "TRANSACTION_STATE_FAILED", + 6: "TRANSACTION_STATE_SUSPENDED", + } + TransactionState_value = map[string]int32{ + "TRANSACTION_STATE_UNSPECIFIED": 0, + "TRANSACTION_STATE_CREATED": 1, + "TRANSACTION_STATE_PENDING_TRANSACTION": 2, + "TRANSACTION_STATE_PENDING_ADMIN": 3, + "TRANSACTION_STATE_SUCCESSFUL": 4, + "TRANSACTION_STATE_FAILED": 5, + "TRANSACTION_STATE_SUSPENDED": 6, + } +) + +func (x TransactionState) Enum() *TransactionState { + p := new(TransactionState) + *p = x + return p +} + +func (x TransactionState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TransactionState) Descriptor() protoreflect.EnumDescriptor { + return file_ledger_v1_msg_proto_enumTypes[1].Descriptor() +} + +func (TransactionState) Type() protoreflect.EnumType { + return &file_ledger_v1_msg_proto_enumTypes[1] +} + +func (x TransactionState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TransactionState.Descriptor instead. +func (TransactionState) EnumDescriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} +} + +type AccountReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + AccountClass AccountClass `protobuf:"varint,1,opt,name=account_class,json=accountClass,proto3,enum=ledger.v1.AccountClass" json:"account_class,omitempty"` + OwnerType string `protobuf:"bytes,2,opt,name=owner_type,json=ownerType,proto3" json:"owner_type,omitempty"` + OwnerId string `protobuf:"bytes,3,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + AssetId int64 `protobuf:"varint,4,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountReference) Reset() { + *x = AccountReference{} + mi := &file_ledger_v1_msg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountReference) ProtoMessage() {} + +func (x *AccountReference) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountReference.ProtoReflect.Descriptor instead. +func (*AccountReference) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{0} +} + +func (x *AccountReference) GetAccountClass() AccountClass { + if x != nil { + return x.AccountClass + } + return AccountClass_ACCOUNT_CLASS_UNSPECIFIED +} + +func (x *AccountReference) GetOwnerType() string { + if x != nil { + return x.OwnerType + } + return "" +} + +func (x *AccountReference) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *AccountReference) GetAssetId() int64 { + if x != nil { + return x.AssetId + } + return 0 +} + +type JournalEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + LineNumber uint32 `protobuf:"varint,1,opt,name=line_number,json=lineNumber,proto3" json:"line_number,omitempty"` + Account *AccountReference `protobuf:"bytes,2,opt,name=account,proto3" json:"account,omitempty"` + // Canonical base-10 decimal. Positive is credit and negative is debit. + Amount string `protobuf:"bytes,3,opt,name=amount,proto3" json:"amount,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *JournalEntry) Reset() { + *x = JournalEntry{} + mi := &file_ledger_v1_msg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *JournalEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*JournalEntry) ProtoMessage() {} + +func (x *JournalEntry) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use JournalEntry.ProtoReflect.Descriptor instead. +func (*JournalEntry) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} +} + +func (x *JournalEntry) GetLineNumber() uint32 { + if x != nil { + return x.LineNumber + } + return 0 +} + +func (x *JournalEntry) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *JournalEntry) GetAmount() string { + if x != nil { + return x.Amount + } + return "" +} + +func (x *JournalEntry) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type BlockchainReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + Network string `protobuf:"bytes,1,opt,name=network,proto3" json:"network,omitempty"` + TransactionHash string `protobuf:"bytes,2,opt,name=transaction_hash,json=transactionHash,proto3" json:"transaction_hash,omitempty"` + LedgerSequence string `protobuf:"bytes,3,opt,name=ledger_sequence,json=ledgerSequence,proto3" json:"ledger_sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BlockchainReference) Reset() { + *x = BlockchainReference{} + mi := &file_ledger_v1_msg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BlockchainReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BlockchainReference) ProtoMessage() {} + +func (x *BlockchainReference) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BlockchainReference.ProtoReflect.Descriptor instead. +func (*BlockchainReference) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{2} +} + +func (x *BlockchainReference) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *BlockchainReference) GetTransactionHash() string { + if x != nil { + return x.TransactionHash + } + return "" +} + +func (x *BlockchainReference) GetLedgerSequence() string { + if x != nil { + return x.LedgerSequence + } + return "" +} + +type Journal struct { + state protoimpl.MessageState `protogen:"open.v1"` + JournalId string `protobuf:"bytes,1,opt,name=journal_id,json=journalId,proto3" json:"journal_id,omitempty"` + SourceService string `protobuf:"bytes,2,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + IdempotencyKey string `protobuf:"bytes,3,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + SourceTransactionId string `protobuf:"bytes,4,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + TrackingCode string `protobuf:"bytes,5,opt,name=tracking_code,json=trackingCode,proto3" json:"tracking_code,omitempty"` + EffectKind string `protobuf:"bytes,6,opt,name=effect_kind,json=effectKind,proto3" json:"effect_kind,omitempty"` + EventVersion uint32 `protobuf:"varint,7,opt,name=event_version,json=eventVersion,proto3" json:"event_version,omitempty"` + Entries []*JournalEntry `protobuf:"bytes,8,rep,name=entries,proto3" json:"entries,omitempty"` + ReversalOfJournalId *string `protobuf:"bytes,9,opt,name=reversal_of_journal_id,json=reversalOfJournalId,proto3,oneof" json:"reversal_of_journal_id,omitempty"` + OccurredAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + RecordedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + CorrelationId string `protobuf:"bytes,12,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + ActorId string `protobuf:"bytes,13,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Blockchain *BlockchainReference `protobuf:"bytes,14,opt,name=blockchain,proto3" json:"blockchain,omitempty"` + Metadata map[string]string `protobuf:"bytes,15,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + PayloadHash string `protobuf:"bytes,16,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Journal) Reset() { + *x = Journal{} + mi := &file_ledger_v1_msg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Journal) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Journal) ProtoMessage() {} + +func (x *Journal) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Journal.ProtoReflect.Descriptor instead. +func (*Journal) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{3} +} + +func (x *Journal) GetJournalId() string { + if x != nil { + return x.JournalId + } + return "" +} + +func (x *Journal) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *Journal) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *Journal) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *Journal) GetTrackingCode() string { + if x != nil { + return x.TrackingCode + } + return "" +} + +func (x *Journal) GetEffectKind() string { + if x != nil { + return x.EffectKind + } + return "" +} + +func (x *Journal) GetEventVersion() uint32 { + if x != nil { + return x.EventVersion + } + return 0 +} + +func (x *Journal) GetEntries() []*JournalEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *Journal) GetReversalOfJournalId() string { + if x != nil && x.ReversalOfJournalId != nil { + return *x.ReversalOfJournalId + } + return "" +} + +func (x *Journal) GetOccurredAt() *timestamppb.Timestamp { + if x != nil { + return x.OccurredAt + } + return nil +} + +func (x *Journal) GetRecordedAt() *timestamppb.Timestamp { + if x != nil { + return x.RecordedAt + } + return nil +} + +func (x *Journal) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *Journal) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *Journal) GetBlockchain() *BlockchainReference { + if x != nil { + return x.Blockchain + } + return nil +} + +func (x *Journal) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Journal) GetPayloadHash() string { + if x != nil { + return x.PayloadHash + } + return "" +} + +type AppendJournalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + TrackingCode string `protobuf:"bytes,4,opt,name=tracking_code,json=trackingCode,proto3" json:"tracking_code,omitempty"` + EffectKind string `protobuf:"bytes,5,opt,name=effect_kind,json=effectKind,proto3" json:"effect_kind,omitempty"` + EventVersion uint32 `protobuf:"varint,6,opt,name=event_version,json=eventVersion,proto3" json:"event_version,omitempty"` + Entries []*JournalEntry `protobuf:"bytes,7,rep,name=entries,proto3" json:"entries,omitempty"` + ReversalOfJournalId *string `protobuf:"bytes,8,opt,name=reversal_of_journal_id,json=reversalOfJournalId,proto3,oneof" json:"reversal_of_journal_id,omitempty"` + OccurredAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + CorrelationId string `protobuf:"bytes,10,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + ActorId string `protobuf:"bytes,11,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Blockchain *BlockchainReference `protobuf:"bytes,12,opt,name=blockchain,proto3" json:"blockchain,omitempty"` + Metadata map[string]string `protobuf:"bytes,13,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendJournalRequest) Reset() { + *x = AppendJournalRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendJournalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendJournalRequest) ProtoMessage() {} + +func (x *AppendJournalRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendJournalRequest.ProtoReflect.Descriptor instead. +func (*AppendJournalRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{4} +} + +func (x *AppendJournalRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *AppendJournalRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *AppendJournalRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *AppendJournalRequest) GetTrackingCode() string { + if x != nil { + return x.TrackingCode + } + return "" +} + +func (x *AppendJournalRequest) GetEffectKind() string { + if x != nil { + return x.EffectKind + } + return "" +} + +func (x *AppendJournalRequest) GetEventVersion() uint32 { + if x != nil { + return x.EventVersion + } + return 0 +} + +func (x *AppendJournalRequest) GetEntries() []*JournalEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *AppendJournalRequest) GetReversalOfJournalId() string { + if x != nil && x.ReversalOfJournalId != nil { + return *x.ReversalOfJournalId + } + return "" +} + +func (x *AppendJournalRequest) GetOccurredAt() *timestamppb.Timestamp { + if x != nil { + return x.OccurredAt + } + return nil +} + +func (x *AppendJournalRequest) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *AppendJournalRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *AppendJournalRequest) GetBlockchain() *BlockchainReference { + if x != nil { + return x.Blockchain + } + return nil +} + +func (x *AppendJournalRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +type AppendJournalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Journal *Journal `protobuf:"bytes,1,opt,name=journal,proto3" json:"journal,omitempty"` + AlreadyExisted bool `protobuf:"varint,2,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendJournalResponse) Reset() { + *x = AppendJournalResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendJournalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendJournalResponse) ProtoMessage() {} + +func (x *AppendJournalResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendJournalResponse.ProtoReflect.Descriptor instead. +func (*AppendJournalResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{5} +} + +func (x *AppendJournalResponse) GetJournal() *Journal { + if x != nil { + return x.Journal + } + return nil +} + +func (x *AppendJournalResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type AppendTransactionEventRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + TrackingCode string `protobuf:"bytes,4,opt,name=tracking_code,json=trackingCode,proto3" json:"tracking_code,omitempty"` + EventVersion uint32 `protobuf:"varint,5,opt,name=event_version,json=eventVersion,proto3" json:"event_version,omitempty"` + State TransactionState `protobuf:"varint,6,opt,name=state,proto3,enum=ledger.v1.TransactionState" json:"state,omitempty"` + ErrorCode string `protobuf:"bytes,7,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + ErrorMessage string `protobuf:"bytes,8,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + OccurredAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=occurred_at,json=occurredAt,proto3" json:"occurred_at,omitempty"` + CorrelationId string `protobuf:"bytes,10,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + ActorId string `protobuf:"bytes,11,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Blockchain *BlockchainReference `protobuf:"bytes,12,opt,name=blockchain,proto3" json:"blockchain,omitempty"` + Metadata map[string]string `protobuf:"bytes,13,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendTransactionEventRequest) Reset() { + *x = AppendTransactionEventRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendTransactionEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendTransactionEventRequest) ProtoMessage() {} + +func (x *AppendTransactionEventRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendTransactionEventRequest.ProtoReflect.Descriptor instead. +func (*AppendTransactionEventRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{6} +} + +func (x *AppendTransactionEventRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *AppendTransactionEventRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *AppendTransactionEventRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *AppendTransactionEventRequest) GetTrackingCode() string { + if x != nil { + return x.TrackingCode + } + return "" +} + +func (x *AppendTransactionEventRequest) GetEventVersion() uint32 { + if x != nil { + return x.EventVersion + } + return 0 +} + +func (x *AppendTransactionEventRequest) GetState() TransactionState { + if x != nil { + return x.State + } + return TransactionState_TRANSACTION_STATE_UNSPECIFIED +} + +func (x *AppendTransactionEventRequest) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *AppendTransactionEventRequest) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *AppendTransactionEventRequest) GetOccurredAt() *timestamppb.Timestamp { + if x != nil { + return x.OccurredAt + } + return nil +} + +func (x *AppendTransactionEventRequest) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *AppendTransactionEventRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *AppendTransactionEventRequest) GetBlockchain() *BlockchainReference { + if x != nil { + return x.Blockchain + } + return nil +} + +func (x *AppendTransactionEventRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +type TransactionEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + Event *AppendTransactionEventRequest `protobuf:"bytes,2,opt,name=event,proto3" json:"event,omitempty"` + RecordedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=recorded_at,json=recordedAt,proto3" json:"recorded_at,omitempty"` + PayloadHash string `protobuf:"bytes,4,opt,name=payload_hash,json=payloadHash,proto3" json:"payload_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TransactionEvent) Reset() { + *x = TransactionEvent{} + mi := &file_ledger_v1_msg_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TransactionEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TransactionEvent) ProtoMessage() {} + +func (x *TransactionEvent) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TransactionEvent.ProtoReflect.Descriptor instead. +func (*TransactionEvent) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{7} +} + +func (x *TransactionEvent) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *TransactionEvent) GetEvent() *AppendTransactionEventRequest { + if x != nil { + return x.Event + } + return nil +} + +func (x *TransactionEvent) GetRecordedAt() *timestamppb.Timestamp { + if x != nil { + return x.RecordedAt + } + return nil +} + +func (x *TransactionEvent) GetPayloadHash() string { + if x != nil { + return x.PayloadHash + } + return "" +} + +type AppendTransactionEventResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *TransactionEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + AlreadyExisted bool `protobuf:"varint,2,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppendTransactionEventResponse) Reset() { + *x = AppendTransactionEventResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppendTransactionEventResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppendTransactionEventResponse) ProtoMessage() {} + +func (x *AppendTransactionEventResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppendTransactionEventResponse.ProtoReflect.Descriptor instead. +func (*AppendTransactionEventResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{8} +} + +func (x *AppendTransactionEventResponse) GetEvent() *TransactionEvent { + if x != nil { + return x.Event + } + return nil +} + +func (x *AppendTransactionEventResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type GetJournalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Lookup: + // + // *GetJournalRequest_JournalId + // *GetJournalRequest_IdempotencyKey + Lookup isGetJournalRequest_Lookup `protobuf_oneof:"lookup"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetJournalRequest) Reset() { + *x = GetJournalRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetJournalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetJournalRequest) ProtoMessage() {} + +func (x *GetJournalRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetJournalRequest.ProtoReflect.Descriptor instead. +func (*GetJournalRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{9} +} + +func (x *GetJournalRequest) GetLookup() isGetJournalRequest_Lookup { + if x != nil { + return x.Lookup + } + return nil +} + +func (x *GetJournalRequest) GetJournalId() string { + if x != nil { + if x, ok := x.Lookup.(*GetJournalRequest_JournalId); ok { + return x.JournalId + } + } + return "" +} + +func (x *GetJournalRequest) GetIdempotencyKey() string { + if x != nil { + if x, ok := x.Lookup.(*GetJournalRequest_IdempotencyKey); ok { + return x.IdempotencyKey + } + } + return "" +} + +type isGetJournalRequest_Lookup interface { + isGetJournalRequest_Lookup() +} + +type GetJournalRequest_JournalId struct { + JournalId string `protobuf:"bytes,1,opt,name=journal_id,json=journalId,proto3,oneof"` +} + +type GetJournalRequest_IdempotencyKey struct { + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3,oneof"` +} + +func (*GetJournalRequest_JournalId) isGetJournalRequest_Lookup() {} + +func (*GetJournalRequest_IdempotencyKey) isGetJournalRequest_Lookup() {} + +type ListEntriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Account *AccountReference `protobuf:"bytes,1,opt,name=account,proto3,oneof" json:"account,omitempty"` + AssetId *int64 `protobuf:"varint,2,opt,name=asset_id,json=assetId,proto3,oneof" json:"asset_id,omitempty"` + RecordedFrom *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=recorded_from,json=recordedFrom,proto3" json:"recorded_from,omitempty"` + RecordedTo *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=recorded_to,json=recordedTo,proto3" json:"recorded_to,omitempty"` + PageSize uint32 `protobuf:"varint,5,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + PageToken string `protobuf:"bytes,6,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntriesRequest) Reset() { + *x = ListEntriesRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntriesRequest) ProtoMessage() {} + +func (x *ListEntriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntriesRequest.ProtoReflect.Descriptor instead. +func (*ListEntriesRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{10} +} + +func (x *ListEntriesRequest) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *ListEntriesRequest) GetAssetId() int64 { + if x != nil && x.AssetId != nil { + return *x.AssetId + } + return 0 +} + +func (x *ListEntriesRequest) GetRecordedFrom() *timestamppb.Timestamp { + if x != nil { + return x.RecordedFrom + } + return nil +} + +func (x *ListEntriesRequest) GetRecordedTo() *timestamppb.Timestamp { + if x != nil { + return x.RecordedTo + } + return nil +} + +func (x *ListEntriesRequest) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListEntriesRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +type ListEntriesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Journals []*Journal `protobuf:"bytes,1,rep,name=journals,proto3" json:"journals,omitempty"` + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListEntriesResponse) Reset() { + *x = ListEntriesResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListEntriesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListEntriesResponse) ProtoMessage() {} + +func (x *ListEntriesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListEntriesResponse.ProtoReflect.Descriptor instead. +func (*ListEntriesResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{11} +} + +func (x *ListEntriesResponse) GetJournals() []*Journal { + if x != nil { + return x.Journals + } + return nil +} + +func (x *ListEntriesResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +type GetBalanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Account *AccountReference `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` + AsOf *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=as_of,json=asOf,proto3" json:"as_of,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBalanceRequest) Reset() { + *x = GetBalanceRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBalanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBalanceRequest) ProtoMessage() {} + +func (x *GetBalanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBalanceRequest.ProtoReflect.Descriptor instead. +func (*GetBalanceRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{12} +} + +func (x *GetBalanceRequest) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *GetBalanceRequest) GetAsOf() *timestamppb.Timestamp { + if x != nil { + return x.AsOf + } + return nil +} + +type GetBalanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Account *AccountReference `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` + // Canonical base-10 decimal reconstructed from immutable entries. + Balance string `protobuf:"bytes,2,opt,name=balance,proto3" json:"balance,omitempty"` + AsOf *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=as_of,json=asOf,proto3" json:"as_of,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBalanceResponse) Reset() { + *x = GetBalanceResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBalanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBalanceResponse) ProtoMessage() {} + +func (x *GetBalanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBalanceResponse.ProtoReflect.Descriptor instead. +func (*GetBalanceResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{13} +} + +func (x *GetBalanceResponse) GetAccount() *AccountReference { + if x != nil { + return x.Account + } + return nil +} + +func (x *GetBalanceResponse) GetBalance() string { + if x != nil { + return x.Balance + } + return "" +} + +func (x *GetBalanceResponse) GetAsOf() *timestamppb.Timestamp { + if x != nil { + return x.AsOf + } + return nil +} + +type ReplayJournalsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Each item is independently atomic and uses normal append idempotency. + Journals []*AppendJournalRequest `protobuf:"bytes,1,rep,name=journals,proto3" json:"journals,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReplayJournalsRequest) Reset() { + *x = ReplayJournalsRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReplayJournalsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReplayJournalsRequest) ProtoMessage() {} + +func (x *ReplayJournalsRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReplayJournalsRequest.ProtoReflect.Descriptor instead. +func (*ReplayJournalsRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{14} +} + +func (x *ReplayJournalsRequest) GetJournals() []*AppendJournalRequest { + if x != nil { + return x.Journals + } + return nil +} + +type ReplayJournalResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + IdempotencyKey string `protobuf:"bytes,1,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Journal *Journal `protobuf:"bytes,2,opt,name=journal,proto3" json:"journal,omitempty"` + AlreadyExisted bool `protobuf:"varint,3,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReplayJournalResult) Reset() { + *x = ReplayJournalResult{} + mi := &file_ledger_v1_msg_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReplayJournalResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReplayJournalResult) ProtoMessage() {} + +func (x *ReplayJournalResult) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReplayJournalResult.ProtoReflect.Descriptor instead. +func (*ReplayJournalResult) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{15} +} + +func (x *ReplayJournalResult) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *ReplayJournalResult) GetJournal() *Journal { + if x != nil { + return x.Journal + } + return nil +} + +func (x *ReplayJournalResult) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type ReplayJournalsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*ReplayJournalResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReplayJournalsResponse) Reset() { + *x = ReplayJournalsResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReplayJournalsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReplayJournalsResponse) ProtoMessage() {} + +func (x *ReplayJournalsResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReplayJournalsResponse.ProtoReflect.Descriptor instead. +func (*ReplayJournalsResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{16} +} + +func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult { + if x != nil { + return x.Results + } + return nil +} + +type HealthResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"` + DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthResponse) Reset() { + *x = HealthResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthResponse) ProtoMessage() {} + +func (x *HealthResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. +func (*HealthResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{17} +} + +func (x *HealthResponse) GetServing() bool { + if x != nil { + return x.Serving + } + return false +} + +func (x *HealthResponse) GetDatabaseReady() bool { + if x != nil { + return x.DatabaseReady + } + return false +} + +var File_ledger_v1_msg_proto protoreflect.FileDescriptor + +const file_ledger_v1_msg_proto_rawDesc = "" + + "\n" + + "\x13ledger/v1/msg.proto\x12\tledger.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa5\x01\n" + + "\x10AccountReference\x12<\n" + + "\raccount_class\x18\x01 \x01(\x0e2\x17.ledger.v1.AccountClassR\faccountClass\x12\x1d\n" + + "\n" + + "owner_type\x18\x02 \x01(\tR\townerType\x12\x19\n" + + "\bowner_id\x18\x03 \x01(\tR\aownerId\x12\x19\n" + + "\basset_id\x18\x04 \x01(\x03R\aassetId\"\xa0\x01\n" + + "\fJournalEntry\x12\x1f\n" + + "\vline_number\x18\x01 \x01(\rR\n" + + "lineNumber\x125\n" + + "\aaccount\x18\x02 \x01(\v2\x1b.ledger.v1.AccountReferenceR\aaccount\x12\x16\n" + + "\x06amount\x18\x03 \x01(\tR\x06amount\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\"\x83\x01\n" + + "\x13BlockchainReference\x12\x18\n" + + "\anetwork\x18\x01 \x01(\tR\anetwork\x12)\n" + + "\x10transaction_hash\x18\x02 \x01(\tR\x0ftransactionHash\x12'\n" + + "\x0fledger_sequence\x18\x03 \x01(\tR\x0eledgerSequence\"\xb9\x06\n" + + "\aJournal\x12\x1d\n" + + "\n" + + "journal_id\x18\x01 \x01(\tR\tjournalId\x12%\n" + + "\x0esource_service\x18\x02 \x01(\tR\rsourceService\x12'\n" + + "\x0fidempotency_key\x18\x03 \x01(\tR\x0eidempotencyKey\x122\n" + + "\x15source_transaction_id\x18\x04 \x01(\tR\x13sourceTransactionId\x12#\n" + + "\rtracking_code\x18\x05 \x01(\tR\ftrackingCode\x12\x1f\n" + + "\veffect_kind\x18\x06 \x01(\tR\n" + + "effectKind\x12#\n" + + "\revent_version\x18\a \x01(\rR\feventVersion\x121\n" + + "\aentries\x18\b \x03(\v2\x17.ledger.v1.JournalEntryR\aentries\x128\n" + + "\x16reversal_of_journal_id\x18\t \x01(\tH\x00R\x13reversalOfJournalId\x88\x01\x01\x12;\n" + + "\voccurred_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "occurredAt\x12;\n" + + "\vrecorded_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedAt\x12%\n" + + "\x0ecorrelation_id\x18\f \x01(\tR\rcorrelationId\x12\x19\n" + + "\bactor_id\x18\r \x01(\tR\aactorId\x12>\n" + + "\n" + + "blockchain\x18\x0e \x01(\v2\x1e.ledger.v1.BlockchainReferenceR\n" + + "blockchain\x12<\n" + + "\bmetadata\x18\x0f \x03(\v2 .ledger.v1.Journal.MetadataEntryR\bmetadata\x12!\n" + + "\fpayload_hash\x18\x10 \x01(\tR\vpayloadHash\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + + "\x17_reversal_of_journal_id\"\xd4\x05\n" + + "\x14AppendJournalRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x12'\n" + + "\x0fidempotency_key\x18\x02 \x01(\tR\x0eidempotencyKey\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12#\n" + + "\rtracking_code\x18\x04 \x01(\tR\ftrackingCode\x12\x1f\n" + + "\veffect_kind\x18\x05 \x01(\tR\n" + + "effectKind\x12#\n" + + "\revent_version\x18\x06 \x01(\rR\feventVersion\x121\n" + + "\aentries\x18\a \x03(\v2\x17.ledger.v1.JournalEntryR\aentries\x128\n" + + "\x16reversal_of_journal_id\x18\b \x01(\tH\x00R\x13reversalOfJournalId\x88\x01\x01\x12;\n" + + "\voccurred_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "occurredAt\x12%\n" + + "\x0ecorrelation_id\x18\n" + + " \x01(\tR\rcorrelationId\x12\x19\n" + + "\bactor_id\x18\v \x01(\tR\aactorId\x12>\n" + + "\n" + + "blockchain\x18\f \x01(\v2\x1e.ledger.v1.BlockchainReferenceR\n" + + "blockchain\x12I\n" + + "\bmetadata\x18\r \x03(\v2-.ledger.v1.AppendJournalRequest.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x19\n" + + "\x17_reversal_of_journal_id\"n\n" + + "\x15AppendJournalResponse\x12,\n" + + "\ajournal\x18\x01 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" + + "\x0falready_existed\x18\x02 \x01(\bR\x0ealreadyExisted\"\xb4\x05\n" + + "\x1dAppendTransactionEventRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x12'\n" + + "\x0fidempotency_key\x18\x02 \x01(\tR\x0eidempotencyKey\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12#\n" + + "\rtracking_code\x18\x04 \x01(\tR\ftrackingCode\x12#\n" + + "\revent_version\x18\x05 \x01(\rR\feventVersion\x121\n" + + "\x05state\x18\x06 \x01(\x0e2\x1b.ledger.v1.TransactionStateR\x05state\x12\x1d\n" + + "\n" + + "error_code\x18\a \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\b \x01(\tR\ferrorMessage\x12;\n" + + "\voccurred_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "occurredAt\x12%\n" + + "\x0ecorrelation_id\x18\n" + + " \x01(\tR\rcorrelationId\x12\x19\n" + + "\bactor_id\x18\v \x01(\tR\aactorId\x12>\n" + + "\n" + + "blockchain\x18\f \x01(\v2\x1e.ledger.v1.BlockchainReferenceR\n" + + "blockchain\x12R\n" + + "\bmetadata\x18\r \x03(\v26.ledger.v1.AppendTransactionEventRequest.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xcd\x01\n" + + "\x10TransactionEvent\x12\x19\n" + + "\bevent_id\x18\x01 \x01(\tR\aeventId\x12>\n" + + "\x05event\x18\x02 \x01(\v2(.ledger.v1.AppendTransactionEventRequestR\x05event\x12;\n" + + "\vrecorded_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedAt\x12!\n" + + "\fpayload_hash\x18\x04 \x01(\tR\vpayloadHash\"|\n" + + "\x1eAppendTransactionEventResponse\x121\n" + + "\x05event\x18\x01 \x01(\v2\x1b.ledger.v1.TransactionEventR\x05event\x12'\n" + + "\x0falready_existed\x18\x02 \x01(\bR\x0ealreadyExisted\"i\n" + + "\x11GetJournalRequest\x12\x1f\n" + + "\n" + + "journal_id\x18\x01 \x01(\tH\x00R\tjournalId\x12)\n" + + "\x0fidempotency_key\x18\x02 \x01(\tH\x00R\x0eidempotencyKeyB\b\n" + + "\x06lookup\"\xc3\x02\n" + + "\x12ListEntriesRequest\x12:\n" + + "\aaccount\x18\x01 \x01(\v2\x1b.ledger.v1.AccountReferenceH\x00R\aaccount\x88\x01\x01\x12\x1e\n" + + "\basset_id\x18\x02 \x01(\x03H\x01R\aassetId\x88\x01\x01\x12?\n" + + "\rrecorded_from\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\frecordedFrom\x12;\n" + + "\vrecorded_to\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "recordedTo\x12\x1b\n" + + "\tpage_size\x18\x05 \x01(\rR\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x06 \x01(\tR\tpageTokenB\n" + + "\n" + + "\b_accountB\v\n" + + "\t_asset_id\"m\n" + + "\x13ListEntriesResponse\x12.\n" + + "\bjournals\x18\x01 \x03(\v2\x12.ledger.v1.JournalR\bjournals\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"{\n" + + "\x11GetBalanceRequest\x125\n" + + "\aaccount\x18\x01 \x01(\v2\x1b.ledger.v1.AccountReferenceR\aaccount\x12/\n" + + "\x05as_of\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04asOf\"\x96\x01\n" + + "\x12GetBalanceResponse\x125\n" + + "\aaccount\x18\x01 \x01(\v2\x1b.ledger.v1.AccountReferenceR\aaccount\x12\x18\n" + + "\abalance\x18\x02 \x01(\tR\abalance\x12/\n" + + "\x05as_of\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x04asOf\"T\n" + + "\x15ReplayJournalsRequest\x12;\n" + + "\bjournals\x18\x01 \x03(\v2\x1f.ledger.v1.AppendJournalRequestR\bjournals\"\x95\x01\n" + + "\x13ReplayJournalResult\x12'\n" + + "\x0fidempotency_key\x18\x01 \x01(\tR\x0eidempotencyKey\x12,\n" + + "\ajournal\x18\x02 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" + + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" + + "\x16ReplayJournalsResponse\x128\n" + + "\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"Q\n" + + "\x0eHealthResponse\x12\x18\n" + + "\aserving\x18\x01 \x01(\bR\aserving\x12%\n" + + "\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady*\x9a\x02\n" + + "\fAccountClass\x12\x1d\n" + + "\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" + + "\x1cACCOUNT_CLASS_USER_AVAILABLE\x10\x01\x12\x1d\n" + + "\x19ACCOUNT_CLASS_USER_FROZEN\x10\x02\x12%\n" + + "!ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN\x10\x03\x12\x1a\n" + + "\x16ACCOUNT_CLASS_TREASURY\x10\x04\x12!\n" + + "\x1dACCOUNT_CLASS_MARKET_CLEARING\x10\x05\x12\x1e\n" + + "\x1aACCOUNT_CLASS_IPG_CLEARING\x10\x06\x12$\n" + + " ACCOUNT_CLASS_COMMISSION_REVENUE\x10\a*\x85\x02\n" + + "\x10TransactionState\x12!\n" + + "\x1dTRANSACTION_STATE_UNSPECIFIED\x10\x00\x12\x1d\n" + + "\x19TRANSACTION_STATE_CREATED\x10\x01\x12)\n" + + "%TRANSACTION_STATE_PENDING_TRANSACTION\x10\x02\x12#\n" + + "\x1fTRANSACTION_STATE_PENDING_ADMIN\x10\x03\x12 \n" + + "\x1cTRANSACTION_STATE_SUCCESSFUL\x10\x04\x12\x1c\n" + + "\x18TRANSACTION_STATE_FAILED\x10\x05\x12\x1f\n" + + "\x1bTRANSACTION_STATE_SUSPENDED\x10\x06By\n" + + "\rcom.ledger.v1B\bMsgProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + + "Ledger::V1b\x06proto3" + +var ( + file_ledger_v1_msg_proto_rawDescOnce sync.Once + file_ledger_v1_msg_proto_rawDescData []byte +) + +func file_ledger_v1_msg_proto_rawDescGZIP() []byte { + file_ledger_v1_msg_proto_rawDescOnce.Do(func() { + file_ledger_v1_msg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc))) + }) + return file_ledger_v1_msg_proto_rawDescData +} + +var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_ledger_v1_msg_proto_goTypes = []any{ + (AccountClass)(0), // 0: ledger.v1.AccountClass + (TransactionState)(0), // 1: ledger.v1.TransactionState + (*AccountReference)(nil), // 2: ledger.v1.AccountReference + (*JournalEntry)(nil), // 3: ledger.v1.JournalEntry + (*BlockchainReference)(nil), // 4: ledger.v1.BlockchainReference + (*Journal)(nil), // 5: ledger.v1.Journal + (*AppendJournalRequest)(nil), // 6: ledger.v1.AppendJournalRequest + (*AppendJournalResponse)(nil), // 7: ledger.v1.AppendJournalResponse + (*AppendTransactionEventRequest)(nil), // 8: ledger.v1.AppendTransactionEventRequest + (*TransactionEvent)(nil), // 9: ledger.v1.TransactionEvent + (*AppendTransactionEventResponse)(nil), // 10: ledger.v1.AppendTransactionEventResponse + (*GetJournalRequest)(nil), // 11: ledger.v1.GetJournalRequest + (*ListEntriesRequest)(nil), // 12: ledger.v1.ListEntriesRequest + (*ListEntriesResponse)(nil), // 13: ledger.v1.ListEntriesResponse + (*GetBalanceRequest)(nil), // 14: ledger.v1.GetBalanceRequest + (*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse + (*ReplayJournalsRequest)(nil), // 16: ledger.v1.ReplayJournalsRequest + (*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult + (*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse + (*HealthResponse)(nil), // 19: ledger.v1.HealthResponse + nil, // 20: ledger.v1.Journal.MetadataEntry + nil, // 21: ledger.v1.AppendJournalRequest.MetadataEntry + nil, // 22: ledger.v1.AppendTransactionEventRequest.MetadataEntry + (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp +} +var file_ledger_v1_msg_proto_depIdxs = []int32{ + 0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass + 2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference + 3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry + 23, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp + 23, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp + 4, // 5: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference + 20, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry + 3, // 7: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry + 23, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp + 4, // 9: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference + 21, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry + 5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal + 1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState + 23, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp + 4, // 14: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference + 22, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry + 8, // 16: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest + 23, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp + 9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent + 2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference + 23, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp + 23, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp + 5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal + 2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference + 23, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp + 2, // 25: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference + 23, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp + 6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest + 5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal + 17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult + 30, // [30:30] is the sub-list for method output_type + 30, // [30:30] is the sub-list for method input_type + 30, // [30:30] is the sub-list for extension type_name + 30, // [30:30] is the sub-list for extension extendee + 0, // [0:30] is the sub-list for field type_name +} + +func init() { file_ledger_v1_msg_proto_init() } +func file_ledger_v1_msg_proto_init() { + if File_ledger_v1_msg_proto != nil { + return + } + file_ledger_v1_msg_proto_msgTypes[3].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[4].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[9].OneofWrappers = []any{ + (*GetJournalRequest_JournalId)(nil), + (*GetJournalRequest_IdempotencyKey)(nil), + } + file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)), + NumEnums: 2, + NumMessages: 21, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_ledger_v1_msg_proto_goTypes, + DependencyIndexes: file_ledger_v1_msg_proto_depIdxs, + EnumInfos: file_ledger_v1_msg_proto_enumTypes, + MessageInfos: file_ledger_v1_msg_proto_msgTypes, + }.Build() + File_ledger_v1_msg_proto = out.File + file_ledger_v1_msg_proto_goTypes = nil + file_ledger_v1_msg_proto_depIdxs = nil +} diff --git a/gen/ledger/v1/srv.pb.go b/gen/ledger/v1/srv.pb.go new file mode 100644 index 0000000..3f90d43 --- /dev/null +++ b/gen/ledger/v1/srv.pb.go @@ -0,0 +1,102 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: ledger/v1/srv.proto + +package ledgerv1 + +import ( + v1 "gl/gen/base/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +var File_ledger_v1_srv_proto protoreflect.FileDescriptor + +const file_ledger_v1_srv_proto_rawDesc = "" + + "\n" + + "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xbe\x04\n" + + "\x14GeneralLedgerService\x123\n" + + "\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" + + "\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" + + "\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" + + "\n" + + "GetJournal\x12\x1c.ledger.v1.GetJournalRequest\x1a\x12.ledger.v1.Journal\x12L\n" + + "\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" + + "\n" + + "GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" + + "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponseBy\n" + + "\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + + "Ledger::V1b\x06proto3" + +var file_ledger_v1_srv_proto_goTypes = []any{ + (*v1.Empty)(nil), // 0: base.v1.Empty + (*AppendJournalRequest)(nil), // 1: ledger.v1.AppendJournalRequest + (*AppendTransactionEventRequest)(nil), // 2: ledger.v1.AppendTransactionEventRequest + (*GetJournalRequest)(nil), // 3: ledger.v1.GetJournalRequest + (*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest + (*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest + (*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest + (*HealthResponse)(nil), // 7: ledger.v1.HealthResponse + (*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse + (*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse + (*Journal)(nil), // 10: ledger.v1.Journal + (*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse + (*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse + (*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse +} +var file_ledger_v1_srv_proto_depIdxs = []int32{ + 0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty + 1, // 1: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest + 2, // 2: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest + 3, // 3: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest + 4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest + 5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest + 6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest + 7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse + 8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse + 9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse + 10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal + 11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse + 12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse + 13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse + 7, // [7:14] is the sub-list for method output_type + 0, // [0:7] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_ledger_v1_srv_proto_init() } +func file_ledger_v1_srv_proto_init() { + if File_ledger_v1_srv_proto != nil { + return + } + file_ledger_v1_msg_proto_init() + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_srv_proto_rawDesc), len(file_ledger_v1_srv_proto_rawDesc)), + NumEnums: 0, + NumMessages: 0, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ledger_v1_srv_proto_goTypes, + DependencyIndexes: file_ledger_v1_srv_proto_depIdxs, + }.Build() + File_ledger_v1_srv_proto = out.File + file_ledger_v1_srv_proto_goTypes = nil + file_ledger_v1_srv_proto_depIdxs = nil +} diff --git a/gen/ledger/v1/srv_grpc.pb.go b/gen/ledger/v1/srv_grpc.pb.go new file mode 100644 index 0000000..e16298a --- /dev/null +++ b/gen/ledger/v1/srv_grpc.pb.go @@ -0,0 +1,348 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: ledger/v1/srv.proto + +package ledgerv1 + +import ( + context "context" + v1 "gl/gen/base/v1" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health" + GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal" + GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent" + GeneralLedgerService_GetJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/GetJournal" + GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries" + GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance" + GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals" +) + +// GeneralLedgerServiceClient is the client API for GeneralLedgerService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type GeneralLedgerServiceClient interface { + Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) + AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) + AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error) + GetJournal(ctx context.Context, in *GetJournalRequest, opts ...grpc.CallOption) (*Journal, error) + ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) + GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) + ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) +} + +type generalLedgerServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerServiceClient { + return &generalLedgerServiceClient{cc} +} + +func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(HealthResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_Health_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AppendJournalResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_AppendJournal_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AppendTransactionEventResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_AppendTransactionEvent_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetJournal(ctx context.Context, in *GetJournalRequest, opts ...grpc.CallOption) (*Journal, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Journal) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetJournal_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListEntriesResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_ListEntries_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetBalanceResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetBalance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReplayJournalsResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_ReplayJournals_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// GeneralLedgerServiceServer is the server API for GeneralLedgerService service. +// All implementations should embed UnimplementedGeneralLedgerServiceServer +// for forward compatibility. +type GeneralLedgerServiceServer interface { + Health(context.Context, *v1.Empty) (*HealthResponse, error) + AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) + AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) + GetJournal(context.Context, *GetJournalRequest) (*Journal, error) + ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) + GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) + ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) +} + +// UnimplementedGeneralLedgerServiceServer should be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedGeneralLedgerServiceServer struct{} + +func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Health not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AppendJournal not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) { + return nil, status.Error(codes.Unimplemented, "method AppendTransactionEvent not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) { + return nil, status.Error(codes.Unimplemented, "method GetJournal not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListEntries not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetBalance not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReplayJournals not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {} + +// UnsafeGeneralLedgerServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to GeneralLedgerServiceServer will +// result in compilation errors. +type UnsafeGeneralLedgerServiceServer interface { + mustEmbedUnimplementedGeneralLedgerServiceServer() +} + +func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) { + // If the following call panics, it indicates UnimplementedGeneralLedgerServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&GeneralLedgerService_ServiceDesc, srv) +} + +func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(v1.Empty) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).Health(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_Health_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).Health(ctx, req.(*v1.Empty)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_AppendJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AppendJournalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).AppendJournal(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_AppendJournal_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).AppendJournal(ctx, req.(*AppendJournalRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_AppendTransactionEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AppendTransactionEventRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).AppendTransactionEvent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_AppendTransactionEvent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).AppendTransactionEvent(ctx, req.(*AppendTransactionEventRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetJournalRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetJournal(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetJournal_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetJournal(ctx, req.(*GetJournalRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListEntriesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).ListEntries(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_ListEntries_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).ListEntries(ctx, req.(*ListEntriesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetBalance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetBalanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetBalance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetBalance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetBalance(ctx, req.(*GetBalanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReplayJournalsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).ReplayJournals(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_ReplayJournals_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).ReplayJournals(ctx, req.(*ReplayJournalsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ledger.v1.GeneralLedgerService", + HandlerType: (*GeneralLedgerServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Health", + Handler: _GeneralLedgerService_Health_Handler, + }, + { + MethodName: "AppendJournal", + Handler: _GeneralLedgerService_AppendJournal_Handler, + }, + { + MethodName: "AppendTransactionEvent", + Handler: _GeneralLedgerService_AppendTransactionEvent_Handler, + }, + { + MethodName: "GetJournal", + Handler: _GeneralLedgerService_GetJournal_Handler, + }, + { + MethodName: "ListEntries", + Handler: _GeneralLedgerService_ListEntries_Handler, + }, + { + MethodName: "GetBalance", + Handler: _GeneralLedgerService_GetBalance_Handler, + }, + { + MethodName: "ReplayJournals", + Handler: _GeneralLedgerService_ReplayJournals_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ledger/v1/srv.proto", +} diff --git a/gl.cfg.toml b/gl.cfg.toml new file mode 100644 index 0000000..c6d5d29 --- /dev/null +++ b/gl.cfg.toml @@ -0,0 +1,14 @@ +environment = "local" + +[grpc] +host = "0.0.0.0" +port = 8500 +shutdown-timeout = "10s" + +[database] +host = "127.0.0.1" +port = 5432 +name = "gl_db" +user = "postgres" +password = "" +ssl-mode = "disable" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..764ea76 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module gl + +go 1.24 + +require ( + github.com/knadh/koanf/parsers/toml v0.1.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.4 + google.golang.org/grpc v1.67.1 + google.golang.org/protobuf v1.36.6 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.17.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ea5e05 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +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/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= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= +github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +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/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/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= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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= +google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go new file mode 100644 index 0000000..ab96ffd --- /dev/null +++ b/infrastructure/config/config.go @@ -0,0 +1,81 @@ +// Package config owns GL configuration loading and validation. +package config + +import ( + "fmt" + "time" + + "github.com/knadh/koanf/parsers/toml" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +type Config struct { + Environment string `koanf:"environment"` + GRPC GRPCConfig `koanf:"grpc"` + Database DatabaseConfig `koanf:"database"` +} + +type GRPCConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` +} + +type DatabaseConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + Name string `koanf:"name"` + User string `koanf:"user"` + Password string `koanf:"password"` + SSLMode string `koanf:"ssl-mode"` +} + +func Load(path string) (*Config, error) { + cfg := &Config{ + Environment: "local", + GRPC: GRPCConfig{ + Host: "0.0.0.0", + Port: 8500, + ShutdownTimeout: 10 * time.Second, + }, + Database: DatabaseConfig{ + Host: "127.0.0.1", + Port: 5432, + Name: "gl_db", + User: "postgres", + SSLMode: "disable", + }, + } + + k := koanf.New(".") + if err := k.Load(file.Provider(path), toml.Parser()); err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + if err := k.Unmarshal("", cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) Validate() error { + if c.GRPC.Host == "" { + return fmt.Errorf("grpc host is required") + } + if c.GRPC.Port < 0 || c.GRPC.Port > 65535 { + return fmt.Errorf("grpc port must be between 0 and 65535") + } + if c.GRPC.ShutdownTimeout <= 0 { + return fmt.Errorf("grpc shutdown timeout must be positive") + } + if c.Database.Host == "" || c.Database.Name == "" || c.Database.User == "" { + return fmt.Errorf("database host, name, and user are required") + } + if c.Database.Port < 1 || c.Database.Port > 65535 { + return fmt.Errorf("database port must be between 1 and 65535") + } + return nil +} diff --git a/infrastructure/config/config_test.go b/infrastructure/config/config_test.go new file mode 100644 index 0000000..4d8b1e0 --- /dev/null +++ b/infrastructure/config/config_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadUsesDefaultsAndOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + contents := []byte("[grpc]\nport = 0\nshutdown-timeout = \"3s\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.GRPC.Port != 0 || cfg.GRPC.ShutdownTimeout != 3*time.Second { + t.Fatalf("unexpected grpc config: %+v", cfg.GRPC) + } + if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 { + t.Fatalf("unexpected database defaults: %+v", cfg.Database) + } +} + +func TestLoadRejectsInvalidConfiguration(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + if err := os.WriteFile(path, []byte("[grpc]\nshutdown-timeout = \"0s\"\n"), 0o600); err != nil { + t.Fatal(err) + } + + if _, err := Load(path); err == nil { + t.Fatal("expected validation error") + } +} + +func TestLoadReturnsMissingFileError(t *testing.T) { + if _, err := Load(filepath.Join(t.TempDir(), "missing.toml")); err == nil { + t.Fatal("expected missing file error") + } +} diff --git a/interface/grpc/health.go b/interface/grpc/health.go new file mode 100644 index 0000000..f49e534 --- /dev/null +++ b/interface/grpc/health.go @@ -0,0 +1,26 @@ +package grpcadapter + +import ( + "context" + + "gl/application/health" + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" +) + +type HealthHandler struct { + ledgerv1.UnimplementedGeneralLedgerServiceServer + service *health.Service +} + +func NewHealthHandler(service *health.Service) *HealthHandler { + return &HealthHandler{service: service} +} + +func (h *HealthHandler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) { + result := h.service.Check(ctx) + return &ledgerv1.HealthResponse{ + Serving: result.Serving, + DatabaseReady: result.DatabaseReady, + }, nil +} diff --git a/interface/grpc/health_test.go b/interface/grpc/health_test.go new file mode 100644 index 0000000..2707180 --- /dev/null +++ b/interface/grpc/health_test.go @@ -0,0 +1,19 @@ +package grpcadapter + +import ( + "context" + "testing" + + "gl/application/health" + basev1 "gl/gen/base/v1" +) + +func TestHealth(t *testing.T) { + response, err := NewHealthHandler(health.NewService(nil)).Health(context.Background(), &basev1.Empty{}) + if err != nil { + t.Fatal(err) + } + if !response.Serving || response.DatabaseReady { + t.Fatalf("unexpected response: %+v", response) + } +} diff --git a/interface/grpc/server.go b/interface/grpc/server.go new file mode 100644 index 0000000..bf45ca6 --- /dev/null +++ b/interface/grpc/server.go @@ -0,0 +1,69 @@ +package grpcadapter + +import ( + "context" + "errors" + "fmt" + "net" + "time" + + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +type ServerConfig struct { + Host string + Port int + ShutdownTimeout time.Duration +} + +func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerServiceServer) error { + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)) + if err != nil { + return fmt.Errorf("listen for grpc: %w", err) + } + return runWithListener(ctx, cfg.ShutdownTimeout, listener, handler) +} + +func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error { + defer listener.Close() + + server := grpc.NewServer() + ledgerv1.RegisterGeneralLedgerServiceServer(server, handler) + reflection.Register(server) + + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + + select { + case err := <-serveErr: + if errors.Is(err, grpc.ErrServerStopped) { + return nil + } + return fmt.Errorf("serve grpc: %w", err) + case <-ctx.Done(): + } + + stopped := make(chan struct{}) + go func() { + server.GracefulStop() + close(stopped) + }() + + timer := time.NewTimer(shutdownTimeout) + defer timer.Stop() + select { + case <-stopped: + case <-timer.C: + server.Stop() + <-stopped + } + + err := <-serveErr + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return fmt.Errorf("serve grpc: %w", err) + } + return nil +} diff --git a/interface/grpc/server_test.go b/interface/grpc/server_test.go new file mode 100644 index 0000000..fff9bc7 --- /dev/null +++ b/interface/grpc/server_test.go @@ -0,0 +1,26 @@ +package grpcadapter + +import ( + "context" + "testing" + "time" + + "gl/application/health" + + "google.golang.org/grpc/test/bufconn" +) + +func TestRunStopsWhenContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := runWithListener( + ctx, + time.Second, + bufconn.Listen(1024), + NewHealthHandler(health.NewService(nil)), + ) + if err != nil { + t.Fatal(err) + } +} From 0b3eaa0c3d4ff42a22cc07953aa6bf6b605e96ec8730bd71a034e889ed4b6d44 Mon Sep 17 00:00:00 2001 From: nfel Date: Fri, 14 Aug 2026 19:00:45 +0330 Subject: [PATCH 03/20] feat(gl): add immutable postgres ledger storage --- cmd/gl/main.go | 14 +- domain/ledger/amount.go | 115 ++++++++++ domain/ledger/amount_test.go | 38 ++++ domain/ledger/journal.go | 137 +++++++++++ domain/ledger/journal_test.go | 50 ++++ go.mod | 6 + go.sum | 18 ++ infrastructure/postgres/database.go | 83 +++++++ infrastructure/postgres/journal_repository.go | 179 +++++++++++++++ .../postgres/journal_repository_test.go | 213 ++++++++++++++++++ infrastructure/postgres/migrate.go | 75 ++++++ infrastructure/postgres/migration_test.go | 26 +++ .../postgres/migrations/000001_init.down.sql | 14 ++ .../postgres/migrations/000001_init.up.sql | 172 ++++++++++++++ 14 files changed, 1139 insertions(+), 1 deletion(-) create mode 100644 domain/ledger/amount.go create mode 100644 domain/ledger/amount_test.go create mode 100644 domain/ledger/journal.go create mode 100644 domain/ledger/journal_test.go create mode 100644 infrastructure/postgres/database.go create mode 100644 infrastructure/postgres/journal_repository.go create mode 100644 infrastructure/postgres/journal_repository_test.go create mode 100644 infrastructure/postgres/migrate.go create mode 100644 infrastructure/postgres/migration_test.go create mode 100644 infrastructure/postgres/migrations/000001_init.down.sql create mode 100644 infrastructure/postgres/migrations/000001_init.up.sql diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 7e13b62..d883fd6 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -10,6 +10,7 @@ import ( "gl/application/health" "gl/infrastructure/config" + "gl/infrastructure/postgres" grpcadapter "gl/interface/grpc" ) @@ -26,7 +27,18 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - handler := grpcadapter.NewHealthHandler(health.NewService(nil)) + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + slog.Error("open GL database", "error", err) + os.Exit(1) + } + defer database.Close() + if err := postgres.Migrate(ctx, database); err != nil { + slog.Error("migrate GL database", "error", err) + os.Exit(1) + } + + handler := grpcadapter.NewHealthHandler(health.NewService(database)) slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, diff --git a/domain/ledger/amount.go b/domain/ledger/amount.go new file mode 100644 index 0000000..c7bb464 --- /dev/null +++ b/domain/ledger/amount.go @@ -0,0 +1,115 @@ +// Package ledger contains GL's framework-independent financial model. +package ledger + +import ( + "fmt" + "math/big" + "strings" + "unicode" +) + +const ( + AmountPrecision = 38 + AmountScale = 18 +) + +var amountFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(AmountScale), nil) + +// Amount is an exact fixed-point decimal with PostgreSQL numeric(38,18) +// semantics. Its zero value is a valid zero amount. +type Amount struct { + units big.Int +} + +func ParseAmount(value string) (Amount, error) { + if value == "" { + return Amount{}, fmt.Errorf("amount is required") + } + + negative := value[0] == '-' + unsigned := value + if negative { + unsigned = value[1:] + } + if unsigned == "" || strings.HasPrefix(unsigned, "+") { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + + parts := strings.Split(unsigned, ".") + if len(parts) > 2 || parts[0] == "" { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + for _, part := range parts { + if part == "" || strings.IndexFunc(part, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { + return Amount{}, fmt.Errorf("invalid amount %q", value) + } + } + if len(parts[0]) > 1 && parts[0][0] == '0' { + return Amount{}, fmt.Errorf("amount must not contain leading zeroes") + } + if len(strings.TrimLeft(parts[0], "0")) > AmountPrecision-AmountScale { + return Amount{}, fmt.Errorf("amount exceeds integer precision %d", AmountPrecision-AmountScale) + } + + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + if len(fraction) > AmountScale { + return Amount{}, fmt.Errorf("amount exceeds scale %d", AmountScale) + } + } + digits := strings.TrimLeft(parts[0]+fraction, "0") + if len(digits) > AmountPrecision { + return Amount{}, fmt.Errorf("amount exceeds precision %d", AmountPrecision) + } + + whole := new(big.Int) + whole.SetString(parts[0], 10) + units := new(big.Int).Mul(whole, amountFactor) + if fraction != "" { + padded := fraction + strings.Repeat("0", AmountScale-len(fraction)) + fractional := new(big.Int) + fractional.SetString(padded, 10) + units.Add(units, fractional) + } + if negative { + units.Neg(units) + } + + return Amount{units: *units}, nil +} + +func (a Amount) IsZero() bool { + return a.units.Sign() == 0 +} + +func (a Amount) Add(other Amount) Amount { + var result big.Int + result.Add(&a.units, &other.units) + return Amount{units: result} +} + +func (a Amount) Negate() Amount { + var result big.Int + result.Neg(&a.units) + return Amount{units: result} +} + +func (a Amount) String() string { + if a.units.Sign() == 0 { + return "0" + } + + abs := new(big.Int).Abs(new(big.Int).Set(&a.units)) + whole, fraction := new(big.Int), new(big.Int) + whole.QuoRem(abs, amountFactor, fraction) + value := whole.String() + if fraction.Sign() != 0 { + fractionText := fmt.Sprintf("%018s", fraction.String()) + value += "." + strings.TrimRight(fractionText, "0") + } + if a.units.Sign() < 0 { + return "-" + value + } + return value +} diff --git a/domain/ledger/amount_test.go b/domain/ledger/amount_test.go new file mode 100644 index 0000000..08f56a5 --- /dev/null +++ b/domain/ledger/amount_test.go @@ -0,0 +1,38 @@ +package ledger + +import "testing" + +func TestParseAmountCanonicalizesExactValues(t *testing.T) { + for input, expected := range map[string]string{ + "0": "0", + "1": "1", + "1.2300": "1.23", + "-0.000000000000000001": "-0.000000000000000001", + "99999999999999999999": "99999999999999999999", + } { + amount, err := ParseAmount(input) + if err != nil { + t.Fatalf("ParseAmount(%q): %v", input, err) + } + if got := amount.String(); got != expected { + t.Fatalf("ParseAmount(%q) = %q, want %q", input, got, expected) + } + } +} + +func TestParseAmountRejectsInvalidValues(t *testing.T) { + for _, input := range []string{"", "+1", "01", ".1", "1.", "1e2", "1.0000000000000000001", "123456789012345678901234567890123456789"} { + if _, err := ParseAmount(input); err == nil { + t.Fatalf("ParseAmount(%q) succeeded", input) + } + } +} + +func TestAmountAdditionIsExact(t *testing.T) { + one, _ := ParseAmount("0.1") + two, _ := ParseAmount("0.2") + minusThree, _ := ParseAmount("-0.3") + if got := one.Add(two).Add(minusThree); !got.IsZero() { + t.Fatalf("expected exact zero, got %s", got.String()) + } +} diff --git a/domain/ledger/journal.go b/domain/ledger/journal.go new file mode 100644 index 0000000..47c82b3 --- /dev/null +++ b/domain/ledger/journal.go @@ -0,0 +1,137 @@ +package ledger + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +type AccountClass string + +const ( + AccountClassUserAvailable AccountClass = "USER_AVAILABLE" + AccountClassUserFrozen AccountClass = "USER_FROZEN" + AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN" + AccountClassTreasury AccountClass = "TREASURY" + AccountClassMarketClearing AccountClass = "MARKET_CLEARING" + AccountClassIPGClearing AccountClass = "IPG_CLEARING" + AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE" +) + +func (c AccountClass) Valid() bool { + switch c { + case AccountClassUserAvailable, + AccountClassUserFrozen, + AccountClassExternalBlockchain, + AccountClassTreasury, + AccountClassMarketClearing, + AccountClassIPGClearing, + AccountClassCommissionRevenue: + return true + default: + return false + } +} + +type AccountReference struct { + Class AccountClass + OwnerType string + OwnerID string + AssetID int64 +} + +func (a AccountReference) Validate() error { + if !a.Class.Valid() { + return fmt.Errorf("invalid account class %q", a.Class) + } + if a.AssetID <= 0 { + return fmt.Errorf("asset id must be positive") + } + if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen { + if a.OwnerType == "" || a.OwnerID == "" { + return fmt.Errorf("user account requires owner type and id") + } + } + return nil +} + +type Entry struct { + LineNumber uint32 + Account AccountReference + Amount Amount + Description string +} + +type BlockchainReference struct { + Network string + TransactionHash string + LedgerSequence string +} + +type Journal struct { + ID string + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EffectKind string + EventVersion uint32 + Entries []Entry + ReversalOfJournalID string + OccurredAt time.Time + CorrelationID string + ActorID string + Blockchain BlockchainReference + Metadata map[string]string + PayloadHash string +} + +func (j Journal) Validate() error { + if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" { + return fmt.Errorf("journal identity fields are required") + } + if j.EventVersion == 0 { + return fmt.Errorf("event version must be positive") + } + if j.OccurredAt.IsZero() { + return fmt.Errorf("occurred time is required") + } + if len(j.PayloadHash) != 64 { + return fmt.Errorf("payload hash must be a SHA-256 hex string") + } + if _, err := hex.DecodeString(j.PayloadHash); err != nil { + return fmt.Errorf("payload hash must be a SHA-256 hex string") + } + if j.PayloadHash != strings.ToLower(j.PayloadHash) { + return fmt.Errorf("payload hash must use lowercase hexadecimal") + } + if len(j.Entries) < 2 { + return fmt.Errorf("journal requires at least two entries") + } + + lines := make(map[uint32]struct{}, len(j.Entries)) + balances := make(map[int64]Amount) + for _, entry := range j.Entries { + if entry.LineNumber == 0 { + return fmt.Errorf("entry line number must be positive") + } + if _, exists := lines[entry.LineNumber]; exists { + return fmt.Errorf("duplicate entry line number %d", entry.LineNumber) + } + lines[entry.LineNumber] = struct{}{} + if err := entry.Account.Validate(); err != nil { + return fmt.Errorf("entry %d: %w", entry.LineNumber, err) + } + if entry.Amount.IsZero() { + return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber) + } + balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount) + } + for assetID, balance := range balances { + if !balance.IsZero() { + return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String()) + } + } + return nil +} diff --git a/domain/ledger/journal_test.go b/domain/ledger/journal_test.go new file mode 100644 index 0000000..68d6054 --- /dev/null +++ b/domain/ledger/journal_test.go @@ -0,0 +1,50 @@ +package ledger + +import ( + "strings" + "testing" + "time" +) + +func TestJournalValidationBalancesEachAsset(t *testing.T) { + journal := validJournal(t) + if err := journal.Validate(); err != nil { + t.Fatal(err) + } + + journal.Entries[1].Amount, _ = ParseAmount("9") + if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "unbalanced") { + t.Fatalf("expected unbalanced error, got %v", err) + } +} + +func TestJournalValidationRejectsDuplicateLines(t *testing.T) { + journal := validJournal(t) + journal.Entries[1].LineNumber = journal.Entries[0].LineNumber + if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("expected duplicate line error, got %v", err) + } +} + +func validJournal(t *testing.T) Journal { + t.Helper() + debit, err := ParseAmount("-10") + if err != nil { + t.Fatal(err) + } + credit := debit.Negate() + return Journal{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + Entries: []Entry{ + {LineNumber: 1, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5}, Amount: debit}, + {LineNumber: 2, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5}, Amount: credit}, + }, + } +} diff --git a/go.mod b/go.mod index 764ea76..05f8c38 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gl go 1.24 require ( + github.com/jackc/pgx/v5 v5.4.3 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.4 @@ -13,11 +14,16 @@ require ( require ( 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/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect + golang.org/x/crypto v0.26.0 // indirect golang.org/x/net v0.28.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect diff --git a/go.sum b/go.sum index 5ea5e05..3dce6d6 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -6,6 +7,14 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= @@ -22,10 +31,17 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v 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= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= @@ -36,5 +52,7 @@ google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/infrastructure/postgres/database.go b/infrastructure/postgres/database.go new file mode 100644 index 0000000..a9f8272 --- /dev/null +++ b/infrastructure/postgres/database.go @@ -0,0 +1,83 @@ +// Package postgres provides GL's PostgreSQL adapters. +package postgres + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "gl/infrastructure/config" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Row interface { + Scan(dest ...any) error +} + +type Tx interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + QueryRow(context.Context, string, ...any) Row + Commit(context.Context) error + Rollback(context.Context) error +} + +type Database interface { + Begin(context.Context) (Tx, error) + QueryRow(context.Context, string, ...any) Row + Ping(context.Context) error + Close() +} + +type Pool struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, cfg config.DatabaseConfig) (*Pool, error) { + connectionURL := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(cfg.User, cfg.Password), + Host: cfg.Host + ":" + strconv.Itoa(cfg.Port), + Path: cfg.Name, + } + query := connectionURL.Query() + query.Set("sslmode", cfg.SSLMode) + connectionURL.RawQuery = query.Encode() + + pool, err := pgxpool.New(ctx, connectionURL.String()) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + return &Pool{pool: pool}, nil +} + +func (p *Pool) Begin(ctx context.Context) (Tx, error) { + tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, err + } + return txAdapter{Tx: tx}, nil +} + +func (p *Pool) Ping(ctx context.Context) error { + return p.pool.Ping(ctx) +} + +func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row { + return p.pool.QueryRow(ctx, sql, args...) +} + +func (p *Pool) Close() { + p.pool.Close() +} + +type txAdapter struct { + pgx.Tx +} + +func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row { + return t.Tx.QueryRow(ctx, sql, args...) +} diff --git a/infrastructure/postgres/journal_repository.go b/infrastructure/postgres/journal_repository.go new file mode 100644 index 0000000..4bc5126 --- /dev/null +++ b/infrastructure/postgres/journal_repository.go @@ -0,0 +1,179 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +var ( + ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload") + ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal") +) + +type AppendResult struct { + JournalID string + AlreadyExists bool +} + +type JournalRepository struct { + database Database +} + +func NewJournalRepository(database Database) *JournalRepository { + return &JournalRepository{database: database} +} + +func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) (result AppendResult, err error) { + if err := journal.Validate(); err != nil { + return AppendResult{}, fmt.Errorf("validate journal: %w", err) + } + metadataValues := journal.Metadata + if metadataValues == nil { + metadataValues = map[string]string{} + } + metadata, err := json.Marshal(metadataValues) + if err != nil { + return AppendResult{}, fmt.Errorf("encode metadata: %w", err) + } + + tx, err := r.database.Begin(ctx) + if err != nil { + return AppendResult{}, fmt.Errorf("begin journal append: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback(ctx) + } + }() + + var insertedID string + err = tx.QueryRow(ctx, insertJournalSQL, + journal.ID, + journal.SourceService, + journal.IdempotencyKey, + journal.SourceTransactionID, + journal.TrackingCode, + journal.EffectKind, + journal.EventVersion, + nullIfEmpty(journal.ReversalOfJournalID), + journal.OccurredAt, + journal.CorrelationID, + journal.ActorID, + journal.Blockchain.Network, + journal.Blockchain.TransactionHash, + journal.Blockchain.LedgerSequence, + metadata, + journal.PayloadHash, + ).Scan(&insertedID) + if errors.Is(err, pgx.ErrNoRows) { + _ = tx.Rollback(ctx) + return r.resolveDuplicate(ctx, journal) + } + if err != nil { + return AppendResult{}, fmt.Errorf("insert journal: %w", err) + } + + for _, entry := range journal.Entries { + var accountID int64 + err = tx.QueryRow(ctx, insertAccountSQL, + entry.Account.Class, + entry.Account.OwnerType, + entry.Account.OwnerID, + entry.Account.AssetID, + ).Scan(&accountID) + if errors.Is(err, pgx.ErrNoRows) { + err = tx.QueryRow(ctx, selectAccountSQL, + entry.Account.Class, + entry.Account.OwnerType, + entry.Account.OwnerID, + entry.Account.AssetID, + ).Scan(&accountID) + } + if err != nil { + return AppendResult{}, fmt.Errorf("resolve entry %d account: %w", entry.LineNumber, err) + } + + if _, err = tx.Exec(ctx, insertEntrySQL, + journal.ID, + entry.LineNumber, + accountID, + entry.Account.AssetID, + entry.Amount.String(), + entry.Description, + ); err != nil { + return AppendResult{}, fmt.Errorf("insert entry %d: %w", entry.LineNumber, err) + } + } + + if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil { + return AppendResult{}, fmt.Errorf("seal journal: %w", err) + } + if err = tx.Commit(ctx); err != nil { + return AppendResult{}, fmt.Errorf("commit journal: %w", err) + } + return AppendResult{JournalID: insertedID}, nil +} + +func (r *JournalRepository) resolveDuplicate(ctx context.Context, journal ledger.Journal) (AppendResult, error) { + var ( + journalID string + payloadHash string + sealed bool + ) + err := r.database.QueryRow(ctx, selectIdempotencySQL, journal.IdempotencyKey).Scan(&journalID, &payloadHash, &sealed) + if err != nil { + return AppendResult{}, fmt.Errorf("read idempotent journal: %w", err) + } + if payloadHash != journal.PayloadHash { + return AppendResult{}, ErrIdempotencyConflict + } + if !sealed { + return AppendResult{}, ErrIncompleteJournal + } + return AppendResult{JournalID: journalID, AlreadyExists: true}, nil +} + +func nullIfEmpty(value string) any { + if value == "" { + return nil + } + return value +} + +const insertJournalSQL = ` +INSERT INTO journals ( + id, source_service, idempotency_key, source_transaction_id, tracking_code, + effect_kind, event_version, reversal_of_journal_id, occurred_at, + correlation_id, actor_id, blockchain_network, blockchain_transaction_hash, + blockchain_ledger_sequence, metadata, payload_hash +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 +) +ON CONFLICT (idempotency_key) DO NOTHING +RETURNING id` + +const insertAccountSQL = ` +INSERT INTO ledger_accounts (class, owner_type, owner_id, asset_id) +VALUES ($1, $2, $3, $4) +ON CONFLICT (class, owner_type, owner_id, asset_id) DO NOTHING +RETURNING id` + +const selectAccountSQL = ` +SELECT id FROM ledger_accounts +WHERE class = $1 AND owner_type = $2 AND owner_id = $3 AND asset_id = $4` + +const insertEntrySQL = ` +INSERT INTO journal_entries ( + journal_id, line_number, account_id, asset_id, amount, description +) VALUES ($1, $2, $3, $4, $5, $6)` + +const selectIdempotencySQL = ` +SELECT id, payload_hash, sealed_at IS NOT NULL +FROM journals +WHERE idempotency_key = $1` diff --git a/infrastructure/postgres/journal_repository_test.go b/infrastructure/postgres/journal_repository_test.go new file mode 100644 index 0000000..12bf4d3 --- /dev/null +++ b/infrastructure/postgres/journal_repository_test.go @@ -0,0 +1,213 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type fakeRow struct { + values []any + err error +} + +func (r fakeRow) Scan(dest ...any) error { + if r.err != nil { + return r.err + } + if len(dest) != len(r.values) { + return errors.New("unexpected scan width") + } + for index, value := range r.values { + switch target := dest[index].(type) { + case *string: + *target = value.(string) + case *int64: + *target = value.(int64) + case *bool: + *target = value.(bool) + default: + return errors.New("unsupported scan target") + } + } + return nil +} + +type fakeTx struct { + rows []Row + execCount int + execErrAt int + committed bool + rolledBack bool +} + +func (t *fakeTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + t.execCount++ + if t.execCount == t.execErrAt { + return pgconn.CommandTag{}, errors.New("exec failed") + } + return pgconn.NewCommandTag("INSERT 0 1"), nil +} + +func (t *fakeTx) QueryRow(context.Context, string, ...any) Row { + row := t.rows[0] + t.rows = t.rows[1:] + return row +} + +func (t *fakeTx) Commit(context.Context) error { + t.committed = true + return nil +} + +func (t *fakeTx) Rollback(context.Context) error { + t.rolledBack = true + return nil +} + +type fakeDatabase struct { + tx *fakeTx + directRow Row + beginCalled bool +} + +func (d *fakeDatabase) Begin(context.Context) (Tx, error) { + d.beginCalled = true + return d.tx, nil +} + +func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { return d.directRow } +func (d *fakeDatabase) Ping(context.Context) error { return nil } +func (d *fakeDatabase) Close() {} + +func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{rows: []Row{ + fakeRow{values: []any{journal.ID}}, + fakeRow{values: []any{int64(10)}}, + fakeRow{values: []any{int64(20)}}, + }} + database := &fakeDatabase{tx: tx} + + result, err := NewJournalRepository(database).Append(context.Background(), journal) + if err != nil { + t.Fatal(err) + } + if result.JournalID != journal.ID || result.AlreadyExists { + t.Fatalf("unexpected result: %+v", result) + } + if !tx.committed || tx.rolledBack { + t.Fatalf("unexpected transaction state: %+v", tx) + } + if tx.execCount != 3 { + t.Fatalf("expected two entry inserts and one seal, got %d execs", tx.execCount) + } +} + +func TestJournalRepositoryReturnsExistingIdenticalJournal(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}} + database := &fakeDatabase{ + tx: tx, + directRow: fakeRow{values: []any{journal.ID, journal.PayloadHash, true}}, + } + + result, err := NewJournalRepository(database).Append(context.Background(), journal) + if err != nil { + t.Fatal(err) + } + if !result.AlreadyExists || result.JournalID != journal.ID { + t.Fatalf("unexpected result: %+v", result) + } + if !tx.rolledBack || tx.committed { + t.Fatalf("duplicate transaction was not rolled back: %+v", tx) + } +} + +func TestJournalRepositoryRejectsConflictingIdempotencyPayload(t *testing.T) { + journal := repositoryJournal(t) + database := &fakeDatabase{ + tx: &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}}, + directRow: fakeRow{values: []any{journal.ID, strings.Repeat("b", 64), true}}, + } + + _, err := NewJournalRepository(database).Append(context.Background(), journal) + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("expected idempotency conflict, got %v", err) + } +} + +func TestJournalRepositoryRollsBackEntryFailure(t *testing.T) { + journal := repositoryJournal(t) + tx := &fakeTx{ + rows: []Row{ + fakeRow{values: []any{journal.ID}}, + fakeRow{values: []any{int64(10)}}, + }, + execErrAt: 1, + } + database := &fakeDatabase{tx: tx} + + if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil { + t.Fatal("expected entry insert error") + } + if !tx.rolledBack || tx.committed { + t.Fatalf("failed append was not rolled back: %+v", tx) + } +} + +func TestJournalRepositoryValidatesBeforeOpeningTransaction(t *testing.T) { + journal := repositoryJournal(t) + journal.Entries[1].Amount, _ = ledger.ParseAmount("9") + database := &fakeDatabase{tx: &fakeTx{}} + + if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil { + t.Fatal("expected validation error") + } + if database.beginCalled { + t.Fatal("invalid journal opened a database transaction") + } +} + +func repositoryJournal(t *testing.T) ledger.Journal { + t.Helper() + debit, err := ledger.ParseAmount("-10.25") + if err != nil { + t.Fatal(err) + } + return ledger.Journal{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + TrackingCode: "track-1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + Metadata: map[string]string{"origin": "test"}, + Entries: []ledger.Entry{ + { + LineNumber: 1, + Account: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5, + }, + Amount: debit, + }, + { + LineNumber: 2, + Account: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5, + }, + Amount: debit.Negate(), + }, + }, + } +} diff --git a/infrastructure/postgres/migrate.go b/infrastructure/postgres/migrate.go new file mode 100644 index 0000000..35dad7b --- /dev/null +++ b/infrastructure/postgres/migrate.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "embed" + "fmt" + "io/fs" + "sort" + "strconv" + "strings" +) + +//go:embed migrations/*.up.sql +var migrationFiles embed.FS + +const migrationLockID int64 = 674301 + +func Migrate(ctx context.Context, database Database) (err error) { + tx, err := database.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migrations: %w", err) + } + defer func() { + if err != nil { + _ = tx.Rollback(ctx) + } + }() + + if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil { + return fmt.Errorf("lock migrations: %w", err) + } + if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() + )`); err != nil { + return fmt.Errorf("create migration ledger: %w", err) + } + + files, err := fs.Glob(migrationFiles, "migrations/*.up.sql") + if err != nil { + return fmt.Errorf("list migrations: %w", err) + } + sort.Strings(files) + for _, name := range files { + versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0] + version, parseErr := strconv.ParseInt(versionText, 10, 64) + if parseErr != nil { + return fmt.Errorf("parse migration %s: %w", name, parseErr) + } + + var applied bool + if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if applied { + continue + } + + contents, readErr := migrationFiles.ReadFile(name) + if readErr != nil { + return fmt.Errorf("read migration %s: %w", name, readErr) + } + if _, err = tx.Exec(ctx, string(contents)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + + if err = tx.Commit(ctx); err != nil { + return fmt.Errorf("commit migrations: %w", err) + } + return nil +} diff --git a/infrastructure/postgres/migration_test.go b/infrastructure/postgres/migration_test.go new file mode 100644 index 0000000..5b612ba --- /dev/null +++ b/infrastructure/postgres/migration_test.go @@ -0,0 +1,26 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) { + contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql") + if err != nil { + t.Fatal(err) + } + schema := string(contents) + for _, required := range []string{ + "amount numeric(38, 18)", + "idempotency_key text NOT NULL UNIQUE", + "guard_journal_seal", + "journal is not balanced per asset", + "cannot append to a sealed journal", + "reject_ledger_mutation", + } { + if !strings.Contains(schema, required) { + t.Fatalf("migration is missing %q", required) + } + } +} diff --git a/infrastructure/postgres/migrations/000001_init.down.sql b/infrastructure/postgres/migrations/000001_init.down.sql new file mode 100644 index 0000000..5fa6e5b --- /dev/null +++ b/infrastructure/postgres/migrations/000001_init.down.sql @@ -0,0 +1,14 @@ +DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events; +DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts; +DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries; +DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries; +DROP TRIGGER IF EXISTS journals_reject_delete ON journals; +DROP TRIGGER IF EXISTS journals_guard_update ON journals; +DROP FUNCTION IF EXISTS guard_entry_insert(); +DROP FUNCTION IF EXISTS guard_journal_seal(); +DROP FUNCTION IF EXISTS reject_ledger_mutation(); +DROP TABLE IF EXISTS transaction_events; +DROP TABLE IF EXISTS journal_entries; +DROP TABLE IF EXISTS journals; +DROP TABLE IF EXISTS ledger_accounts; +DROP TABLE IF EXISTS ledger_schema_migrations; diff --git a/infrastructure/postgres/migrations/000001_init.up.sql b/infrastructure/postgres/migrations/000001_init.up.sql new file mode 100644 index 0000000..b577c96 --- /dev/null +++ b/infrastructure/postgres/migrations/000001_init.up.sql @@ -0,0 +1,172 @@ +CREATE TABLE IF NOT EXISTS ledger_schema_migrations ( + version bigint PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE ledger_accounts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + class text NOT NULL CHECK (class IN ( + 'USER_AVAILABLE', + 'USER_FROZEN', + 'EXTERNAL_BLOCKCHAIN', + 'TREASURY', + 'MARKET_CLEARING', + 'IPG_CLEARING', + 'COMMISSION_REVENUE' + )), + owner_type text NOT NULL DEFAULT '', + owner_id text NOT NULL DEFAULT '', + asset_id bigint NOT NULL CHECK (asset_id > 0), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (class, owner_type, owner_id, asset_id), + UNIQUE (id, asset_id), + CHECK ( + class NOT IN ('USER_AVAILABLE', 'USER_FROZEN') + OR (owner_type <> '' AND owner_id <> '') + ) +); + +CREATE TABLE journals ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + tracking_code text NOT NULL DEFAULT '', + effect_kind text NOT NULL CHECK (effect_kind <> ''), + event_version integer NOT NULL CHECK (event_version > 0), + reversal_of_journal_id uuid REFERENCES journals (id), + occurred_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + sealed_at timestamptz, + correlation_id text NOT NULL DEFAULT '', + actor_id text NOT NULL DEFAULT '', + blockchain_network text NOT NULL DEFAULT '', + blockchain_transaction_hash text NOT NULL DEFAULT '', + blockchain_ledger_sequence text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'), + CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id) +); + +CREATE INDEX journals_source_transaction_idx + ON journals (source_service, source_transaction_id); + +CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id); + +CREATE TABLE journal_entries ( + journal_id uuid NOT NULL REFERENCES journals (id), + line_number integer NOT NULL CHECK (line_number > 0), + account_id bigint NOT NULL, + asset_id bigint NOT NULL CHECK (asset_id > 0), + amount numeric(38, 18) NOT NULL CHECK (amount <> 0), + description text NOT NULL DEFAULT '', + PRIMARY KEY (journal_id, line_number), + FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id) +); + +CREATE INDEX journal_entries_account_idx + ON journal_entries (account_id, journal_id, line_number); + +CREATE TABLE transaction_events ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + tracking_code text NOT NULL DEFAULT '', + event_version integer NOT NULL CHECK (event_version > 0), + state text NOT NULL CHECK (state IN ( + 'CREATED', + 'PENDING_TRANSACTION', + 'PENDING_ADMIN', + 'SUCCESSFUL', + 'FAILED', + 'SUSPENDED' + )), + error_code text NOT NULL DEFAULT '', + error_message text NOT NULL DEFAULT '', + occurred_at timestamptz NOT NULL, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + correlation_id text NOT NULL DEFAULT '', + actor_id text NOT NULL DEFAULT '', + blockchain_network text NOT NULL DEFAULT '', + blockchain_transaction_hash text NOT NULL DEFAULT '', + blockchain_ledger_sequence text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'), + payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$') +); + +CREATE INDEX transaction_events_source_idx + ON transaction_events (source_service, source_transaction_id, event_version); + +CREATE FUNCTION reject_ledger_mutation() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + RAISE EXCEPTION '% is append-only', TG_TABLE_NAME + USING ERRCODE = '55000'; +END; +$$; + +CREATE FUNCTION guard_journal_seal() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF OLD.sealed_at IS NOT NULL + OR NEW.sealed_at IS NULL + OR (to_jsonb(NEW) - 'sealed_at') IS DISTINCT FROM (to_jsonb(OLD) - 'sealed_at') THEN + RAISE EXCEPTION 'journals are immutable except for initial sealing' + USING ERRCODE = '55000'; + END IF; + + IF (SELECT count(*) FROM journal_entries WHERE journal_id = NEW.id) < 2 THEN + RAISE EXCEPTION 'journal requires at least two entries' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT asset_id + FROM journal_entries + WHERE journal_id = NEW.id + GROUP BY asset_id + HAVING sum(amount) <> 0 + ) THEN + RAISE EXCEPTION 'journal is not balanced per asset' + USING ERRCODE = '23514'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE FUNCTION guard_entry_insert() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id = NEW.journal_id) THEN + RAISE EXCEPTION 'cannot append to a sealed journal' + USING ERRCODE = '55000'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER journals_guard_update +BEFORE UPDATE ON journals +FOR EACH ROW EXECUTE FUNCTION guard_journal_seal(); + +CREATE TRIGGER journals_reject_delete +BEFORE DELETE ON journals +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER entries_guard_insert +BEFORE INSERT ON journal_entries +FOR EACH ROW EXECUTE FUNCTION guard_entry_insert(); + +CREATE TRIGGER entries_reject_update_or_delete +BEFORE UPDATE OR DELETE ON journal_entries +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER accounts_reject_update_or_delete +BEFORE UPDATE OR DELETE ON ledger_accounts +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); + +CREATE TRIGGER transaction_events_reject_update_or_delete +BEFORE UPDATE OR DELETE ON transaction_events +FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation(); From 76905ab451e5496ec8f0626657e95a9077de83aea1c0ec11ae882469b88862bf Mon Sep 17 00:00:00 2001 From: nfel Date: Fri, 14 Aug 2026 19:11:29 +0330 Subject: [PATCH 04/20] feat(gl): expose ledger application and grpc operations --- application/ledger/service.go | 384 +++++++++++++++++ application/ledger/service_test.go | 223 ++++++++++ cmd/gl/main.go | 3 +- domain/ledger/errors.go | 10 + domain/ledger/event.go | 74 ++++ domain/ledger/journal.go | 15 + infrastructure/postgres/database.go | 17 + infrastructure/postgres/event_repository.go | 106 +++++ .../postgres/event_repository_test.go | 73 ++++ infrastructure/postgres/journal_repository.go | 14 +- .../postgres/journal_repository_test.go | 28 +- infrastructure/postgres/migration_test.go | 1 + .../postgres/migrations/000001_init.up.sql | 4 + infrastructure/postgres/query_repository.go | 239 ++++++++++ interface/grpc/health.go | 18 +- interface/grpc/ledger.go | 408 ++++++++++++++++++ interface/grpc/ledger_test.go | 123 ++++++ 17 files changed, 1724 insertions(+), 16 deletions(-) create mode 100644 application/ledger/service.go create mode 100644 application/ledger/service_test.go create mode 100644 domain/ledger/errors.go create mode 100644 domain/ledger/event.go create mode 100644 infrastructure/postgres/event_repository.go create mode 100644 infrastructure/postgres/event_repository_test.go create mode 100644 infrastructure/postgres/query_repository.go create mode 100644 interface/grpc/ledger.go create mode 100644 interface/grpc/ledger_test.go diff --git a/application/ledger/service.go b/application/ledger/service.go new file mode 100644 index 0000000..22fd19e --- /dev/null +++ b/application/ledger/service.go @@ -0,0 +1,384 @@ +// Package ledger implements GL's transport-independent use cases. +package ledger + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "time" + + domain "gl/domain/ledger" +) + +var ErrInvalidArgument = errors.New("invalid ledger request") + +type Repository interface { + Append(context.Context, domain.Journal) (domain.AppendResult, error) + AppendEvent(context.Context, domain.TransactionEvent) (domain.TransactionEvent, bool, error) + GetByID(context.Context, string) (domain.Journal, error) + GetByIdempotencyKey(context.Context, string) (domain.Journal, error) + List(context.Context, domain.JournalFilter) ([]domain.Journal, error) + Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) +} + +type IDGenerator func() (string, error) + +type Service struct { + repository Repository + newID IDGenerator +} + +func NewService(repository Repository, newID IDGenerator) *Service { + if newID == nil { + newID = newUUID + } + return &Service{repository: repository, newID: newID} +} + +type EntryCommand struct { + LineNumber uint32 + Account domain.AccountReference + Amount string + Description string +} + +type AppendJournalCommand struct { + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EffectKind string + EventVersion uint32 + Entries []EntryCommand + ReversalOfJournalID string + OccurredAt time.Time + CorrelationID string + ActorID string + Blockchain domain.BlockchainReference + Metadata map[string]string +} + +type AppendJournalResult struct { + Journal domain.Journal + AlreadyExisted bool +} + +func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) { + journalID, err := s.newID() + if err != nil { + return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err) + } + journal := domain.Journal{ + ID: journalID, + SourceService: command.SourceService, + IdempotencyKey: command.IdempotencyKey, + SourceTransactionID: command.SourceTransactionID, + TrackingCode: command.TrackingCode, + EffectKind: command.EffectKind, + EventVersion: command.EventVersion, + ReversalOfJournalID: command.ReversalOfJournalID, + OccurredAt: command.OccurredAt, + CorrelationID: command.CorrelationID, + ActorID: command.ActorID, + Blockchain: command.Blockchain, + Metadata: cloneMetadata(command.Metadata), + Entries: make([]domain.Entry, 0, len(command.Entries)), + } + for _, input := range command.Entries { + amount, parseErr := domain.ParseAmount(input.Amount) + if parseErr != nil { + return AppendJournalResult{}, invalid("entry %d amount: %v", input.LineNumber, parseErr) + } + journal.Entries = append(journal.Entries, domain.Entry{ + LineNumber: input.LineNumber, + Account: input.Account, + Amount: amount, + Description: input.Description, + }) + } + sort.Slice(journal.Entries, func(i, j int) bool { + return journal.Entries[i].LineNumber < journal.Entries[j].LineNumber + }) + + journal.PayloadHash, err = journalHash(journal) + if err != nil { + return AppendJournalResult{}, fmt.Errorf("hash journal: %w", err) + } + if err := journal.Validate(); err != nil { + return AppendJournalResult{}, invalid("%v", err) + } + if journal.ReversalOfJournalID != "" { + original, getErr := s.repository.GetByID(ctx, journal.ReversalOfJournalID) + if getErr != nil { + return AppendJournalResult{}, fmt.Errorf("read reversal target: %w", getErr) + } + if err := validateFullReversal(original, journal); err != nil { + return AppendJournalResult{}, invalid("%v", err) + } + } + + appendResult, err := s.repository.Append(ctx, journal) + if err != nil { + return AppendJournalResult{}, err + } + stored, err := s.repository.GetByID(ctx, appendResult.JournalID) + if err != nil { + return AppendJournalResult{}, fmt.Errorf("read appended journal: %w", err) + } + return AppendJournalResult{Journal: stored, AlreadyExisted: appendResult.AlreadyExists}, nil +} + +type AppendEventCommand struct { + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EventVersion uint32 + State domain.TransactionState + ErrorCode string + ErrorMessage string + OccurredAt time.Time + CorrelationID string + ActorID string + Blockchain domain.BlockchainReference + Metadata map[string]string +} + +func (s *Service) AppendEvent(ctx context.Context, command AppendEventCommand) (domain.TransactionEvent, bool, error) { + eventID, err := s.newID() + if err != nil { + return domain.TransactionEvent{}, false, fmt.Errorf("generate event id: %w", err) + } + event := domain.TransactionEvent{ + ID: eventID, + SourceService: command.SourceService, + IdempotencyKey: command.IdempotencyKey, + SourceTransactionID: command.SourceTransactionID, + TrackingCode: command.TrackingCode, + EventVersion: command.EventVersion, + State: command.State, + ErrorCode: command.ErrorCode, + ErrorMessage: command.ErrorMessage, + OccurredAt: command.OccurredAt, + CorrelationID: command.CorrelationID, + ActorID: command.ActorID, + Blockchain: command.Blockchain, + Metadata: cloneMetadata(command.Metadata), + } + event.PayloadHash, err = eventHash(event) + if err != nil { + return domain.TransactionEvent{}, false, fmt.Errorf("hash transaction event: %w", err) + } + if err := event.Validate(); err != nil { + return domain.TransactionEvent{}, false, invalid("%v", err) + } + return s.repository.AppendEvent(ctx, event) +} + +func (s *Service) GetJournal(ctx context.Context, journalID, idempotencyKey string) (domain.Journal, error) { + if (journalID == "") == (idempotencyKey == "") { + return domain.Journal{}, invalid("exactly one journal lookup is required") + } + if journalID != "" { + return s.repository.GetByID(ctx, journalID) + } + return s.repository.GetByIdempotencyKey(ctx, idempotencyKey) +} + +type ListCommand struct { + Account *domain.AccountReference + AssetID *int64 + RecordedFrom *time.Time + RecordedTo *time.Time + PageSize uint32 + PageToken string +} + +func (s *Service) List(ctx context.Context, command ListCommand) ([]domain.Journal, string, error) { + if command.Account != nil { + if err := command.Account.Validate(); err != nil { + return nil, "", invalid("account: %v", err) + } + } + if command.AssetID != nil && *command.AssetID <= 0 { + return nil, "", invalid("asset id must be positive") + } + pageSize := int(command.PageSize) + if pageSize == 0 { + pageSize = 50 + } + if pageSize > 200 { + return nil, "", invalid("page size must not exceed 200") + } + offset, err := decodePageToken(command.PageToken) + if err != nil { + return nil, "", invalid("page token: %v", err) + } + journals, err := s.repository.List(ctx, domain.JournalFilter{ + Account: command.Account, + AssetID: command.AssetID, + RecordedFrom: command.RecordedFrom, + RecordedTo: command.RecordedTo, + Limit: pageSize + 1, + Offset: offset, + }) + if err != nil { + return nil, "", err + } + next := "" + if len(journals) > pageSize { + journals = journals[:pageSize] + next = encodePageToken(offset + pageSize) + } + return journals, next, nil +} + +func (s *Service) Balance(ctx context.Context, account domain.AccountReference, asOf time.Time) (domain.Amount, error) { + if err := account.Validate(); err != nil { + return domain.Amount{}, invalid("account: %v", err) + } + return s.repository.Balance(ctx, account, asOf) +} + +func (s *Service) Replay(ctx context.Context, commands []AppendJournalCommand) ([]AppendJournalResult, error) { + if len(commands) == 0 || len(commands) > 200 { + return nil, invalid("replay batch must contain between 1 and 200 journals") + } + results := make([]AppendJournalResult, 0, len(commands)) + for _, command := range commands { + result, err := s.AppendJournal(ctx, command) + if err != nil { + return nil, err + } + results = append(results, result) + } + return results, nil +} + +func validateFullReversal(original, reversal domain.Journal) error { + if len(original.Entries) != len(reversal.Entries) { + return fmt.Errorf("reversal must contain every original entry") + } + expected := make(map[string]int, len(original.Entries)) + for _, entry := range original.Entries { + expected[entryKey(entry.Account, entry.Amount.Negate())]++ + } + for _, entry := range reversal.Entries { + key := entryKey(entry.Account, entry.Amount) + if expected[key] == 0 { + return fmt.Errorf("reversal entries must exactly negate the original journal") + } + expected[key]-- + } + return nil +} + +func entryKey(account domain.AccountReference, amount domain.Amount) string { + return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", account.Class, account.OwnerType, account.OwnerID, account.AssetID, amount.String()) +} + +func journalHash(journal domain.Journal) (string, error) { + entries := make([]map[string]any, 0, len(journal.Entries)) + for _, entry := range journal.Entries { + entries = append(entries, map[string]any{ + "line_number": entry.LineNumber, + "class": entry.Account.Class, + "owner_type": entry.Account.OwnerType, + "owner_id": entry.Account.OwnerID, + "asset_id": entry.Account.AssetID, + "amount": entry.Amount.String(), + "description": entry.Description, + }) + } + return hashPayload(map[string]any{ + "source_service": journal.SourceService, + "idempotency_key": journal.IdempotencyKey, + "source_transaction_id": journal.SourceTransactionID, + "tracking_code": journal.TrackingCode, + "effect_kind": journal.EffectKind, + "event_version": journal.EventVersion, + "entries": entries, + "reversal_of": journal.ReversalOfJournalID, + "occurred_at": journal.OccurredAt.UTC().Format(time.RFC3339Nano), + "correlation_id": journal.CorrelationID, + "actor_id": journal.ActorID, + "blockchain": journal.Blockchain, + "metadata": journal.Metadata, + }) +} + +func eventHash(event domain.TransactionEvent) (string, error) { + return hashPayload(map[string]any{ + "source_service": event.SourceService, + "idempotency_key": event.IdempotencyKey, + "source_transaction_id": event.SourceTransactionID, + "tracking_code": event.TrackingCode, + "event_version": event.EventVersion, + "state": event.State, + "error_code": event.ErrorCode, + "error_message": event.ErrorMessage, + "occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano), + "correlation_id": event.CorrelationID, + "actor_id": event.ActorID, + "blockchain": event.Blockchain, + "metadata": event.Metadata, + }) +} + +func hashPayload(payload any) (string, error) { + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + hash := sha256.Sum256(encoded) + return hex.EncodeToString(hash[:]), nil +} + +func newUUID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value) + return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil +} + +func cloneMetadata(metadata map[string]string) map[string]string { + clone := make(map[string]string, len(metadata)) + for key, value := range metadata { + clone[key] = value + } + return clone +} + +func invalid(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidArgument, fmt.Sprintf(format, args...)) +} + +func encodePageToken(offset int) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset))) +} + +func decodePageToken(token string) (int, error) { + if token == "" { + return 0, nil + } + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return 0, err + } + offset, err := strconv.Atoi(string(decoded)) + if err != nil || offset < 0 { + return 0, fmt.Errorf("invalid offset") + } + return offset, nil +} diff --git a/application/ledger/service_test.go b/application/ledger/service_test.go new file mode 100644 index 0000000..91a6af4 --- /dev/null +++ b/application/ledger/service_test.go @@ -0,0 +1,223 @@ +package ledger + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + domain "gl/domain/ledger" +) + +type repositoryStub struct { + journals map[string]domain.Journal + byKey map[string]string + events map[string]domain.TransactionEvent + appendCalls int + list []domain.Journal + balance domain.Amount + lastAttempt domain.Journal +} + +func newRepositoryStub() *repositoryStub { + return &repositoryStub{ + journals: make(map[string]domain.Journal), + byKey: make(map[string]string), + events: make(map[string]domain.TransactionEvent), + } +} + +func (r *repositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) { + r.appendCalls++ + r.lastAttempt = journal + if existingID, ok := r.byKey[journal.IdempotencyKey]; ok { + return domain.AppendResult{JournalID: existingID, AlreadyExists: true}, nil + } + journal.RecordedAt = time.Unix(2, 0).UTC() + r.journals[journal.ID] = journal + r.byKey[journal.IdempotencyKey] = journal.ID + return domain.AppendResult{JournalID: journal.ID}, nil +} + +func (r *repositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) { + if existing, ok := r.events[event.IdempotencyKey]; ok { + return existing, true, nil + } + event.RecordedAt = time.Unix(2, 0).UTC() + r.events[event.IdempotencyKey] = event + return event, false, nil +} + +func (r *repositoryStub) GetByID(_ context.Context, id string) (domain.Journal, error) { + journal, ok := r.journals[id] + if !ok { + return domain.Journal{}, errors.New("not found") + } + return journal, nil +} + +func (r *repositoryStub) GetByIdempotencyKey(_ context.Context, key string) (domain.Journal, error) { + id, ok := r.byKey[key] + if !ok { + return domain.Journal{}, errors.New("not found") + } + return r.journals[id], nil +} + +func (r *repositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) { + return append([]domain.Journal(nil), r.list...), nil +} + +func (r *repositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) { + return r.balance, nil +} + +func TestAppendJournalIsBalancedCanonicalAndIdempotent(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) + command := validAppendCommand() + + first, err := service.AppendJournal(context.Background(), command) + if err != nil { + t.Fatal(err) + } + command.Entries[0], command.Entries[1] = command.Entries[1], command.Entries[0] + second, err := service.AppendJournal(context.Background(), command) + if err != nil { + t.Fatal(err) + } + if first.AlreadyExisted || !second.AlreadyExisted { + t.Fatalf("unexpected idempotency results: first=%+v second=%+v", first, second) + } + if first.Journal.PayloadHash == "" || first.Journal.PayloadHash != second.Journal.PayloadHash { + t.Fatalf("unexpected payload hashes: %q %q", first.Journal.PayloadHash, second.Journal.PayloadHash) + } + if repository.lastAttempt.PayloadHash != first.Journal.PayloadHash { + t.Fatal("entry order changed the canonical payload hash") + } + if got := first.Journal.Entries[0].Amount.String(); got != "-10.25" { + t.Fatalf("amount was not canonical: %s", got) + } +} + +func TestAppendJournalRejectsInvalidAmountBeforeRepository(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) + command := validAppendCommand() + command.Entries[0].Amount = "NaN" + + _, err := service.AppendJournal(context.Background(), command) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("expected invalid argument, got %v", err) + } + if repository.appendCalls != 0 { + t.Fatal("invalid journal reached repository") + } +} + +func TestAppendJournalRequiresExactFullReversal(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("22222222-2222-4222-8222-222222222222")) + originalCommand := validAppendCommand() + original, err := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")).AppendJournal(context.Background(), originalCommand) + if err != nil { + t.Fatal(err) + } + + reversal := validAppendCommand() + reversal.IdempotencyKey = "wallet:1:internal-transfer-reversal:v1" + reversal.EffectKind = "internal-transfer-reversal" + reversal.ReversalOfJournalID = original.Journal.ID + reversal.Entries[0].Amount = "10.25" + reversal.Entries[1].Amount = "-10.25" + if _, err := service.AppendJournal(context.Background(), reversal); err != nil { + t.Fatalf("valid reversal failed: %v", err) + } + + reversal.IdempotencyKey = "wallet:1:bad-reversal:v1" + reversal.Entries[0].Account.OwnerID = "different-user" + if _, err := service.AppendJournal(context.Background(), reversal); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("expected invalid reversal, got %v", err) + } +} + +func TestAppendEventIsIdempotent(t *testing.T) { + repository := newRepositoryStub() + service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")) + command := AppendEventCommand{ + SourceService: "wallet", + IdempotencyKey: "wallet:1:status:v1", + SourceTransactionID: "1", + EventVersion: 1, + State: domain.TransactionStateCreated, + OccurredAt: time.Unix(1, 0).UTC(), + } + + first, existed, err := service.AppendEvent(context.Background(), command) + if err != nil || existed { + t.Fatalf("first append: existed=%v err=%v", existed, err) + } + second, existed, err := service.AppendEvent(context.Background(), command) + if err != nil || !existed || first.PayloadHash != second.PayloadHash { + t.Fatalf("second append: existed=%v err=%v first=%+v second=%+v", existed, err, first, second) + } +} + +func TestListUsesOpaquePageToken(t *testing.T) { + repository := newRepositoryStub() + repository.list = make([]domain.Journal, 3) + service := NewService(repository, nil) + + journals, next, err := service.List(context.Background(), ListCommand{PageSize: 2}) + if err != nil { + t.Fatal(err) + } + if len(journals) != 2 || next == "" { + t.Fatalf("unexpected page: len=%d next=%q", len(journals), next) + } + if _, err := decodePageToken(next); err != nil { + t.Fatalf("invalid generated page token: %v", err) + } +} + +func TestNewUUIDProducesRFC4122Shape(t *testing.T) { + id, err := newUUID() + if err != nil { + t.Fatal(err) + } + if len(id) != 36 || id[14] != '4' || !strings.Contains("89ab", string(id[19])) { + t.Fatalf("unexpected UUID: %q", id) + } +} + +func validAppendCommand() AppendJournalCommand { + return AppendJournalCommand{ + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionID: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: time.Unix(1, 0).UTC(), + Entries: []EntryCommand{ + { + LineNumber: 1, + Account: domain.AccountReference{ + Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5, + }, + Amount: "-10.2500", + }, + { + LineNumber: 2, + Account: domain.AccountReference{ + Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5, + }, + Amount: "10.25", + }, + }, + } +} + +func fixedID(id string) IDGenerator { + return func() (string, error) { return id, nil } +} diff --git a/cmd/gl/main.go b/cmd/gl/main.go index d883fd6..6d4c5af 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -9,6 +9,7 @@ import ( "syscall" "gl/application/health" + applicationledger "gl/application/ledger" "gl/infrastructure/config" "gl/infrastructure/postgres" grpcadapter "gl/interface/grpc" @@ -38,7 +39,7 @@ func main() { os.Exit(1) } - handler := grpcadapter.NewHealthHandler(health.NewService(database)) + handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(postgres.NewJournalRepository(database), nil)) slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, diff --git a/domain/ledger/errors.go b/domain/ledger/errors.go new file mode 100644 index 0000000..6708e0a --- /dev/null +++ b/domain/ledger/errors.go @@ -0,0 +1,10 @@ +package ledger + +import "errors" + +var ( + ErrNotFound = errors.New("ledger record not found") + ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload") + ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal") + ErrAlreadyReversed = errors.New("journal already has a reversal") +) diff --git a/domain/ledger/event.go b/domain/ledger/event.go new file mode 100644 index 0000000..0f9084c --- /dev/null +++ b/domain/ledger/event.go @@ -0,0 +1,74 @@ +package ledger + +import ( + "encoding/hex" + "fmt" + "strings" + "time" +) + +type TransactionState string + +const ( + TransactionStateCreated TransactionState = "CREATED" + TransactionStatePendingTransaction TransactionState = "PENDING_TRANSACTION" + TransactionStatePendingAdmin TransactionState = "PENDING_ADMIN" + TransactionStateSuccessful TransactionState = "SUCCESSFUL" + TransactionStateFailed TransactionState = "FAILED" + TransactionStateSuspended TransactionState = "SUSPENDED" +) + +func (s TransactionState) Valid() bool { + switch s { + case TransactionStateCreated, + TransactionStatePendingTransaction, + TransactionStatePendingAdmin, + TransactionStateSuccessful, + TransactionStateFailed, + TransactionStateSuspended: + return true + default: + return false + } +} + +type TransactionEvent struct { + ID string + SourceService string + IdempotencyKey string + SourceTransactionID string + TrackingCode string + EventVersion uint32 + State TransactionState + ErrorCode string + ErrorMessage string + OccurredAt time.Time + RecordedAt time.Time + CorrelationID string + ActorID string + Blockchain BlockchainReference + Metadata map[string]string + PayloadHash string +} + +func (e TransactionEvent) Validate() error { + if e.ID == "" || e.SourceService == "" || e.IdempotencyKey == "" || e.SourceTransactionID == "" { + return fmt.Errorf("transaction event identity fields are required") + } + if e.EventVersion == 0 { + return fmt.Errorf("event version must be positive") + } + if !e.State.Valid() { + return fmt.Errorf("invalid transaction state %q", e.State) + } + if e.OccurredAt.IsZero() { + return fmt.Errorf("occurred time is required") + } + if len(e.PayloadHash) != 64 || e.PayloadHash != strings.ToLower(e.PayloadHash) { + return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string") + } + if _, err := hex.DecodeString(e.PayloadHash); err != nil { + return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string") + } + return nil +} diff --git a/domain/ledger/journal.go b/domain/ledger/journal.go index 47c82b3..c885cef 100644 --- a/domain/ledger/journal.go +++ b/domain/ledger/journal.go @@ -80,6 +80,7 @@ type Journal struct { Entries []Entry ReversalOfJournalID string OccurredAt time.Time + RecordedAt time.Time CorrelationID string ActorID string Blockchain BlockchainReference @@ -87,6 +88,20 @@ type Journal struct { PayloadHash string } +type AppendResult struct { + JournalID string + AlreadyExists bool +} + +type JournalFilter struct { + Account *AccountReference + AssetID *int64 + RecordedFrom *time.Time + RecordedTo *time.Time + Limit int + Offset int +} + func (j Journal) Validate() error { if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" { return fmt.Errorf("journal identity fields are required") diff --git a/infrastructure/postgres/database.go b/infrastructure/postgres/database.go index a9f8272..f5e5a16 100644 --- a/infrastructure/postgres/database.go +++ b/infrastructure/postgres/database.go @@ -18,6 +18,13 @@ type Row interface { Scan(dest ...any) error } +type Rows interface { + Next() bool + Scan(dest ...any) error + Err() error + Close() +} + type Tx interface { Exec(context.Context, string, ...any) (pgconn.CommandTag, error) QueryRow(context.Context, string, ...any) Row @@ -27,7 +34,9 @@ type Tx interface { type Database interface { Begin(context.Context) (Tx, error) + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) QueryRow(context.Context, string, ...any) Row + Query(context.Context, string, ...any) (Rows, error) Ping(context.Context) error Close() } @@ -70,6 +79,14 @@ func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row { return p.pool.QueryRow(ctx, sql, args...) } +func (p *Pool) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return p.pool.Query(ctx, sql, args...) +} + +func (p *Pool) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + return p.pool.Exec(ctx, sql, args...) +} + func (p *Pool) Close() { p.pool.Close() } diff --git a/infrastructure/postgres/event_repository.go b/infrastructure/postgres/event_repository.go new file mode 100644 index 0000000..b0fc698 --- /dev/null +++ b/infrastructure/postgres/event_repository.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +func (r *JournalRepository) AppendEvent(ctx context.Context, event ledger.TransactionEvent) (ledger.TransactionEvent, bool, error) { + if err := event.Validate(); err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("validate transaction event: %w", err) + } + metadataValues := event.Metadata + if metadataValues == nil { + metadataValues = map[string]string{} + } + metadata, err := json.Marshal(metadataValues) + if err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("encode event metadata: %w", err) + } + + err = r.database.QueryRow(ctx, insertEventSQL, + event.ID, + event.SourceService, + event.IdempotencyKey, + event.SourceTransactionID, + event.TrackingCode, + event.EventVersion, + event.State, + event.ErrorCode, + event.ErrorMessage, + event.OccurredAt, + event.CorrelationID, + event.ActorID, + event.Blockchain.Network, + event.Blockchain.TransactionHash, + event.Blockchain.LedgerSequence, + metadata, + event.PayloadHash, + ).Scan(&event.RecordedAt) + if !errors.Is(err, pgx.ErrNoRows) { + if err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("insert transaction event: %w", err) + } + return event, false, nil + } + + var stored ledger.TransactionEvent + var storedMetadata []byte + err = r.database.QueryRow(ctx, selectEventByIdempotencySQL, event.IdempotencyKey).Scan( + &stored.ID, + &stored.SourceService, + &stored.IdempotencyKey, + &stored.SourceTransactionID, + &stored.TrackingCode, + &stored.EventVersion, + &stored.State, + &stored.ErrorCode, + &stored.ErrorMessage, + &stored.OccurredAt, + &stored.RecordedAt, + &stored.CorrelationID, + &stored.ActorID, + &stored.Blockchain.Network, + &stored.Blockchain.TransactionHash, + &stored.Blockchain.LedgerSequence, + &storedMetadata, + &stored.PayloadHash, + ) + if err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("read idempotent transaction event: %w", err) + } + if stored.PayloadHash != event.PayloadHash { + return ledger.TransactionEvent{}, false, ErrIdempotencyConflict + } + if err := json.Unmarshal(storedMetadata, &stored.Metadata); err != nil { + return ledger.TransactionEvent{}, false, fmt.Errorf("decode event metadata: %w", err) + } + return stored, true, nil +} + +const insertEventSQL = ` +INSERT INTO transaction_events ( + id, source_service, idempotency_key, source_transaction_id, tracking_code, + event_version, state, error_code, error_message, occurred_at, correlation_id, + actor_id, blockchain_network, blockchain_transaction_hash, + blockchain_ledger_sequence, metadata, payload_hash +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17 +) +ON CONFLICT (idempotency_key) DO NOTHING +RETURNING recorded_at` + +const selectEventByIdempotencySQL = ` +SELECT id, source_service, idempotency_key, source_transaction_id, + tracking_code, event_version, state, error_code, error_message, + occurred_at, recorded_at, correlation_id, actor_id, blockchain_network, + blockchain_transaction_hash, blockchain_ledger_sequence, metadata, + payload_hash +FROM transaction_events +WHERE idempotency_key = $1` diff --git a/infrastructure/postgres/event_repository_test.go b/infrastructure/postgres/event_repository_test.go new file mode 100644 index 0000000..8149292 --- /dev/null +++ b/infrastructure/postgres/event_repository_test.go @@ -0,0 +1,73 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +func TestAppendEventStoresAndReturnsRecordingTime(t *testing.T) { + event := repositoryEvent() + recordedAt := time.Unix(2, 0).UTC() + database := &fakeDatabase{directRows: []Row{fakeRow{values: []any{recordedAt}}}} + + stored, existed, err := NewJournalRepository(database).AppendEvent(context.Background(), event) + if err != nil { + t.Fatal(err) + } + if existed || !stored.RecordedAt.Equal(recordedAt) { + t.Fatalf("unexpected append result: existed=%v event=%+v", existed, stored) + } +} + +func TestAppendEventRejectsConflictingIdempotencyPayload(t *testing.T) { + event := repositoryEvent() + storedHash := strings.Repeat("b", 64) + database := &fakeDatabase{directRows: []Row{ + fakeRow{err: pgx.ErrNoRows}, + fakeRow{values: []any{ + event.ID, + event.SourceService, + event.IdempotencyKey, + event.SourceTransactionID, + event.TrackingCode, + event.EventVersion, + event.State, + event.ErrorCode, + event.ErrorMessage, + event.OccurredAt, + time.Unix(2, 0).UTC(), + event.CorrelationID, + event.ActorID, + event.Blockchain.Network, + event.Blockchain.TransactionHash, + event.Blockchain.LedgerSequence, + []byte(`{}`), + storedHash, + }}, + }} + + _, _, err := NewJournalRepository(database).AppendEvent(context.Background(), event) + if !errors.Is(err, ErrIdempotencyConflict) { + t.Fatalf("expected idempotency conflict, got %v", err) + } +} + +func repositoryEvent() ledger.TransactionEvent { + return ledger.TransactionEvent{ + ID: "11111111-1111-4111-8111-111111111111", + SourceService: "wallet", + IdempotencyKey: "wallet:1:status:v1", + SourceTransactionID: "1", + EventVersion: 1, + State: ledger.TransactionStateCreated, + OccurredAt: time.Unix(1, 0).UTC(), + PayloadHash: strings.Repeat("a", 64), + } +} diff --git a/infrastructure/postgres/journal_repository.go b/infrastructure/postgres/journal_repository.go index 4bc5126..72c593b 100644 --- a/infrastructure/postgres/journal_repository.go +++ b/infrastructure/postgres/journal_repository.go @@ -9,17 +9,15 @@ import ( "gl/domain/ledger" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) var ( - ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload") - ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal") + ErrIdempotencyConflict = ledger.ErrIdempotencyConflict + ErrIncompleteJournal = ledger.ErrIncompleteJournal ) -type AppendResult struct { - JournalID string - AlreadyExists bool -} +type AppendResult = ledger.AppendResult type JournalRepository struct { database Database @@ -76,6 +74,10 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) return r.resolveDuplicate(ctx, journal) } if err != nil { + var postgresError *pgconn.PgError + if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" { + return AppendResult{}, ledger.ErrAlreadyReversed + } return AppendResult{}, fmt.Errorf("insert journal: %w", err) } diff --git a/infrastructure/postgres/journal_repository_test.go b/infrastructure/postgres/journal_repository_test.go index 12bf4d3..a90154f 100644 --- a/infrastructure/postgres/journal_repository_test.go +++ b/infrastructure/postgres/journal_repository_test.go @@ -33,6 +33,14 @@ func (r fakeRow) Scan(dest ...any) error { *target = value.(int64) case *bool: *target = value.(bool) + case *uint32: + *target = value.(uint32) + case *time.Time: + *target = value.(time.Time) + case *ledger.TransactionState: + *target = value.(ledger.TransactionState) + case *[]byte: + *target = value.([]byte) default: return errors.New("unsupported scan target") } @@ -75,6 +83,7 @@ func (t *fakeTx) Rollback(context.Context) error { type fakeDatabase struct { tx *fakeTx directRow Row + directRows []Row beginCalled bool } @@ -83,9 +92,22 @@ func (d *fakeDatabase) Begin(context.Context) (Tx, error) { return d.tx, nil } -func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { return d.directRow } -func (d *fakeDatabase) Ping(context.Context) error { return nil } -func (d *fakeDatabase) Close() {} +func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row { + if len(d.directRows) > 0 { + row := d.directRows[0] + d.directRows = d.directRows[1:] + return row + } + return d.directRow +} +func (d *fakeDatabase) Query(context.Context, string, ...any) (Rows, error) { + return nil, errors.New("unexpected query") +} +func (d *fakeDatabase) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + return pgconn.CommandTag{}, errors.New("unexpected exec") +} +func (d *fakeDatabase) Ping(context.Context) error { return nil } +func (d *fakeDatabase) Close() {} func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) { journal := repositoryJournal(t) diff --git a/infrastructure/postgres/migration_test.go b/infrastructure/postgres/migration_test.go index 5b612ba..ea3a977 100644 --- a/infrastructure/postgres/migration_test.go +++ b/infrastructure/postgres/migration_test.go @@ -18,6 +18,7 @@ func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) { "journal is not balanced per asset", "cannot append to a sealed journal", "reject_ledger_mutation", + "journals_one_reversal_idx", } { if !strings.Contains(schema, required) { t.Fatalf("migration is missing %q", required) diff --git a/infrastructure/postgres/migrations/000001_init.up.sql b/infrastructure/postgres/migrations/000001_init.up.sql index b577c96..5d86fba 100644 --- a/infrastructure/postgres/migrations/000001_init.up.sql +++ b/infrastructure/postgres/migrations/000001_init.up.sql @@ -53,6 +53,10 @@ CREATE INDEX journals_source_transaction_idx CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id); +CREATE UNIQUE INDEX journals_one_reversal_idx + ON journals (reversal_of_journal_id) + WHERE reversal_of_journal_id IS NOT NULL; + CREATE TABLE journal_entries ( journal_id uuid NOT NULL REFERENCES journals (id), line_number integer NOT NULL CHECK (line_number > 0), diff --git a/infrastructure/postgres/query_repository.go b/infrastructure/postgres/query_repository.go new file mode 100644 index 0000000..50a01e6 --- /dev/null +++ b/infrastructure/postgres/query_repository.go @@ -0,0 +1,239 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "gl/domain/ledger" + + "github.com/jackc/pgx/v5" +) + +var ErrNotFound = ledger.ErrNotFound + +type JournalFilter = ledger.JournalFilter + +func (r *JournalRepository) GetByID(ctx context.Context, journalID string) (ledger.Journal, error) { + return r.get(ctx, getJournalByIDSQL, journalID) +} + +func (r *JournalRepository) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (ledger.Journal, error) { + return r.get(ctx, getJournalByIdempotencySQL, idempotencyKey) +} + +func (r *JournalRepository) get(ctx context.Context, query string, value string) (ledger.Journal, error) { + journal, err := scanJournal(r.database.QueryRow(ctx, query, value)) + if errors.Is(err, pgx.ErrNoRows) { + return ledger.Journal{}, ErrNotFound + } + if err != nil { + return ledger.Journal{}, fmt.Errorf("read journal: %w", err) + } + journal.Entries, err = r.loadEntries(ctx, journal.ID) + if err != nil { + return ledger.Journal{}, err + } + return journal, nil +} + +func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]ledger.Journal, error) { + limit := filter.Limit + if limit <= 0 || limit > 201 { + limit = 50 + } + if filter.Offset < 0 { + filter.Offset = 0 + } + + var accountClass, ownerType, ownerID any + if filter.Account != nil { + accountClass = filter.Account.Class + ownerType = filter.Account.OwnerType + ownerID = filter.Account.OwnerID + } + rows, err := r.database.Query(ctx, listJournalsSQL, + filter.AssetID, + accountClass, + ownerType, + ownerID, + filter.RecordedFrom, + filter.RecordedTo, + limit, + filter.Offset, + ) + if err != nil { + return nil, fmt.Errorf("list journals: %w", err) + } + defer rows.Close() + + journals := make([]ledger.Journal, 0, limit) + for rows.Next() { + journal, scanErr := scanJournal(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan journal: %w", scanErr) + } + journals = append(journals, journal) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate journals: %w", err) + } + rows.Close() + + for index := range journals { + journals[index].Entries, err = r.loadEntries(ctx, journals[index].ID) + if err != nil { + return nil, err + } + } + return journals, nil +} + +func (r *JournalRepository) Balance(ctx context.Context, account ledger.AccountReference, asOf time.Time) (ledger.Amount, error) { + if err := account.Validate(); err != nil { + return ledger.Amount{}, err + } + var asOfValue any + if !asOf.IsZero() { + asOfValue = asOf + } + var value string + if err := r.database.QueryRow(ctx, getBalanceSQL, + account.Class, + account.OwnerType, + account.OwnerID, + account.AssetID, + asOfValue, + ).Scan(&value); err != nil { + return ledger.Amount{}, fmt.Errorf("read balance: %w", err) + } + amount, err := ledger.ParseAmount(value) + if err != nil { + return ledger.Amount{}, fmt.Errorf("decode balance: %w", err) + } + return amount, nil +} + +type scanner interface { + Scan(...any) error +} + +func scanJournal(row scanner) (ledger.Journal, error) { + var ( + journal ledger.Journal + reversal *string + metadata []byte + ) + err := row.Scan( + &journal.ID, + &journal.SourceService, + &journal.IdempotencyKey, + &journal.SourceTransactionID, + &journal.TrackingCode, + &journal.EffectKind, + &journal.EventVersion, + &reversal, + &journal.OccurredAt, + &journal.RecordedAt, + &journal.CorrelationID, + &journal.ActorID, + &journal.Blockchain.Network, + &journal.Blockchain.TransactionHash, + &journal.Blockchain.LedgerSequence, + &metadata, + &journal.PayloadHash, + ) + if err != nil { + return ledger.Journal{}, err + } + if reversal != nil { + journal.ReversalOfJournalID = *reversal + } + if err := json.Unmarshal(metadata, &journal.Metadata); err != nil { + return ledger.Journal{}, fmt.Errorf("decode metadata: %w", err) + } + return journal, nil +} + +func (r *JournalRepository) loadEntries(ctx context.Context, journalID string) ([]ledger.Entry, error) { + rows, err := r.database.Query(ctx, getEntriesSQL, journalID) + if err != nil { + return nil, fmt.Errorf("read journal entries: %w", err) + } + defer rows.Close() + + entries := make([]ledger.Entry, 0) + for rows.Next() { + var ( + entry ledger.Entry + amountValue string + ) + if err := rows.Scan( + &entry.LineNumber, + &entry.Account.Class, + &entry.Account.OwnerType, + &entry.Account.OwnerID, + &entry.Account.AssetID, + &amountValue, + &entry.Description, + ); err != nil { + return nil, fmt.Errorf("scan journal entry: %w", err) + } + entry.Amount, err = ledger.ParseAmount(amountValue) + if err != nil { + return nil, fmt.Errorf("decode journal entry amount: %w", err) + } + entries = append(entries, entry) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate journal entries: %w", err) + } + return entries, nil +} + +const journalColumns = ` + j.id, j.source_service, j.idempotency_key, j.source_transaction_id, + j.tracking_code, j.effect_kind, j.event_version, j.reversal_of_journal_id, + j.occurred_at, j.recorded_at, j.correlation_id, j.actor_id, + j.blockchain_network, j.blockchain_transaction_hash, + j.blockchain_ledger_sequence, j.metadata, j.payload_hash` + +const getJournalByIDSQL = `SELECT ` + journalColumns + ` +FROM journals j WHERE j.id = $1 AND j.sealed_at IS NOT NULL` + +const getJournalByIdempotencySQL = `SELECT ` + journalColumns + ` +FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL` + +const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + ` +FROM journals j +JOIN journal_entries e ON e.journal_id = j.id +JOIN ledger_accounts a ON a.id = e.account_id +WHERE j.sealed_at IS NOT NULL + AND ($1::bigint IS NULL OR e.asset_id = $1) + AND ($2::text IS NULL OR ( + a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4 + )) + AND ($5::timestamptz IS NULL OR j.recorded_at >= $5) + AND ($6::timestamptz IS NULL OR j.recorded_at <= $6) +ORDER BY j.recorded_at DESC, j.id DESC +LIMIT $7 OFFSET $8` + +const getEntriesSQL = ` +SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id, + e.amount::text, e.description +FROM journal_entries e +JOIN ledger_accounts a ON a.id = e.account_id +WHERE e.journal_id = $1 +ORDER BY e.line_number` + +const getBalanceSQL = ` +SELECT COALESCE(sum(e.amount), 0)::text +FROM journal_entries e +JOIN ledger_accounts a ON a.id = e.account_id +JOIN journals j ON j.id = e.journal_id +WHERE j.sealed_at IS NOT NULL + AND a.class = $1 AND a.owner_type = $2 AND a.owner_id = $3 + AND a.asset_id = $4 + AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)` diff --git a/interface/grpc/health.go b/interface/grpc/health.go index f49e534..4757e5a 100644 --- a/interface/grpc/health.go +++ b/interface/grpc/health.go @@ -4,21 +4,27 @@ import ( "context" "gl/application/health" + applicationledger "gl/application/ledger" basev1 "gl/gen/base/v1" ledgerv1 "gl/gen/ledger/v1" ) -type HealthHandler struct { +type Handler struct { ledgerv1.UnimplementedGeneralLedgerServiceServer - service *health.Service + health *health.Service + ledger *applicationledger.Service } -func NewHealthHandler(service *health.Service) *HealthHandler { - return &HealthHandler{service: service} +func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service) *Handler { + return &Handler{health: healthService, ledger: ledgerService} } -func (h *HealthHandler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) { - result := h.service.Check(ctx) +func NewHealthHandler(service *health.Service) *Handler { + return NewHandler(service, nil) +} + +func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) { + result := h.health.Check(ctx) return &ledgerv1.HealthResponse{ Serving: result.Serving, DatabaseReady: result.DatabaseReady, diff --git a/interface/grpc/ledger.go b/interface/grpc/ledger.go new file mode 100644 index 0000000..acff151 --- /dev/null +++ b/interface/grpc/ledger.go @@ -0,0 +1,408 @@ +package grpcadapter + +import ( + "context" + "errors" + "time" + + applicationledger "gl/application/ledger" + domain "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func (h *Handler) AppendJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) { + command, err := appendJournalCommand(request) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + result, err := h.ledger.AppendJournal(ctx, command) + if err != nil { + return nil, rpcError(err) + } + return &ledgerv1.AppendJournalResponse{ + Journal: journalMessage(result.Journal), + AlreadyExisted: result.AlreadyExisted, + }, nil +} + +func (h *Handler) AppendTransactionEvent(ctx context.Context, request *ledgerv1.AppendTransactionEventRequest) (*ledgerv1.AppendTransactionEventResponse, error) { + occurredAt, err := requiredTime(request.GetOccurredAt(), "occurred_at") + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + event, alreadyExisted, err := h.ledger.AppendEvent(ctx, applicationledger.AppendEventCommand{ + SourceService: request.GetSourceService(), + IdempotencyKey: request.GetIdempotencyKey(), + SourceTransactionID: request.GetSourceTransactionId(), + TrackingCode: request.GetTrackingCode(), + EventVersion: request.GetEventVersion(), + State: transactionState(request.GetState()), + ErrorCode: request.GetErrorCode(), + ErrorMessage: request.GetErrorMessage(), + OccurredAt: occurredAt, + CorrelationID: request.GetCorrelationId(), + ActorID: request.GetActorId(), + Blockchain: blockchainReference(request.GetBlockchain()), + Metadata: request.GetMetadata(), + }) + if err != nil { + return nil, rpcError(err) + } + return &ledgerv1.AppendTransactionEventResponse{ + Event: eventMessage(event), + AlreadyExisted: alreadyExisted, + }, nil +} + +func (h *Handler) GetJournal(ctx context.Context, request *ledgerv1.GetJournalRequest) (*ledgerv1.Journal, error) { + var journalID, idempotencyKey string + switch lookup := request.GetLookup().(type) { + case *ledgerv1.GetJournalRequest_JournalId: + journalID = lookup.JournalId + case *ledgerv1.GetJournalRequest_IdempotencyKey: + idempotencyKey = lookup.IdempotencyKey + } + journal, err := h.ledger.GetJournal(ctx, journalID, idempotencyKey) + if err != nil { + return nil, rpcError(err) + } + return journalMessage(journal), nil +} + +func (h *Handler) ListEntries(ctx context.Context, request *ledgerv1.ListEntriesRequest) (*ledgerv1.ListEntriesResponse, error) { + command := applicationledger.ListCommand{ + PageSize: request.GetPageSize(), + PageToken: request.GetPageToken(), + } + if request.Account != nil { + account := accountReference(request.Account) + command.Account = &account + } + if request.AssetId != nil { + assetID := request.GetAssetId() + command.AssetID = &assetID + } + var err error + if request.RecordedFrom != nil { + value, parseErr := requiredTime(request.RecordedFrom, "recorded_from") + if parseErr != nil { + return nil, status.Error(codes.InvalidArgument, parseErr.Error()) + } + command.RecordedFrom = &value + } + if request.RecordedTo != nil { + value, parseErr := requiredTime(request.RecordedTo, "recorded_to") + if parseErr != nil { + return nil, status.Error(codes.InvalidArgument, parseErr.Error()) + } + command.RecordedTo = &value + } + journals, nextPageToken, err := h.ledger.List(ctx, command) + if err != nil { + return nil, rpcError(err) + } + response := &ledgerv1.ListEntriesResponse{ + Journals: make([]*ledgerv1.Journal, 0, len(journals)), + NextPageToken: nextPageToken, + } + for _, journal := range journals { + response.Journals = append(response.Journals, journalMessage(journal)) + } + return response, nil +} + +func (h *Handler) GetBalance(ctx context.Context, request *ledgerv1.GetBalanceRequest) (*ledgerv1.GetBalanceResponse, error) { + if request.Account == nil { + return nil, status.Error(codes.InvalidArgument, "account is required") + } + asOf := time.Now().UTC() + if request.AsOf != nil { + value, err := requiredTime(request.AsOf, "as_of") + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + asOf = value + } + account := accountReference(request.Account) + balance, err := h.ledger.Balance(ctx, account, asOf) + if err != nil { + return nil, rpcError(err) + } + return &ledgerv1.GetBalanceResponse{ + Account: accountMessage(account), + Balance: balance.String(), + AsOf: timestamppb.New(asOf), + }, nil +} + +func (h *Handler) ReplayJournals(ctx context.Context, request *ledgerv1.ReplayJournalsRequest) (*ledgerv1.ReplayJournalsResponse, error) { + commands := make([]applicationledger.AppendJournalCommand, 0, len(request.GetJournals())) + for _, journal := range request.GetJournals() { + command, err := appendJournalCommand(journal) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + commands = append(commands, command) + } + results, err := h.ledger.Replay(ctx, commands) + if err != nil { + return nil, rpcError(err) + } + response := &ledgerv1.ReplayJournalsResponse{Results: make([]*ledgerv1.ReplayJournalResult, 0, len(results))} + for _, result := range results { + response.Results = append(response.Results, &ledgerv1.ReplayJournalResult{ + IdempotencyKey: result.Journal.IdempotencyKey, + Journal: journalMessage(result.Journal), + AlreadyExisted: result.AlreadyExisted, + }) + } + return response, nil +} + +func appendJournalCommand(request *ledgerv1.AppendJournalRequest) (applicationledger.AppendJournalCommand, error) { + occurredAt, err := requiredTime(request.GetOccurredAt(), "occurred_at") + if err != nil { + return applicationledger.AppendJournalCommand{}, err + } + command := applicationledger.AppendJournalCommand{ + SourceService: request.GetSourceService(), + IdempotencyKey: request.GetIdempotencyKey(), + SourceTransactionID: request.GetSourceTransactionId(), + TrackingCode: request.GetTrackingCode(), + EffectKind: request.GetEffectKind(), + EventVersion: request.GetEventVersion(), + ReversalOfJournalID: request.GetReversalOfJournalId(), + OccurredAt: occurredAt, + CorrelationID: request.GetCorrelationId(), + ActorID: request.GetActorId(), + Blockchain: blockchainReference(request.GetBlockchain()), + Metadata: request.GetMetadata(), + Entries: make([]applicationledger.EntryCommand, 0, len(request.GetEntries())), + } + for _, entry := range request.GetEntries() { + if entry.Account == nil { + return applicationledger.AppendJournalCommand{}, errors.New("entry account is required") + } + command.Entries = append(command.Entries, applicationledger.EntryCommand{ + LineNumber: entry.GetLineNumber(), + Account: accountReference(entry.Account), + Amount: entry.GetAmount(), + Description: entry.GetDescription(), + }) + } + return command, nil +} + +func requiredTime(value *timestamppb.Timestamp, field string) (time.Time, error) { + if value == nil { + return time.Time{}, errors.New(field + " is required") + } + if err := value.CheckValid(); err != nil { + return time.Time{}, errors.New(field + " is invalid: " + err.Error()) + } + return value.AsTime().UTC(), nil +} + +func accountReference(value *ledgerv1.AccountReference) domain.AccountReference { + return domain.AccountReference{ + Class: accountClass(value.GetAccountClass()), + OwnerType: value.GetOwnerType(), + OwnerID: value.GetOwnerId(), + AssetID: value.GetAssetId(), + } +} + +func accountClass(value ledgerv1.AccountClass) domain.AccountClass { + switch value { + case ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE: + return domain.AccountClassUserAvailable + case ledgerv1.AccountClass_ACCOUNT_CLASS_USER_FROZEN: + return domain.AccountClassUserFrozen + case ledgerv1.AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN: + return domain.AccountClassExternalBlockchain + case ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY: + return domain.AccountClassTreasury + case ledgerv1.AccountClass_ACCOUNT_CLASS_MARKET_CLEARING: + return domain.AccountClassMarketClearing + case ledgerv1.AccountClass_ACCOUNT_CLASS_IPG_CLEARING: + return domain.AccountClassIPGClearing + case ledgerv1.AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE: + return domain.AccountClassCommissionRevenue + default: + return "" + } +} + +func accountClassMessage(value domain.AccountClass) ledgerv1.AccountClass { + switch value { + case domain.AccountClassUserAvailable: + return ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE + case domain.AccountClassUserFrozen: + return ledgerv1.AccountClass_ACCOUNT_CLASS_USER_FROZEN + case domain.AccountClassExternalBlockchain: + return ledgerv1.AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN + case domain.AccountClassTreasury: + return ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY + case domain.AccountClassMarketClearing: + return ledgerv1.AccountClass_ACCOUNT_CLASS_MARKET_CLEARING + case domain.AccountClassIPGClearing: + return ledgerv1.AccountClass_ACCOUNT_CLASS_IPG_CLEARING + case domain.AccountClassCommissionRevenue: + return ledgerv1.AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE + default: + return ledgerv1.AccountClass_ACCOUNT_CLASS_UNSPECIFIED + } +} + +func transactionState(value ledgerv1.TransactionState) domain.TransactionState { + switch value { + case ledgerv1.TransactionState_TRANSACTION_STATE_CREATED: + return domain.TransactionStateCreated + case ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION: + return domain.TransactionStatePendingTransaction + case ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_ADMIN: + return domain.TransactionStatePendingAdmin + case ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL: + return domain.TransactionStateSuccessful + case ledgerv1.TransactionState_TRANSACTION_STATE_FAILED: + return domain.TransactionStateFailed + case ledgerv1.TransactionState_TRANSACTION_STATE_SUSPENDED: + return domain.TransactionStateSuspended + default: + return "" + } +} + +func transactionStateMessage(value domain.TransactionState) ledgerv1.TransactionState { + switch value { + case domain.TransactionStateCreated: + return ledgerv1.TransactionState_TRANSACTION_STATE_CREATED + case domain.TransactionStatePendingTransaction: + return ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION + case domain.TransactionStatePendingAdmin: + return ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_ADMIN + case domain.TransactionStateSuccessful: + return ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL + case domain.TransactionStateFailed: + return ledgerv1.TransactionState_TRANSACTION_STATE_FAILED + case domain.TransactionStateSuspended: + return ledgerv1.TransactionState_TRANSACTION_STATE_SUSPENDED + default: + return ledgerv1.TransactionState_TRANSACTION_STATE_UNSPECIFIED + } +} + +func blockchainReference(value *ledgerv1.BlockchainReference) domain.BlockchainReference { + if value == nil { + return domain.BlockchainReference{} + } + return domain.BlockchainReference{ + Network: value.GetNetwork(), + TransactionHash: value.GetTransactionHash(), + LedgerSequence: value.GetLedgerSequence(), + } +} + +func journalMessage(value domain.Journal) *ledgerv1.Journal { + message := &ledgerv1.Journal{ + JournalId: value.ID, + SourceService: value.SourceService, + IdempotencyKey: value.IdempotencyKey, + SourceTransactionId: value.SourceTransactionID, + TrackingCode: value.TrackingCode, + EffectKind: value.EffectKind, + EventVersion: value.EventVersion, + OccurredAt: timestampMessage(value.OccurredAt), + RecordedAt: timestampMessage(value.RecordedAt), + CorrelationId: value.CorrelationID, + ActorId: value.ActorID, + Blockchain: blockchainMessage(value.Blockchain), + Metadata: value.Metadata, + PayloadHash: value.PayloadHash, + Entries: make([]*ledgerv1.JournalEntry, 0, len(value.Entries)), + } + if value.ReversalOfJournalID != "" { + message.ReversalOfJournalId = &value.ReversalOfJournalID + } + for _, entry := range value.Entries { + message.Entries = append(message.Entries, &ledgerv1.JournalEntry{ + LineNumber: entry.LineNumber, + Account: accountMessage(entry.Account), + Amount: entry.Amount.String(), + Description: entry.Description, + }) + } + return message +} + +func eventMessage(value domain.TransactionEvent) *ledgerv1.TransactionEvent { + return &ledgerv1.TransactionEvent{ + EventId: value.ID, + Event: &ledgerv1.AppendTransactionEventRequest{ + SourceService: value.SourceService, + IdempotencyKey: value.IdempotencyKey, + SourceTransactionId: value.SourceTransactionID, + TrackingCode: value.TrackingCode, + EventVersion: value.EventVersion, + State: transactionStateMessage(value.State), + ErrorCode: value.ErrorCode, + ErrorMessage: value.ErrorMessage, + OccurredAt: timestampMessage(value.OccurredAt), + CorrelationId: value.CorrelationID, + ActorId: value.ActorID, + Blockchain: blockchainMessage(value.Blockchain), + Metadata: value.Metadata, + }, + RecordedAt: timestampMessage(value.RecordedAt), + PayloadHash: value.PayloadHash, + } +} + +func accountMessage(value domain.AccountReference) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: accountClassMessage(value.Class), + OwnerType: value.OwnerType, + OwnerId: value.OwnerID, + AssetId: value.AssetID, + } +} + +func blockchainMessage(value domain.BlockchainReference) *ledgerv1.BlockchainReference { + return &ledgerv1.BlockchainReference{ + Network: value.Network, + TransactionHash: value.TransactionHash, + LedgerSequence: value.LedgerSequence, + } +} + +func timestampMessage(value time.Time) *timestamppb.Timestamp { + if value.IsZero() { + return nil + } + return timestamppb.New(value) +} + +func rpcError(err error) error { + switch { + case errors.Is(err, applicationledger.ErrInvalidArgument): + return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, domain.ErrNotFound): + return status.Error(codes.NotFound, err.Error()) + case errors.Is(err, domain.ErrIdempotencyConflict): + return status.Error(codes.AlreadyExists, err.Error()) + case errors.Is(err, domain.ErrIncompleteJournal): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, domain.ErrAlreadyReversed): + return status.Error(codes.FailedPrecondition, err.Error()) + case errors.Is(err, context.Canceled): + return status.Error(codes.Canceled, err.Error()) + case errors.Is(err, context.DeadlineExceeded): + return status.Error(codes.DeadlineExceeded, err.Error()) + default: + return status.Error(codes.Internal, "internal ledger error") + } +} diff --git a/interface/grpc/ledger_test.go b/interface/grpc/ledger_test.go new file mode 100644 index 0000000..6cec019 --- /dev/null +++ b/interface/grpc/ledger_test.go @@ -0,0 +1,123 @@ +package grpcadapter + +import ( + "context" + "errors" + "testing" + "time" + + applicationledger "gl/application/ledger" + domain "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type ledgerRepositoryStub struct { + journal domain.Journal +} + +func (r *ledgerRepositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) { + journal.RecordedAt = time.Unix(2, 0).UTC() + r.journal = journal + return domain.AppendResult{JournalID: journal.ID}, nil +} + +func (r *ledgerRepositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) { + event.RecordedAt = time.Unix(2, 0).UTC() + return event, false, nil +} + +func (r *ledgerRepositoryStub) GetByID(context.Context, string) (domain.Journal, error) { + return r.journal, nil +} + +func (r *ledgerRepositoryStub) GetByIdempotencyKey(context.Context, string) (domain.Journal, error) { + return r.journal, nil +} + +func (r *ledgerRepositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) { + return []domain.Journal{r.journal}, nil +} + +func (r *ledgerRepositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) { + return domain.ParseAmount("12.5") +} + +func TestAppendJournalMapsProtoToExactDomainAndBack(t *testing.T) { + repository := &ledgerRepositoryStub{} + service := applicationledger.NewService(repository, func() (string, error) { + return "11111111-1111-4111-8111-111111111111", nil + }) + handler := NewHandler(nil, service) + + response, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{ + SourceService: "wallet", + IdempotencyKey: "wallet:1:internal-transfer:v1", + SourceTransactionId: "1", + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: timestamppb.New(time.Unix(1, 0).UTC()), + Entries: []*ledgerv1.JournalEntry{ + { + LineNumber: 1, + Account: &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: "1", + AssetId: 5, + }, + Amount: "-1.2500", + }, + { + LineNumber: 2, + Account: &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: "2", + AssetId: 5, + }, + Amount: "1.25", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + if got := response.GetJournal().GetEntries()[0].GetAmount(); got != "-1.25" { + t.Fatalf("unexpected canonical amount: %q", got) + } + if response.GetJournal().GetPayloadHash() == "" { + t.Fatal("payload hash is missing") + } +} + +func TestAppendJournalRejectsMissingTimestamp(t *testing.T) { + handler := NewHandler(nil, nil) + _, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument, got %v", err) + } +} + +func TestRPCErrorMapping(t *testing.T) { + for _, testCase := range []struct { + err error + code codes.Code + }{ + {err: applicationledger.ErrInvalidArgument, code: codes.InvalidArgument}, + {err: domain.ErrNotFound, code: codes.NotFound}, + {err: domain.ErrIdempotencyConflict, code: codes.AlreadyExists}, + {err: domain.ErrIncompleteJournal, code: codes.FailedPrecondition}, + {err: domain.ErrAlreadyReversed, code: codes.FailedPrecondition}, + {err: context.Canceled, code: codes.Canceled}, + {err: context.DeadlineExceeded, code: codes.DeadlineExceeded}, + {err: errors.New("database details must not escape"), code: codes.Internal}, + } { + if got := status.Code(rpcError(testCase.err)); got != testCase.code { + t.Fatalf("rpcError(%v) = %v, want %v", testCase.err, got, testCase.code) + } + } +} From 8ba5d6cef87623a49978492a78217427cc26cfc54c96a1bdc3e5ca93665062a7 Mon Sep 17 00:00:00 2001 From: nfel Date: Fri, 14 Aug 2026 19:15:36 +0330 Subject: [PATCH 05/20] fix(gl): avoid internal wallet port collision --- gl.cfg.toml | 2 +- infrastructure/config/config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gl.cfg.toml b/gl.cfg.toml index c6d5d29..4da7515 100644 --- a/gl.cfg.toml +++ b/gl.cfg.toml @@ -2,7 +2,7 @@ environment = "local" [grpc] host = "0.0.0.0" -port = 8500 +port = 8600 shutdown-timeout = "10s" [database] diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index ab96ffd..4468b4a 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -36,7 +36,7 @@ func Load(path string) (*Config, error) { Environment: "local", GRPC: GRPCConfig{ Host: "0.0.0.0", - Port: 8500, + Port: 8600, ShutdownTimeout: 10 * time.Second, }, Database: DatabaseConfig{ From ce5f8b4a843c8ba6bd2ff414b218d422cbc23955463922df1312d0c89646b0ad Mon Sep 17 00:00:00 2001 From: nfel Date: Fri, 14 Aug 2026 23:15:07 +0330 Subject: [PATCH 06/20] build(gl): require Go 1.26 --- build/Dockerfile | 36 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 build/Dockerfile diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..58b9b55 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26-bookworm AS builder + +WORKDIR /src + +ARG GO_PROXY=https://go.reg.darano.ir +ENV GOPROXY="$GO_PROXY" +ENV CGO_ENABLED=0 + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ + go mod download + +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ + --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ + go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl + +FROM alpine:3.24.1 + +RUN apk add --no-cache ca-certificates \ + && addgroup -S darano \ + && adduser -S -G darano darano + +WORKDIR /app + +COPY --from=builder /out/gl /app/gl +COPY gl.cfg.toml /app/gl.cfg.toml + +USER darano + +EXPOSE 8600 + +ENTRYPOINT ["/app/gl"] +CMD ["-conf", "/app/gl.cfg.toml"] diff --git a/go.mod b/go.mod index 05f8c38..de227f6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gl -go 1.24 +go 1.26 require ( github.com/jackc/pgx/v5 v5.4.3 From 5b4cb5a2a3284b2c63725a01982b6b9a3b976b59c0d1755dc7cc95edc0b7d5ee Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 15 Aug 2026 00:47:56 +0330 Subject: [PATCH 07/20] feat: add localized ledger explorer dashboard --- .air.toml | 29 + .gitignore | 4 +- Makefile | 12 +- README.md | 42 +- application/explorer/service.go | 255 ++ application/explorer/service_test.go | 131 + cmd/dashboard-loadtest/main.go | 165 + cmd/dashboard-loadtest/main_test.go | 68 + cmd/dashboard/main.go | 54 + cmd/gl/main.go | 3 +- dashboard.cfg.toml | 15 + domain/ledger/journal.go | 2 + gl.cfg.toml | 2 +- go.mod | 23 +- go.sum | 49 +- infrastructure/config/config.go | 91 +- infrastructure/config/config_test.go | 20 + infrastructure/postgres/migration_test.go | 13 + .../000002_explorer_filters.down.sql | 2 + .../migrations/000002_explorer_filters.up.sql | 7 + infrastructure/postgres/query_repository.go | 113 +- interface/web/assets.go | 16 + interface/web/assets/favicon.ico | Bin 0 -> 15086 bytes interface/web/handler.go | 317 ++ interface/web/handler_test.go | 284 ++ interface/web/i18n.go | 286 ++ interface/web/i18n_test.go | 23 + interface/web/server.go | 47 + interface/web/templates.templ | 421 +++ interface/web/templates_templ.go | 2983 +++++++++++++++++ scripts/air-run.sh | 23 + 31 files changed, 5458 insertions(+), 42 deletions(-) create mode 100644 .air.toml create mode 100644 application/explorer/service.go create mode 100644 application/explorer/service_test.go create mode 100644 cmd/dashboard-loadtest/main.go create mode 100644 cmd/dashboard-loadtest/main_test.go create mode 100644 cmd/dashboard/main.go create mode 100644 dashboard.cfg.toml create mode 100644 infrastructure/postgres/migrations/000002_explorer_filters.down.sql create mode 100644 infrastructure/postgres/migrations/000002_explorer_filters.up.sql create mode 100644 interface/web/assets.go create mode 100644 interface/web/assets/favicon.ico create mode 100644 interface/web/handler.go create mode 100644 interface/web/handler_test.go create mode 100644 interface/web/i18n.go create mode 100644 interface/web/i18n_test.go create mode 100644 interface/web/server.go create mode 100644 interface/web/templates.templ create mode 100644 interface/web/templates_templ.go create mode 100644 scripts/air-run.sh diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..2a48a7e --- /dev/null +++ b/.air.toml @@ -0,0 +1,29 @@ +#:schema https://json.schemastore.org/any.json + +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go build -o ./tmp/gl ./cmd/gl && go build -o ./tmp/dashboard ./cmd/dashboard" +entrypoint = ["bash", "./scripts/air-run.sh"] +include_ext = ["go", "templ", "toml"] +exclude_dir = ["tmp", "vendor", ".git"] +exclude_regex = ["_test\\.go$", "_templ\\.go$"] +exclude_unchanged = true +delay = 300 +stop_on_error = true +send_interrupt = true +kill_delay = 1000 + +[log] +time = true + +[misc] +clean_on_exit = true + +[screen] +clear_on_rebuild = false +keep_scroll = true + +[proxy] +enabled = false diff --git a/.gitignore b/.gitignore index c8969f7..a899429 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ go.work go.work.sum +# Air live-reload build artifacts +/tmp/ + # env file .env @@ -240,4 +243,3 @@ cython_debug/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* - diff --git a/Makefile b/Makefile index 0aae601..1072051 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: build generate test +.PHONY: build generate gl-load-test gl-redistribution-load-test load-test test generate: + go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1 build: generate @@ -8,3 +9,12 @@ build: generate test: go test ./... + +load-test: + go run ./cmd/dashboard-loadtest $(LOADTEST_ARGS) + +gl-load-test: + go run ./cmd/gl-loadtest $(GL_LOADTEST_ARGS) + +gl-redistribution-load-test: + go run ./cmd/gl-redistribution-loadtest $(GL_REDISTRIBUTION_LOADTEST_ARGS) diff --git a/README.md b/README.md index 0e473a7..5913013 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,44 @@ make test make build ``` -Run locally with `go run ./cmd/gl -conf ./gl.cfg.toml`. +Run the ledger API and dashboard as separate processes: + +```bash +go run ./cmd/gl -conf ./gl.cfg.toml +go run ./cmd/dashboard -conf ./dashboard.cfg.toml +``` + +For live reload, install [Air](https://github.com/air-verse/air) and run `air` +from the repository root. The checked-in `.air.toml` rebuilds and restarts both +processes together. + +The read-only network explorer is served on `http://localhost:8080` by default: + +- `/` shows network totals and recent committed journals; +- `/assets` ranks the top positive user holders for recently active assets; +- `/holders` combines a user's available and frozen balance and activity for one asset; +- `/transactions` shows paginated recent transactions, filters them by user/wallet and effect type, and finds journals by exact blockchain transaction hash or internal journal ID; +- `/accounts` rebuilds a ledger account balance and activity history. + +The gRPC service owns schema migrations. The dashboard is read-only, uses its +own `dashboard.cfg.toml`, and does not run migrations. + +Run the dashboard load test against a started dashboard with: + +```bash +make load-test LOADTEST_ARGS='-duration 30s -concurrency 50' +``` + +Use `-paths` to exercise known explorer records as well as the overview, for +example `-paths '/,/transactions?q=KNOWN_HASH,/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9'`. + +Run the fixed 10-person, 10,000,000-transfer GL conservation scenario with: + +```bash +make gl-redistribution-load-test +Asset number: 7 +``` + +It starts with one person holding 10,000, uses random transfer values while +protecting a minimum of 45 per person, and verifies that the final ten balances +still sum to exactly 10,000. diff --git a/application/explorer/service.go b/application/explorer/service.go new file mode 100644 index 0000000..d36a946 --- /dev/null +++ b/application/explorer/service.go @@ -0,0 +1,255 @@ +// Package explorer implements the read-only queries used by GL's web explorer. +package explorer + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "gl/domain/ledger" +) + +const ( + recentJournalLimit = 12 + transactionPageSize = 20 + topAssetLimit = 5 + topHoldersPerAsset = 5 +) + +type Stats struct { + JournalCount int64 + EntryCount int64 + AccountCount int64 + LastRecordedAt time.Time +} + +type Holder struct { + Rank int64 + OwnerType string + OwnerID string + AssetID int64 + Balance ledger.Amount +} + +type HolderReference struct { + OwnerType string + OwnerID string + AssetID int64 +} + +type Repository interface { + Stats(context.Context) (Stats, error) + List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error) + GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) + GetByID(context.Context, string) (ledger.Journal, error) + Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) + TopHolders(context.Context, int, int) ([]Holder, error) +} + +type Service struct { + repository Repository +} + +type Dashboard struct { + Stats Stats + Journals []ledger.Journal +} + +type Assets struct { + TopHolders []Holder +} + +type TransactionListing struct { + Journals []ledger.Journal + Filter TransactionFilter + HasPrevious bool + HasNext bool +} + +type TransactionFilter struct { + Page int + Wallet string + EffectKind string +} + +type Account struct { + Reference ledger.AccountReference + Balance ledger.Amount + Journals []ledger.Journal +} + +type HolderAccount struct { + Reference HolderReference + Balance ledger.Amount + Journals []ledger.Journal +} + +func NewService(repository Repository) *Service { + return &Service{repository: repository} +} + +func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) { + stats, err := s.repository.Stats(ctx) + if err != nil { + return Dashboard{}, fmt.Errorf("read explorer stats: %w", err) + } + journals, err := s.repository.List(ctx, ledger.JournalFilter{Limit: recentJournalLimit}) + if err != nil { + return Dashboard{}, fmt.Errorf("read recent journals: %w", err) + } + return Dashboard{Stats: stats, Journals: journals}, nil +} + +func (s *Service) Assets(ctx context.Context) (Assets, error) { + holders, err := s.repository.TopHolders(ctx, topAssetLimit, topHoldersPerAsset) + if err != nil { + return Assets{}, fmt.Errorf("read top asset holders: %w", err) + } + return Assets{TopHolders: holders}, nil +} + +func (s *Service) Transactions(ctx context.Context, filter TransactionFilter) (TransactionListing, error) { + filter.Wallet = strings.TrimSpace(filter.Wallet) + filter.EffectKind = strings.TrimSpace(filter.EffectKind) + if filter.Page < 1 { + filter.Page = 1 + } + if filter.Page > 1_000_000 { + return TransactionListing{}, fmt.Errorf("transaction page is too large") + } + if len(filter.Wallet) > 256 || len(filter.EffectKind) > 128 { + return TransactionListing{}, fmt.Errorf("transaction filter is too long") + } + repositoryFilter := ledger.JournalFilter{ + Limit: transactionPageSize + 1, + Offset: (filter.Page - 1) * transactionPageSize, + } + if filter.Wallet != "" { + repositoryFilter.OwnerID = &filter.Wallet + } + if filter.EffectKind != "" { + repositoryFilter.EffectKind = &filter.EffectKind + } + journals, err := s.repository.List(ctx, repositoryFilter) + if err != nil { + return TransactionListing{}, fmt.Errorf("read transaction page: %w", err) + } + listing := TransactionListing{Journals: journals, Filter: filter, HasPrevious: filter.Page > 1} + if len(listing.Journals) > transactionPageSize { + listing.HasNext = true + listing.Journals = listing.Journals[:transactionPageSize] + } + return listing, nil +} + +func (s *Service) Transaction(ctx context.Context, reference string) ([]ledger.Journal, error) { + reference = strings.TrimSpace(reference) + if reference == "" { + return nil, fmt.Errorf("transaction reference is required") + } + if len(reference) > 256 { + return nil, fmt.Errorf("transaction reference is too long") + } + journals, err := s.repository.GetByTransactionHash(ctx, reference) + if err != nil { + return nil, fmt.Errorf("read transaction: %w", err) + } + if len(journals) != 0 { + return journals, nil + } + if !isJournalID(reference) { + return nil, ledger.ErrNotFound + } + journal, err := s.repository.GetByID(ctx, reference) + if err != nil { + if errors.Is(err, ledger.ErrNotFound) { + return nil, ledger.ErrNotFound + } + return nil, fmt.Errorf("read internal transaction: %w", err) + } + return []ledger.Journal{journal}, nil +} + +func isJournalID(value string) bool { + if len(value) != 36 { + return false + } + for index, character := range value { + switch index { + case 8, 13, 18, 23: + if character != '-' { + return false + } + default: + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) { + return false + } + } + } + return true +} + +func (s *Service) Account(ctx context.Context, reference ledger.AccountReference) (Account, error) { + if err := reference.Validate(); err != nil { + return Account{}, fmt.Errorf("invalid account: %w", err) + } + balance, err := s.repository.Balance(ctx, reference, time.Time{}) + if err != nil { + return Account{}, fmt.Errorf("read account balance: %w", err) + } + journals, err := s.repository.List(ctx, ledger.JournalFilter{ + Account: &reference, + AssetID: &reference.AssetID, + Limit: 50, + }) + if err != nil { + return Account{}, fmt.Errorf("read account journals: %w", err) + } + return Account{Reference: reference, Balance: balance, Journals: journals}, nil +} + +func (s *Service) Holder(ctx context.Context, reference HolderReference) (HolderAccount, error) { + if strings.TrimSpace(reference.OwnerType) == "" || strings.TrimSpace(reference.OwnerID) == "" || reference.AssetID <= 0 { + return HolderAccount{}, fmt.Errorf("invalid holder reference") + } + + result := HolderAccount{Reference: reference} + seen := make(map[string]struct{}) + for _, class := range []ledger.AccountClass{ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen} { + account := ledger.AccountReference{Class: class, OwnerType: reference.OwnerType, OwnerID: reference.OwnerID, AssetID: reference.AssetID} + balance, err := s.repository.Balance(ctx, account, time.Time{}) + if err != nil { + return HolderAccount{}, fmt.Errorf("read holder balance: %w", err) + } + result.Balance = result.Balance.Add(balance) + + journals, err := s.repository.List(ctx, ledger.JournalFilter{Account: &account, AssetID: &reference.AssetID, Limit: 50}) + if err != nil { + return HolderAccount{}, fmt.Errorf("read holder journals: %w", err) + } + for _, journal := range journals { + if _, exists := seen[journal.ID]; exists { + continue + } + seen[journal.ID] = struct{}{} + result.Journals = append(result.Journals, journal) + } + } + sort.Slice(result.Journals, func(left, right int) bool { + if result.Journals[left].RecordedAt.Equal(result.Journals[right].RecordedAt) { + return result.Journals[left].ID > result.Journals[right].ID + } + return result.Journals[left].RecordedAt.After(result.Journals[right].RecordedAt) + }) + if len(result.Journals) > 50 { + result.Journals = result.Journals[:50] + } + return result, nil +} + +func IsNotFound(err error) bool { + return errors.Is(err, ledger.ErrNotFound) +} diff --git a/application/explorer/service_test.go b/application/explorer/service_test.go new file mode 100644 index 0000000..da7d873 --- /dev/null +++ b/application/explorer/service_test.go @@ -0,0 +1,131 @@ +package explorer + +import ( + "context" + "fmt" + "testing" + "time" + + "gl/domain/ledger" +) + +type repositoryStub struct { + stats Stats + journals []ledger.Journal + transactions []ledger.Journal + journal ledger.Journal + journalErr error + holders []Holder + balance ledger.Amount + lastFilter *ledger.JournalFilter +} + +func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil } +func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) { + if r.lastFilter != nil { + *r.lastFilter = filter + } + return r.journals, nil +} +func (r repositoryStub) GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) { + return r.transactions, nil +} +func (r repositoryStub) GetByID(context.Context, string) (ledger.Journal, error) { + return r.journal, r.journalErr +} +func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) { + return r.balance, nil +} +func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) { + return r.holders, nil +} + +func TestAssetsIncludesTopHolders(t *testing.T) { + balance, err := ledger.ParseAmount("125.5") + if err != nil { + t.Fatal(err) + } + service := NewService(repositoryStub{holders: []Holder{{Rank: 1, OwnerType: "user", OwnerID: "42", AssetID: 7, Balance: balance}}}) + + result, err := service.Assets(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result.TopHolders) != 1 || result.TopHolders[0].Balance.String() != "125.5" { + t.Fatalf("unexpected top holders: %+v", result.TopHolders) + } +} + +func TestTransactionsPaginatesWithLookahead(t *testing.T) { + journals := make([]ledger.Journal, transactionPageSize+1) + for index := range journals { + journals[index].ID = fmt.Sprintf("journal-%d", index) + } + var repositoryFilter ledger.JournalFilter + service := NewService(repositoryStub{journals: journals, lastFilter: &repositoryFilter}) + + result, err := service.Transactions(context.Background(), TransactionFilter{Page: 2, Wallet: " wallet-42 ", EffectKind: " transfer "}) + if err != nil { + t.Fatal(err) + } + if result.Filter.Page != 2 || result.Filter.Wallet != "wallet-42" || result.Filter.EffectKind != "transfer" || !result.HasPrevious || !result.HasNext || len(result.Journals) != transactionPageSize { + t.Fatalf("unexpected transaction page: %+v", result) + } + if repositoryFilter.OwnerID == nil || *repositoryFilter.OwnerID != "wallet-42" || repositoryFilter.EffectKind == nil || *repositoryFilter.EffectKind != "transfer" || repositoryFilter.Offset != transactionPageSize { + t.Fatalf("unexpected repository filter: %+v", repositoryFilter) + } +} + +func TestHolderCombinesAvailableAndFrozenBalances(t *testing.T) { + balance, err := ledger.ParseAmount("10.25") + if err != nil { + t.Fatal(err) + } + service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}}) + + result, err := service.Holder(context.Background(), HolderReference{OwnerType: "user", OwnerID: "42", AssetID: 7}) + if err != nil { + t.Fatal(err) + } + if result.Balance.String() != "20.5" || len(result.Journals) != 1 { + t.Fatalf("unexpected aggregate holder account: %+v", result) + } +} + +func TestTransactionRequiresAResult(t *testing.T) { + service := NewService(repositoryStub{}) + if _, err := service.Transaction(context.Background(), "hash"); !IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestTransactionFallsBackToInternalJournalID(t *testing.T) { + const journalID = "5c1e31b0-0000-4000-8000-0000084accc8" + service := NewService(repositoryStub{journal: ledger.Journal{ID: journalID}}) + + result, err := service.Transaction(context.Background(), journalID) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 || result[0].ID != journalID { + t.Fatalf("unexpected internal transaction result: %+v", result) + } +} + +func TestAccountReturnsBalanceAndHistory(t *testing.T) { + balance, err := ledger.ParseAmount("12.5") + if err != nil { + t.Fatal(err) + } + reference := ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "42", AssetID: 7, + } + service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}}) + result, err := service.Account(context.Background(), reference) + if err != nil { + t.Fatal(err) + } + if result.Balance.String() != "12.5" || len(result.Journals) != 1 { + t.Fatalf("unexpected account result: %+v", result) + } +} diff --git a/cmd/dashboard-loadtest/main.go b/cmd/dashboard-loadtest/main.go new file mode 100644 index 0000000..c6fd75c --- /dev/null +++ b/cmd/dashboard-loadtest/main.go @@ -0,0 +1,165 @@ +// Command dashboard-loadtest runs a concurrent read-only HTTP load test against +// a deployed GL dashboard. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/signal" + "sort" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +type result struct { + Duration time.Duration + Requests int64 + Errors int64 + Non2xx int64 + Latency []time.Duration +} + +func main() { + baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL") + paths := flag.String("paths", "/", "comma-separated request paths") + concurrency := flag.Int("concurrency", 20, "number of concurrent workers") + duration := flag.Duration("duration", 15*time.Second, "test duration") + requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request") + flag.Parse() + + if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 { + fmt.Fprintln(os.Stderr, "concurrency and durations must be positive") + os.Exit(2) + } + targets, err := targets(*baseURL, *paths) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = *concurrency + transport.MaxIdleConnsPerHost = *concurrency + client := &http.Client{Transport: transport, Timeout: *requestTimeout} + defer transport.CloseIdleConnections() + + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(signalCtx, *duration) + defer cancel() + + fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL) + started := time.Now() + result := run(ctx, client, targets, *concurrency) + result.Duration = time.Since(started) + printResult(result) + if result.Errors > 0 || result.Non2xx > 0 { + os.Exit(1) + } +} + +func targets(baseURL, pathList string) ([]string, error) { + base, err := url.Parse(baseURL) + if err != nil || base.Scheme == "" || base.Host == "" { + return nil, fmt.Errorf("invalid dashboard URL %q", baseURL) + } + paths := strings.Split(pathList, ",") + result := make([]string, 0, len(paths)) + for _, value := range paths { + value = strings.TrimSpace(value) + if value == "" { + continue + } + reference, parseErr := url.Parse(value) + if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") { + return nil, fmt.Errorf("invalid request path %q", value) + } + result = append(result, base.ResolveReference(reference).String()) + } + if len(result) == 0 { + return nil, errors.New("at least one request path is required") + } + return result, nil +} + +func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result { + var ( + requests int64 + failures int64 + non2xx int64 + sequence uint64 + latency = make([]time.Duration, 0, concurrency*100) + lock sync.Mutex + workers sync.WaitGroup + ) + workers.Add(concurrency) + for range concurrency { + go func() { + defer workers.Done() + for ctx.Err() == nil { + target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))] + request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil) + if err != nil { + atomic.AddInt64(&failures, 1) + continue + } + started := time.Now() + response, err := client.Do(request) + elapsed := time.Since(started) + if err != nil { + atomic.AddInt64(&requests, 1) + atomic.AddInt64(&failures, 1) + continue + } + _, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20)) + closeErr := response.Body.Close() + atomic.AddInt64(&requests, 1) + if response.StatusCode < 200 || response.StatusCode >= 300 { + atomic.AddInt64(&non2xx, 1) + } + if copyErr != nil || closeErr != nil { + atomic.AddInt64(&failures, 1) + } + lock.Lock() + latency = append(latency, elapsed) + lock.Unlock() + } + }() + } + workers.Wait() + return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency} +} + +func printResult(value result) { + seconds := value.Duration.Seconds() + requestsPerSecond := float64(value.Requests) + if seconds > 0 { + requestsPerSecond /= seconds + } + fmt.Printf("requests: %d\n", value.Requests) + fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond) + fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99)) + fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx) +} + +func percentile(values []time.Duration, percent int) time.Duration { + if len(values) == 0 { + return 0 + } + ordered := append([]time.Duration(nil), values...) + sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] }) + index := (len(ordered)*percent + 99) / 100 + if index < 1 { + index = 1 + } + return ordered[index-1] +} diff --git a/cmd/dashboard-loadtest/main_test.go b/cmd/dashboard-loadtest/main_test.go new file mode 100644 index 0000000..12d4d23 --- /dev/null +++ b/cmd/dashboard-loadtest/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusOK) + _, _ = response.Write([]byte("ok")) + })) + defer server.Close() + + requestTargets, err := targets(server.URL, "/,/transactions") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + result := run(ctx, server.Client(), requestTargets, 4) + + if result.Requests == 0 || result.Errors != 0 || result.Non2xx != 0 { + t.Fatalf("unexpected load result: %+v", result) + } + if len(result.Latency) != int(result.Requests) { + t.Fatalf("expected one latency per request, got %d for %d", len(result.Latency), result.Requests) + } +} + +func TestTargetsRejectsExternalAndRelativePaths(t *testing.T) { + for _, paths := range []string{"relative", "https://example.com/"} { + if _, err := targets("http://localhost:8080", paths); err == nil { + t.Fatalf("expected %q to be rejected", paths) + } + } +} + +func TestPercentileUsesNearestRank(t *testing.T) { + values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond} + if got := percentile(values, 95); got != 4*time.Millisecond { + t.Fatalf("unexpected p95: %s", got) + } +} + +func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { + var canceled atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + time.Sleep(20 * time.Millisecond) + if request.Context().Err() != nil { + canceled.Store(true) + } + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + result := run(ctx, server.Client(), []string{server.URL}, 1) + + if result.Requests != 1 || result.Errors != 0 || canceled.Load() { + t.Fatalf("in-flight request was not drained cleanly: %+v", result) + } +} diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go new file mode 100644 index 0000000..0825463 --- /dev/null +++ b/cmd/dashboard/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "gl/application/explorer" + "gl/infrastructure/config" + "gl/infrastructure/postgres" + webadapter "gl/interface/web" +) + +func main() { + configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file") + flag.Parse() + + cfg, err := config.LoadDashboard(*configPath) + if err != nil { + slog.Error("load dashboard configuration", "error", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + slog.Error("open GL database", "error", err) + os.Exit(1) + } + defer database.Close() + if err := database.Ping(ctx); err != nil { + slog.Error("connect to GL database", "error", err) + os.Exit(1) + } + + repository := postgres.NewJournalRepository(database) + handler := webadapter.NewHandler(explorer.NewService(repository)) + serverConfig := webadapter.ServerConfig{ + Host: cfg.HTTP.Host, + Port: cfg.HTTP.Port, + ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout, + ShutdownTimeout: cfg.HTTP.ShutdownTimeout, + } + slog.Info("starting GL explorer", "host", cfg.HTTP.Host, "port", cfg.HTTP.Port) + if err := webadapter.Run(ctx, serverConfig, handler); err != nil { + slog.Error("GL explorer stopped", "error", err) + os.Exit(1) + } +} diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 6d4c5af..8f5eb0a 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -39,7 +39,8 @@ func main() { os.Exit(1) } - handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(postgres.NewJournalRepository(database), nil)) + repository := postgres.NewJournalRepository(database) + handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil)) slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, diff --git a/dashboard.cfg.toml b/dashboard.cfg.toml new file mode 100644 index 0000000..dda9ac5 --- /dev/null +++ b/dashboard.cfg.toml @@ -0,0 +1,15 @@ +environment = "local" + +[http] +host = "0.0.0.0" +port = 8080 +read-header-timeout = "5s" +shutdown-timeout = "10s" + +[database] +host = "127.0.0.1" +port = 5432 +name = "gl_db" +user = "postgres" +password = "postgres" +ssl-mode = "disable" diff --git a/domain/ledger/journal.go b/domain/ledger/journal.go index c885cef..505b19f 100644 --- a/domain/ledger/journal.go +++ b/domain/ledger/journal.go @@ -96,6 +96,8 @@ type AppendResult struct { type JournalFilter struct { Account *AccountReference AssetID *int64 + OwnerID *string + EffectKind *string RecordedFrom *time.Time RecordedTo *time.Time Limit int diff --git a/gl.cfg.toml b/gl.cfg.toml index 4da7515..3c8ae30 100644 --- a/gl.cfg.toml +++ b/gl.cfg.toml @@ -10,5 +10,5 @@ host = "127.0.0.1" port = 5432 name = "gl_db" user = "postgres" -password = "" +password = "postgres" ssl-mode = "disable" diff --git a/go.mod b/go.mod index de227f6..a6d5cab 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gl go 1.26 require ( + github.com/a-h/templ v0.3.1020 github.com/jackc/pgx/v5 v5.4.3 github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v1.2.1 @@ -12,19 +13,31 @@ 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.26.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.17.0 // 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 ) + +tool github.com/a-h/templ diff --git a/go.sum b/go.sum index 3dce6d6..fac2ebf 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,18 @@ +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= @@ -23,10 +35,17 @@ 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= @@ -34,18 +53,24 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +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= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index 4468b4a..ad430e9 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -16,12 +16,25 @@ type Config struct { Database DatabaseConfig `koanf:"database"` } +type DashboardConfig struct { + Environment string `koanf:"environment"` + HTTP HTTPConfig `koanf:"http"` + Database DatabaseConfig `koanf:"database"` +} + type GRPCConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` } +type HTTPConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadHeaderTimeout time.Duration `koanf:"read-header-timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` +} + type DatabaseConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` @@ -39,21 +52,11 @@ func Load(path string) (*Config, error) { Port: 8600, ShutdownTimeout: 10 * time.Second, }, - Database: DatabaseConfig{ - Host: "127.0.0.1", - Port: 5432, - Name: "gl_db", - User: "postgres", - SSLMode: "disable", - }, + Database: defaultDatabaseConfig(), } - k := koanf.New(".") - if err := k.Load(file.Provider(path), toml.Parser()); err != nil { - return nil, fmt.Errorf("load config: %w", err) - } - if err := k.Unmarshal("", cfg); err != nil { - return nil, fmt.Errorf("decode config: %w", err) + if err := load(path, cfg); err != nil { + return nil, err } if err := cfg.Validate(); err != nil { return nil, err @@ -61,6 +64,47 @@ func Load(path string) (*Config, error) { return cfg, nil } +func LoadDashboard(path string) (*DashboardConfig, error) { + cfg := &DashboardConfig{ + Environment: "local", + HTTP: HTTPConfig{ + Host: "0.0.0.0", + Port: 8080, + ReadHeaderTimeout: 5 * time.Second, + ShutdownTimeout: 10 * time.Second, + }, + Database: defaultDatabaseConfig(), + } + if err := load(path, cfg); err != nil { + return nil, err + } + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func load(path string, target any) error { + k := koanf.New(".") + if err := k.Load(file.Provider(path), toml.Parser()); err != nil { + return fmt.Errorf("load config: %w", err) + } + if err := k.Unmarshal("", target); err != nil { + return fmt.Errorf("decode config: %w", err) + } + return nil +} + +func defaultDatabaseConfig() DatabaseConfig { + return DatabaseConfig{ + Host: "127.0.0.1", + Port: 5432, + Name: "gl_db", + User: "postgres", + SSLMode: "disable", + } +} + func (c *Config) Validate() error { if c.GRPC.Host == "" { return fmt.Errorf("grpc host is required") @@ -71,10 +115,27 @@ func (c *Config) Validate() error { if c.GRPC.ShutdownTimeout <= 0 { return fmt.Errorf("grpc shutdown timeout must be positive") } - if c.Database.Host == "" || c.Database.Name == "" || c.Database.User == "" { + return validateDatabase(c.Database) +} + +func (c *DashboardConfig) Validate() error { + if c.HTTP.Host == "" { + return fmt.Errorf("http host is required") + } + if c.HTTP.Port < 0 || c.HTTP.Port > 65535 { + return fmt.Errorf("http port must be between 0 and 65535") + } + if c.HTTP.ReadHeaderTimeout <= 0 || c.HTTP.ShutdownTimeout <= 0 { + return fmt.Errorf("http timeouts must be positive") + } + return validateDatabase(c.Database) +} + +func validateDatabase(database DatabaseConfig) error { + if database.Host == "" || database.Name == "" || database.User == "" { return fmt.Errorf("database host, name, and user are required") } - if c.Database.Port < 1 || c.Database.Port > 65535 { + if database.Port < 1 || database.Port > 65535 { return fmt.Errorf("database port must be between 1 and 65535") } return nil diff --git a/infrastructure/config/config_test.go b/infrastructure/config/config_test.go index 4d8b1e0..8deb720 100644 --- a/infrastructure/config/config_test.go +++ b/infrastructure/config/config_test.go @@ -27,6 +27,26 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) { } } +func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dashboard.toml") + contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadDashboard(path) + if err != nil { + t.Fatal(err) + } + if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second { + t.Fatalf("unexpected http config: %+v", cfg.HTTP) + } + if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 { + t.Fatalf("unexpected database defaults: %+v", cfg.Database) + } +} + func TestLoadRejectsInvalidConfiguration(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "gl.toml") diff --git a/infrastructure/postgres/migration_test.go b/infrastructure/postgres/migration_test.go index ea3a977..9293b1d 100644 --- a/infrastructure/postgres/migration_test.go +++ b/infrastructure/postgres/migration_test.go @@ -25,3 +25,16 @@ func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) { } } } + +func TestExplorerFilterMigrationAddsSupportingIndexes(t *testing.T) { + contents, err := migrationFiles.ReadFile("migrations/000002_explorer_filters.up.sql") + if err != nil { + t.Fatal(err) + } + schema := string(contents) + for _, required := range []string{"journals_effect_recorded_idx", "lower(effect_kind)", "ledger_accounts_user_owner_idx", "USER_AVAILABLE", "USER_FROZEN"} { + if !strings.Contains(schema, required) { + t.Fatalf("explorer filter migration is missing %q", required) + } + } +} diff --git a/infrastructure/postgres/migrations/000002_explorer_filters.down.sql b/infrastructure/postgres/migrations/000002_explorer_filters.down.sql new file mode 100644 index 0000000..1c77993 --- /dev/null +++ b/infrastructure/postgres/migrations/000002_explorer_filters.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS ledger_accounts_user_owner_idx; +DROP INDEX IF EXISTS journals_effect_recorded_idx; diff --git a/infrastructure/postgres/migrations/000002_explorer_filters.up.sql b/infrastructure/postgres/migrations/000002_explorer_filters.up.sql new file mode 100644 index 0000000..7f71d4e --- /dev/null +++ b/infrastructure/postgres/migrations/000002_explorer_filters.up.sql @@ -0,0 +1,7 @@ +CREATE INDEX journals_effect_recorded_idx + ON journals (lower(effect_kind), recorded_at DESC, id DESC) + WHERE sealed_at IS NOT NULL; + +CREATE INDEX ledger_accounts_user_owner_idx + ON ledger_accounts (owner_id, id) + WHERE class IN ('USER_AVAILABLE', 'USER_FROZEN'); diff --git a/infrastructure/postgres/query_repository.go b/infrastructure/postgres/query_repository.go index 50a01e6..d090006 100644 --- a/infrastructure/postgres/query_repository.go +++ b/infrastructure/postgres/query_repository.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "gl/application/explorer" "gl/domain/ledger" "github.com/jackc/pgx/v5" @@ -54,22 +55,77 @@ func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]l ownerType = filter.Account.OwnerType ownerID = filter.Account.OwnerID } - rows, err := r.database.Query(ctx, listJournalsSQL, + return r.queryJournals(ctx, listJournalsSQL, filter.AssetID, accountClass, ownerType, ownerID, + filter.OwnerID, + filter.EffectKind, filter.RecordedFrom, filter.RecordedTo, limit, filter.Offset, ) +} + +func (r *JournalRepository) GetByTransactionHash(ctx context.Context, hash string) ([]ledger.Journal, error) { + return r.queryJournals(ctx, getJournalsByTransactionHashSQL, hash) +} + +func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) { + var stats explorer.Stats + if err := r.database.QueryRow(ctx, getExplorerStatsSQL).Scan( + &stats.JournalCount, + &stats.EntryCount, + &stats.AccountCount, + &stats.LastRecordedAt, + ); err != nil { + return explorer.Stats{}, fmt.Errorf("read explorer stats: %w", err) + } + return stats, nil +} + +func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) { + if assetLimit <= 0 || assetLimit > 20 { + assetLimit = 5 + } + if holderLimit <= 0 || holderLimit > 20 { + holderLimit = 5 + } + rows, err := r.database.Query(ctx, topHoldersSQL, assetLimit, holderLimit) + if err != nil { + return nil, fmt.Errorf("list top holders: %w", err) + } + defer rows.Close() + + holders := make([]explorer.Holder, 0, assetLimit*holderLimit) + for rows.Next() { + var holder explorer.Holder + var balance string + if err := rows.Scan(&holder.Rank, &holder.OwnerType, &holder.OwnerID, &holder.AssetID, &balance); err != nil { + return nil, fmt.Errorf("scan top holder: %w", err) + } + holder.Balance, err = ledger.ParseAmount(balance) + if err != nil { + return nil, fmt.Errorf("decode top holder balance: %w", err) + } + holders = append(holders, holder) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate top holders: %w", err) + } + return holders, nil +} + +func (r *JournalRepository) queryJournals(ctx context.Context, query string, args ...any) ([]ledger.Journal, error) { + rows, err := r.database.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("list journals: %w", err) } defer rows.Close() - journals := make([]ledger.Journal, 0, limit) + journals := make([]ledger.Journal, 0) for rows.Next() { journal, scanErr := scanJournal(rows) if scanErr != nil { @@ -206,6 +262,42 @@ FROM journals j WHERE j.id = $1 AND j.sealed_at IS NOT NULL` const getJournalByIdempotencySQL = `SELECT ` + journalColumns + ` FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL` +const getJournalsByTransactionHashSQL = `SELECT ` + journalColumns + ` +FROM journals j +WHERE j.blockchain_transaction_hash = $1 AND j.sealed_at IS NOT NULL +ORDER BY j.recorded_at DESC, j.id DESC` + +const topHoldersSQL = `WITH recent_assets AS ( + SELECT e.asset_id, MAX(j.recorded_at) AS last_activity + FROM journal_entries e + JOIN journals j ON j.id = e.journal_id + WHERE j.sealed_at IS NOT NULL + GROUP BY e.asset_id + ORDER BY last_activity DESC, e.asset_id + LIMIT $1 +), holder_balances AS ( + SELECT a.owner_type, a.owner_id, e.asset_id, SUM(e.amount) AS balance + FROM journal_entries e + JOIN journals j ON j.id = e.journal_id AND j.sealed_at IS NOT NULL + JOIN ledger_accounts a ON a.id = e.account_id + JOIN recent_assets ra ON ra.asset_id = e.asset_id + WHERE a.class IN ('USER_AVAILABLE', 'USER_FROZEN') + GROUP BY a.owner_type, a.owner_id, e.asset_id + HAVING SUM(e.amount) > 0 +), ranked_holders AS ( + SELECT owner_type, owner_id, asset_id, balance, + ROW_NUMBER() OVER ( + PARTITION BY asset_id + ORDER BY balance DESC, owner_type, owner_id + ) AS holder_rank + FROM holder_balances +) +SELECT rh.holder_rank, rh.owner_type, rh.owner_id, rh.asset_id, rh.balance::text +FROM ranked_holders rh +JOIN recent_assets ra ON ra.asset_id = rh.asset_id +WHERE rh.holder_rank <= $2 +ORDER BY ra.last_activity DESC, rh.asset_id, rh.holder_rank` + const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + ` FROM journals j JOIN journal_entries e ON e.journal_id = j.id @@ -215,10 +307,14 @@ WHERE j.sealed_at IS NOT NULL AND ($2::text IS NULL OR ( a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4 )) - AND ($5::timestamptz IS NULL OR j.recorded_at >= $5) - AND ($6::timestamptz IS NULL OR j.recorded_at <= $6) + AND ($5::text IS NULL OR ( + a.class IN ('USER_AVAILABLE', 'USER_FROZEN') AND a.owner_id = $5 + )) + AND ($6::text IS NULL OR lower(j.effect_kind) = lower($6)) + AND ($7::timestamptz IS NULL OR j.recorded_at >= $7) + AND ($8::timestamptz IS NULL OR j.recorded_at <= $8) ORDER BY j.recorded_at DESC, j.id DESC -LIMIT $7 OFFSET $8` +LIMIT $9 OFFSET $10` const getEntriesSQL = ` SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id, @@ -237,3 +333,10 @@ WHERE j.sealed_at IS NOT NULL AND a.class = $1 AND a.owner_type = $2 AND a.owner_id = $3 AND a.asset_id = $4 AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)` + +const getExplorerStatsSQL = ` +SELECT + (SELECT count(*) FROM journals WHERE sealed_at IS NOT NULL), + (SELECT count(*) FROM journal_entries e JOIN journals j ON j.id = e.journal_id WHERE j.sealed_at IS NOT NULL), + (SELECT count(*) FROM ledger_accounts), + COALESCE((SELECT max(recorded_at) FROM journals WHERE sealed_at IS NOT NULL), 'epoch'::timestamptz)` diff --git a/interface/web/assets.go b/interface/web/assets.go new file mode 100644 index 0000000..4dcc58b --- /dev/null +++ b/interface/web/assets.go @@ -0,0 +1,16 @@ +package web + +import ( + "bytes" + _ "embed" + "net/http" + "time" +) + +//go:embed assets/favicon.ico +var favicon []byte + +func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon)) +} diff --git a/interface/web/assets/favicon.ico b/interface/web/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..545e58f39482ecf9e365e84c14ad8cc1ff2317accff4336fb57f9c22708f6b5b GIT binary patch literal 15086 zcmeI3e~^`B702J@8W+R7h$%ngt_en=16opy*gBT^2WkGu)Z#}*I5vV>GbV|#m#DFH zgp_2;h(OU8e`H3fj45{qOG`@!Z9quoGKi8&UZrT!ZQnlM-Fukh!+ZC>cXuhB*>ygi z=f^qcInVPxzutGaQPdD+qCtZqWMg#6K~Xd@ilWBG&irc1ms2)jLM1;Yiav8h6pclP zDl9t75mAKR{(mt=?+89Y9JApp=${bNXv)41D`5^C42r!U+zMp&@eJt^V0}B4^grP# zSOa_DFYrE?1+x7C3lnK97anFdy!N=fLVzeM2=%(kPeFeL+y-BO@cDOT`#-C*QC;7{K2f0A z0Z}w`WE4$iHjNq@MT?30F-C1WBl3DKidyqg)COiZ7L!Ie1++hP{0-_9Z~uQ?V~1&< ze%~0Rngj1)oQ?;rx08X{mO!V1^`&x6)_GcfB@*$+hTd+;h?un+C4cfm1`G>;YE zufW^*w9cC8Rcy^4D>Qz#3m@{QbK`ShHj?j!C&0Itlx{~yXN0%4G`EX*qw}FN_3Q9? z_!j8AF`uoZKL%!dgR7IyDwqg^VLaRfZJ;{EFdNKP@=);c|B!qp&|wGb*mLDW{IODh z7;;agOyz}2x%aQM_G+DP((y6K@57aH)hz>dN0R%aIr%h5Pn-bfy8LF+Vcg!&7peE- za5?#Vo&B9P?B(mLPXAKUSGv5OgKsY>tvP5hSU)5mgrxgf=%Wcevztd+eX#u1q(l9r z{7m%z%d~r+`O=ctzuR?;)(0Z!YW>|g&x;}<#<8U5&Hg9y7YYf`Bk-WwB zDCt9j`mkM}zslLFjWYt<)5+`JCjYO2)}6OkT5EO*SPVtd+80B;YI)sr&2}egi+>Vn zy%VTTd+$k5%|{r+T6DF)RMYq6T6^2UV$eF*U3XWdOndHW;BAu9VGL`qNwcoyYdKi^ z>q);*$!l(&4N2`Kl_`dsE4K1?4;%zZy1wiNXJc__pO~J)*85R9jNuoQWor1*9%$`K zYF*5!VH39N^I;6D@Kr5_anxy?ef#r!(6+@9#&8Ed!@YJiWzPj|>kKv@J(Y^Vugw?9 z{|K&u)!@%>^0hIhcD;E2Y18N8-`}$=291gK;%f6Ft^YK7 z8jE8=d-?_Naj3RFd;2-0iLXzmcjx{A8_#&ZzqWiiU(ObDrA#4PY$#+Fv}9W{rEDo% z&gs9_d^v|owmr9PVVrGgi5D&`#x2=Wb1_$LD&<@ArKYxAskuE{jN{CLI4(5AaWNCe zD`}No1S6mxzv}ZD@DOZ=f57ev zyO7)9H*gB{gL-_MuI`evKzF`>f!--_^fpwccgsznKI)r-*(XWGatz!9N&Z6rt?1nZ zdge*~s+A2we=&sbC82+x-;2J!1q_C2es$l}v-978ypN=`z6;zBW1*T~U#`Bcgm8T1 zM|bUKU@IuzusxOC0^xg-{Evh!5c&;$RLg7LYF+qP!?eCVEP*${``JML0?_-K;%J1E zU=@V@&@=lEc;EUSvIe|QrS<;xXP5)y;RA3o=p9gF7Rh=OwUX05f{NrU^bF^P7}d&^$kMbf4t5c-k{#F{o{;ms|v@3;p^0k<{M?;d6M5GV6oJ z@G{VNc>i~i?`kdjcGQRP9eEvPN5C-n6L{axldl!K`5%hz0}%G%LCQV|=YW5<8au7` zwEnFf?GL-a;@m-6{*Q*8u3Yus1hY#cH3oNrpWAw#MR*ykP1WBABOr}mucI~|2lKUu z^hZGbQrgNjjuXM#rb)~H-4M=0^DC*nK;xzLlg6jl(Oh{1%->$p<3M}fZda~7;lto< z)22sZSA?WKY^6NkO}}biD42D?tCf6r7!@rH_Zp_3n~|X zgIfD?t+vPDL;GmhhdU@60=4X|zCXGb2Y$2uDc!20R454;m-+ zAbR&%OMM)em!~JHJ$?VE2%!*4oPQlKguV8-aE`j`kIegwv{xObcehYda@oSW4{x8{2Rz?Zaf^6ZzDeiG@kXwFMai48ThuerY?r= z`W%9-&KK`fcl&3d&6TU{H0W+S)?eBG47Ap)P3^~LL3ex~g55^v%Y1sNJL{QXG4xa( zhA-`{z7J|+D%86kMq;xSe7rvW8g;Xvr+Y=%XZ2xu(5~k3g`hnz)UTHRG`g>VkJ-{% zuRUE~)!J2g(p=K{csiu%gVy~b&{Vwv_wJ)i;^fgGDx9Zmn_&azTOY5vT6l%5GoArum8Td0!^Wt|f9ZrDp z@FjQv{C5_MTV<=E-aVkV`l0jTM(};`u_eW9^=;VQ3qzo{;^~f`9PM8R+B+<^G;$sK zM?u;E z7o#W>7c!laf6Ua8FXcLAxguM$$~VMq8J!A+xV-@vQK31G+SN`|T$rnN^KtPCBvz#f z#Df(f@!PEozw<*9Z#g1>nBV=;HZe4J%3@PFqkJh(!^m>3Q?_OcdF8p~G)Z#1pvjIK YMyD)l1jrXNyE`N;cSsGS%G1gJ0>mI|H~;_u literal 0 HcmV?d00001 diff --git a/interface/web/handler.go b/interface/web/handler.go new file mode 100644 index 0000000..00e4d26 --- /dev/null +++ b/interface/web/handler.go @@ -0,0 +1,317 @@ +package web + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "syscall" + "time" + _ "time/tzdata" + + "gl/application/explorer" + "gl/domain/ledger" + + "github.com/a-h/templ" +) + +type Explorer interface { + Dashboard(context.Context) (explorer.Dashboard, error) + Assets(context.Context) (explorer.Assets, error) + Transactions(context.Context, explorer.TransactionFilter) (explorer.TransactionListing, error) + Transaction(context.Context, string) ([]ledger.Journal, error) + Account(context.Context, ledger.AccountReference) (explorer.Account, error) + Holder(context.Context, explorer.HolderReference) (explorer.HolderAccount, error) +} + +type Handler struct { + explorer Explorer + routes *http.ServeMux +} + +type TransactionPageData struct { + Query string + Journals []ledger.Journal + Listing *explorer.TransactionListing + Error string +} + +type AccountInput struct { + Class string + OwnerType string + OwnerID string + AssetID string +} + +type AccountPageData struct { + Input AccountInput + Account *explorer.Account + Error string +} + +type HolderPageData struct { + Account *explorer.HolderAccount + Error string +} + +func NewHandler(service Explorer) *Handler { + handler := &Handler{explorer: service, routes: http.NewServeMux()} + handler.routes.HandleFunc("GET /favicon.ico", handler.favicon) + handler.routes.HandleFunc("GET /{$}", handler.dashboard) + handler.routes.HandleFunc("GET /assets", handler.assets) + handler.routes.HandleFunc("GET /transactions", handler.transaction) + handler.routes.HandleFunc("GET /transactions/{reference}", handler.transaction) + handler.routes.HandleFunc("GET /accounts", handler.account) + handler.routes.HandleFunc("GET /holders", handler.holder) + return handler +} + +func (h *Handler) assets(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data, err := h.explorer.Assets(request.Context()) + if err != nil { + h.renderFailure(response, request, locale, tr(locale, "assets_unavailable"), err) + return + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_assets"), "assets", AssetsContent(data, locale)) +} + +func (h *Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) { + response.Header().Set("X-Content-Type-Options", "nosniff") + response.Header().Set("Referrer-Policy", "same-origin") + response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'") + h.routes.ServeHTTP(response, request) +} + +func (h *Handler) dashboard(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data, err := h.explorer.Dashboard(request.Context()) + if err != nil { + h.renderFailure(response, request, locale, tr(locale, "dashboard_unavailable"), err) + return + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_overview"), "dashboard", DashboardContent(data, locale)) +} + +func (h *Handler) transaction(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + query := strings.TrimSpace(request.PathValue("reference")) + if query == "" { + query = strings.TrimSpace(request.URL.Query().Get("q")) + } + data := TransactionPageData{Query: query} + showLatest := query == "" + if query != "" { + journals, err := h.explorer.Transaction(request.Context(), query) + switch { + case err == nil: + data.Journals = journals + case requestEnded(request, err): + return + case explorer.IsNotFound(err): + data.Error = tr(locale, "transaction_missing") + showLatest = true + default: + data.Error = tr(locale, "transaction_lookup_error") + } + } + if showLatest { + page, parseErr := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get("page"))) + if parseErr != nil || page < 1 { + page = 1 + } + listing, err := h.explorer.Transactions(request.Context(), explorer.TransactionFilter{ + Page: page, + Wallet: request.URL.Query().Get("wallet"), + EffectKind: request.URL.Query().Get("effect"), + }) + if requestEnded(request, err) { + return + } else if err != nil { + h.renderFailure(response, request, locale, tr(locale, "transactions_unavailable"), err) + return + } + data.Listing = &listing + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_transactions"), "transactions", TransactionContent(data, locale)) +} + +func (h *Handler) account(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data := AccountPageData{Input: AccountInput{ + Class: strings.TrimSpace(request.URL.Query().Get("class")), + OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), + OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), + AssetID: strings.TrimSpace(request.URL.Query().Get("asset_id")), + }} + if request.URL.Query().Has("class") { + assetID, err := strconv.ParseInt(data.Input.AssetID, 10, 64) + if err != nil || assetID <= 0 { + data.Error = tr(locale, "asset_positive") + } else { + result, lookupErr := h.explorer.Account(request.Context(), ledger.AccountReference{ + Class: ledger.AccountClass(data.Input.Class), + OwnerType: data.Input.OwnerType, + OwnerID: data.Input.OwnerID, + AssetID: assetID, + }) + if requestEnded(request, lookupErr) { + return + } else if lookupErr != nil { + data.Error = tr(locale, "account_lookup_error") + } else { + data.Account = &result + } + } + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_accounts"), "accounts", AccountContent(data, locale)) +} + +func (h *Handler) holder(response http.ResponseWriter, request *http.Request) { + locale := localeForRequest(response, request) + data := HolderPageData{} + assetID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("asset_id")), 10, 64) + reference := explorer.HolderReference{ + OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")), + OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")), + AssetID: assetID, + } + if err != nil || reference.OwnerType == "" || reference.OwnerID == "" || reference.AssetID <= 0 { + data.Error = tr(locale, "holder_reference_invalid") + } else { + result, lookupErr := h.explorer.Holder(request.Context(), reference) + if requestEnded(request, lookupErr) { + return + } else if lookupErr != nil { + data.Error = tr(locale, "account_lookup_error") + } else { + data.Account = &result + } + } + h.render(response, request, http.StatusOK, locale, tr(locale, "title_holder"), "assets", HolderContent(data, locale)) +} + +func (h *Handler) render(response http.ResponseWriter, request *http.Request, status int, locale Locale, title, active string, content templ.Component) { + component := Page(title, active, locale, localeURL(request, LocaleEnglish), localeURL(request, LocalePersian), content) + if request.Header.Get("HX-Request") == "true" && request.Header.Get("HX-History-Restore-Request") != "true" { + component = content + } + response.Header().Set("Content-Type", "text/html; charset=utf-8") + response.WriteHeader(status) + if err := component.Render(request.Context(), response); err != nil { + if requestEnded(request, err) { + return + } + slog.Error("render explorer", "error", err) + } +} + +func (h *Handler) renderFailure(response http.ResponseWriter, request *http.Request, locale Locale, title string, err error) { + if requestEnded(request, err) { + return + } + slog.Error("explorer request failed", "error", err) + h.render(response, request, http.StatusInternalServerError, locale, title, "", FailureContent(title, tr(locale, "read_model_unavailable"), locale)) +} + +func requestEnded(request *http.Request, err error) bool { + return request.Context().Err() != nil || + errors.Is(err, context.Canceled) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) +} + +func accountClasses() []ledger.AccountClass { + return []ledger.AccountClass{ + ledger.AccountClassUserAvailable, + ledger.AccountClassUserFrozen, + ledger.AccountClassExternalBlockchain, + ledger.AccountClassTreasury, + ledger.AccountClassMarketClearing, + ledger.AccountClassIPGClearing, + ledger.AccountClassCommissionRevenue, + } +} + +func formatTime(value time.Time) string { + if value.IsZero() || value.Unix() == 0 { + return "—" + } + return value.In(defaultTimezone).Format("02 Jan 2006 · 15:04:05") + " Asia/Tehran" +} + +func short(value string, size int) string { + if len(value) <= size { + return value + } + left := size / 2 + return value[:left] + "…" + value[len(value)-(size-left):] +} + +func count(value int64) string { + text := strconv.FormatInt(value, 10) + for index := len(text) - 3; index > 0; index -= 3 { + text = text[:index] + "," + text[index:] + } + return text +} + +func transactionName(journal ledger.Journal, locale Locale) string { + if journal.Blockchain.TransactionHash != "" { + return short(journal.Blockchain.TransactionHash, 22) + } + return tr(locale, "internal") + " · " + short(journal.ID, 14) +} + +func transactionReference(journal ledger.Journal) string { + if journal.Blockchain.TransactionHash != "" { + return journal.Blockchain.TransactionHash + } + return journal.ID +} + +func transactionURL(reference string) string { + return "/transactions/" + url.PathEscape(reference) +} + +func transactionPageURL(filter explorer.TransactionFilter, page int) string { + values := url.Values{"page": []string{strconv.Itoa(page)}} + if filter.Wallet != "" { + values.Set("wallet", filter.Wallet) + } + if filter.EffectKind != "" { + values.Set("effect", filter.EffectKind) + } + return "/transactions?" + values.Encode() +} + +func accountURL(account ledger.AccountReference) string { + values := url.Values{ + "class": []string{string(account.Class)}, + "owner_type": []string{account.OwnerType}, + "owner_id": []string{account.OwnerID}, + "asset_id": []string{strconv.FormatInt(account.AssetID, 10)}, + } + return "/accounts?" + values.Encode() +} + +func holderURL(holder explorer.Holder) string { + values := url.Values{ + "owner_type": []string{holder.OwnerType}, + "owner_id": []string{holder.OwnerID}, + "asset_id": []string{strconv.FormatInt(holder.AssetID, 10)}, + } + return "/holders?" + values.Encode() +} + +func accountName(account ledger.AccountReference, locale Locale) string { + owner := account.OwnerID + if owner == "" { + owner = tr(locale, "system") + } + return fmt.Sprintf("%s / %s", account.Class, owner) +} diff --git a/interface/web/handler_test.go b/interface/web/handler_test.go new file mode 100644 index 0000000..e6daf0b --- /dev/null +++ b/interface/web/handler_test.go @@ -0,0 +1,284 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "gl/application/explorer" + "gl/domain/ledger" +) + +type explorerStub struct { + dashboard explorer.Dashboard + dashboardErr error + assets explorer.Assets + assetsErr error + listing explorer.TransactionListing + listingErr error + transaction []ledger.Journal + transactionErr error + account explorer.Account + accountErr error + holder explorer.HolderAccount + holderErr error + lastHash string + lastFilter explorer.TransactionFilter + lastAccount ledger.AccountReference +} + +func (s *explorerStub) Dashboard(context.Context) (explorer.Dashboard, error) { + return s.dashboard, s.dashboardErr +} + +func (s *explorerStub) Assets(context.Context) (explorer.Assets, error) { + return s.assets, s.assetsErr +} + +func (s *explorerStub) Transactions(_ context.Context, filter explorer.TransactionFilter) (explorer.TransactionListing, error) { + s.lastFilter = filter + return s.listing, s.listingErr +} + +func (s *explorerStub) Transaction(_ context.Context, hash string) ([]ledger.Journal, error) { + s.lastHash = hash + return s.transaction, s.transactionErr +} + +func (s *explorerStub) Account(_ context.Context, account ledger.AccountReference) (explorer.Account, error) { + s.lastAccount = account + return s.account, s.accountErr +} + +func (s *explorerStub) Holder(_ context.Context, reference explorer.HolderReference) (explorer.HolderAccount, error) { + s.holder.Reference = reference + return s.holder, s.holderErr +} + +func TestDashboardRendersFullTemplPage(t *testing.T) { + service := &explorerStub{dashboard: explorer.Dashboard{ + Stats: explorer.Stats{JournalCount: 1234, LastRecordedAt: time.Unix(10, 0).UTC()}, + Journals: []ledger.Journal{{ + ID: "journal-1", EffectKind: "deposit", SourceService: "wallet", + Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"}, + }}, + }} + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { + if !strings.Contains(body, expected) { + t.Fatalf("response did not contain %q", expected) + } + } + if response.Header().Get("Content-Security-Policy") == "" { + t.Fatal("expected content security policy") + } +} + +func TestDashboardLinksInternalTransactionsByJournalID(t *testing.T) { + const journalID = "5c1e31b0-0000-4000-8000-0000084accc8" + service := &explorerStub{dashboard: explorer.Dashboard{Journals: []ledger.Journal{{ID: journalID}}}} + request := httptest.NewRequest(http.MethodGet, "/", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if !strings.Contains(response.Body.String(), `href="/transactions/`+journalID+`"`) { + t.Fatalf("internal transaction was not linked: %s", response.Body.String()) + } +} + +func TestAssetsPageRendersLinkedTopAssetHolders(t *testing.T) { + balance, err := ledger.ParseAmount("987.25") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{assets: explorer.Assets{TopHolders: []explorer.Holder{{ + Rank: 1, OwnerType: "user", OwnerID: "holder-42", AssetID: 7, Balance: balance, + }}}} + request := httptest.NewRequest(http.MethodGet, "/assets", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"Top asset holders", "holder-42", "Asset 7", "987.25", "/holders?asset_id=7&owner_id=holder-42&owner_type=user"} { + if !strings.Contains(body, expected) { + t.Fatalf("top holder response did not contain %q: %s", expected, body) + } + } +} + +func TestHolderPageLoadsAggregateAccountDetail(t *testing.T) { + balance, err := ledger.ParseAmount("42.5") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{holder: explorer.HolderAccount{Balance: balance}} + request := httptest.NewRequest(http.MethodGet, "/holders?owner_type=user&owner_id=holder-42&asset_id=7", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"holder-42", "Asset 7", "42.5", "AVAILABLE + FROZEN"} { + if !strings.Contains(body, expected) { + t.Fatalf("holder response did not contain %q: %s", expected, body) + } + } +} + +func TestDashboardDefaultsToEnglishAndSupportsPersian(t *testing.T) { + service := &explorerStub{} + handler := NewHandler(service) + + englishRequest := httptest.NewRequest(http.MethodGet, "/", nil) + englishResponse := httptest.NewRecorder() + handler.ServeHTTP(englishResponse, englishRequest) + if !strings.Contains(englishResponse.Body.String(), `lang="en" dir="ltr"`) || !strings.Contains(englishResponse.Body.String(), "Every movement") { + t.Fatalf("dashboard did not default to English: %s", englishResponse.Body.String()) + } + + persianRequest := httptest.NewRequest(http.MethodGet, "/?lang=fa", nil) + persianResponse := httptest.NewRecorder() + handler.ServeHTTP(persianResponse, persianRequest) + if !strings.Contains(persianResponse.Body.String(), `lang="fa" dir="rtl"`) || !strings.Contains(persianResponse.Body.String(), "هر جابه‌جایی") { + t.Fatalf("dashboard did not render Persian: %s", persianResponse.Body.String()) + } + if !strings.Contains(persianResponse.Header().Get("Set-Cookie"), "gl_locale=fa") { + t.Fatal("Persian locale was not persisted") + } +} + +func TestDashboardUsesLocaleCookie(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/transactions", nil) + request.AddCookie(&http.Cookie{Name: localeCookie, Value: "fa"}) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + if !strings.Contains(response.Body.String(), "کاوشگر تراکنش") { + t.Fatalf("locale cookie was ignored: %s", response.Body.String()) + } +} + +func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + if response.Code != http.StatusOK || response.Body.Len() < 1000 { + t.Fatalf("unexpected favicon response: status=%d bytes=%d", response.Code, response.Body.Len()) + } + if !strings.Contains(response.Header().Get("Content-Type"), "image/") { + t.Fatalf("unexpected favicon content type: %s", response.Header().Get("Content-Type")) + } +} + +func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) { + service := &explorerStub{transaction: []ledger.Journal{{ + ID: "journal-1", EffectKind: "withdrawal", + Blockchain: ledger.BlockchainReference{TransactionHash: "tx-hash"}, + }}} + request := httptest.NewRequest(http.MethodGet, "/transactions?q=tx-hash", nil) + request.Header.Set("HX-Request", "true") + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + if strings.Contains(body, "") || !strings.Contains(body, `id="explorer-content"`) { + t.Fatalf("unexpected HTMX fragment: %s", body) + } + if service.lastHash != "tx-hash" || !strings.Contains(body, "COMMITTED") { + t.Fatalf("transaction was not rendered: %s", body) + } +} + +func TestTransactionsPageShowsLatestTransactionsWithPagination(t *testing.T) { + service := &explorerStub{listing: explorer.TransactionListing{ + Journals: []ledger.Journal{{ID: "latest-journal", EffectKind: "transfer"}}, + Filter: explorer.TransactionFilter{Page: 2, Wallet: "wallet-42", EffectKind: "transfer"}, + HasPrevious: true, HasNext: true, + }} + request := httptest.NewRequest(http.MethodGet, "/transactions?page=2&wallet=wallet-42&effect=transfer", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + for _, expected := range []string{"Latest transactions", "latest-journal", "value=\"wallet-42\"", "value=\"transfer\"", "/transactions?effect=transfer&page=1&wallet=wallet-42", "/transactions?effect=transfer&page=3&wallet=wallet-42"} { + if !strings.Contains(body, expected) { + t.Fatalf("transaction listing did not contain %q: %s", expected, body) + } + } + if service.lastFilter.Page != 2 || service.lastFilter.Wallet != "wallet-42" || service.lastFilter.EffectKind != "transfer" { + t.Fatalf("unexpected transaction filter: %+v", service.lastFilter) + } +} + +func TestMissingTransactionShowsLatestTransactions(t *testing.T) { + service := &explorerStub{ + transactionErr: ledger.ErrNotFound, + listing: explorer.TransactionListing{Journals: []ledger.Journal{{ID: "latest-journal"}}, Filter: explorer.TransactionFilter{Page: 1}}, + } + request := httptest.NewRequest(http.MethodGet, "/transactions?q=missing", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + body := response.Body.String() + if !strings.Contains(body, "No committed transaction matches") || !strings.Contains(body, "latest-journal") { + t.Fatalf("missing transaction did not include latest activity: %s", body) + } +} + +func TestAccountExplorerParsesStableAccountIdentity(t *testing.T) { + balance, err := ledger.ParseAmount("42.5") + if err != nil { + t.Fatal(err) + } + service := &explorerStub{account: explorer.Account{Balance: balance, Reference: ledger.AccountReference{ + Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "17", AssetID: 9, + }}} + request := httptest.NewRequest(http.MethodGet, "/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9", nil) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if service.lastAccount.OwnerID != "17" || service.lastAccount.AssetID != 9 { + t.Fatalf("unexpected account lookup: %+v", service.lastAccount) + } + if !strings.Contains(response.Body.String(), "42.5") { + t.Fatal("account balance was not rendered") + } +} + +func TestCanceledDashboardRequestDoesNotRenderAnErrorPage(t *testing.T) { + service := &explorerStub{dashboardErr: context.Canceled} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + response := httptest.NewRecorder() + + NewHandler(service).ServeHTTP(response, request) + + if response.Body.Len() != 0 { + t.Fatalf("expected canceled request to produce no response, got %q", response.Body.String()) + } +} + +func TestFormatTimeUsesTehranTimezone(t *testing.T) { + value := time.Date(2026, time.August, 14, 0, 0, 0, 0, time.UTC) + if got := formatTime(value); got != "14 Aug 2026 · 03:30:00 Asia/Tehran" { + t.Fatalf("unexpected localized time: %q", got) + } +} diff --git a/interface/web/i18n.go b/interface/web/i18n.go new file mode 100644 index 0000000..fda10c0 --- /dev/null +++ b/interface/web/i18n.go @@ -0,0 +1,286 @@ +package web + +import ( + "net/http" + "net/url" + "time" +) + +type Locale string + +const ( + LocaleEnglish Locale = "en" + LocalePersian Locale = "fa" + localeCookie = "gl_locale" +) + +var defaultTimezone = mustLoadLocation("Asia/Tehran") + +func mustLoadLocation(name string) *time.Location { + location, err := time.LoadLocation(name) + if err != nil { + panic("load dashboard timezone: " + err.Error()) + } + return location +} + +func localeForRequest(response http.ResponseWriter, request *http.Request) Locale { + if locale, ok := validLocale(request.URL.Query().Get("lang")); ok { + http.SetCookie(response, &http.Cookie{ + Name: localeCookie, + Value: string(locale), + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + SameSite: http.SameSiteLaxMode, + }) + return locale + } + if cookie, err := request.Cookie(localeCookie); err == nil { + if locale, ok := validLocale(cookie.Value); ok { + return locale + } + } + return LocaleEnglish +} + +func validLocale(value string) (Locale, bool) { + switch Locale(value) { + case LocaleEnglish: + return LocaleEnglish, true + case LocalePersian: + return LocalePersian, true + default: + return "", false + } +} + +func (l Locale) Direction() string { + if l == LocalePersian { + return "rtl" + } + return "ltr" +} + +func localeURL(request *http.Request, locale Locale) string { + query := cloneQuery(request.URL.Query()) + query.Set("lang", string(locale)) + return request.URL.Path + "?" + query.Encode() +} + +func cloneQuery(source url.Values) url.Values { + result := make(url.Values, len(source)) + for key, values := range source { + result[key] = append([]string(nil), values...) + } + return result +} + +func tr(locale Locale, key string) string { + if locale == LocalePersian { + if value, ok := persian[key]; ok { + return value + } + } + if value, ok := english[key]; ok { + return value + } + return key +} + +var english = map[string]string{ + "site_name": "DARANO", + "site_subtitle": "LEDGER NETWORK", + "overview": "Overview", + "transactions": "Transactions", + "assets": "Assets", + "accounts": "Accounts", + "read_only": "READ ONLY", + "general_ledger_explorer": "General ledger explorer", + "hero_line_one": "Every movement.", + "hero_line_two": "One durable record.", + "hero_description": "Inspect committed journals, trace blockchain transaction hashes, and reconstruct any ledger account without touching the write path.", + "transaction_placeholder": "Enter a transaction hash or journal ID", + "transaction_hash": "Transaction hash", + "explore_transaction": "Explore transaction", + "committed_journals": "Committed journals", + "ledger_entries": "Ledger entries", + "tracked_accounts": "Tracked accounts", + "last_recorded": "Last recorded", + "latest_activity": "Latest ledger activity", + "newest_first": "NEWEST FIRST", + "top_asset_holders": "Top asset holders", + "holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS", + "no_asset_holders": "No positive user holdings yet.", + "assets_explorer": "Asset explorer", + "track_asset_ownership": "Track asset ownership.", + "assets_description": "Review the leading positive user balances for the most recently active ledger assets.", + "rank": "Rank", + "holder": "Holder", + "balance": "Balance", + "holder_account": "Holder account", + "inspect_holder": "Inspect a holder.", + "holder_description": "This account combines the holder's available and frozen balances and activity for one asset.", + "combined_balance": "Combined balance", + "available_and_frozen": "AVAILABLE + FROZEN", + "holder_reference_invalid": "A holder identity and positive asset ID are required.", + "transaction": "Transaction", + "effect": "Effect", + "source": "Source", + "entries": "Entries", + "recorded": "Recorded", + "no_journals": "No committed journals yet.", + "network": "NETWORK", + "transaction_explorer": "Transaction explorer", + "trace_settlement": "Trace a settlement.", + "transaction_description": "Search an exact blockchain hash or internal journal ID retained on committed ledger journals.", + "transaction_not_found": "Transaction not found", + "transaction_missing": "No committed transaction matches that hash or journal ID.", + "transaction_lookup_error": "The transaction could not be loaded. Try again shortly.", + "enter_hash": "Enter a hash or journal ID to inspect its balanced financial effects.", + "latest_transactions": "Latest transactions", + "page": "PAGE", + "pagination": "Transaction pages", + "previous": "Previous", + "next": "Next", + "user_wallet": "User / wallet", + "user_wallet_placeholder": "Exact owner or wallet ID", + "effect_type": "Effect type", + "effect_type_placeholder": "Exact effect type", + "apply_filters": "Apply filters", + "clear_filters": "Clear", + "committed": "COMMITTED", + "journal_id": "Journal ID", + "ledger_sequence": "Ledger sequence", + "balanced_entries": "Balanced entries", + "asset": "Asset", + "internal": "internal", + "system": "system", + "account_explorer": "Account explorer", + "rebuild_account": "Rebuild an account.", + "account_description": "Select the stable ledger identity and asset to calculate its current balance from immutable entries.", + "account_class": "Account class", + "owner_type": "Owner type", + "owner_id": "Owner ID", + "stable_owner_id": "Stable owner ID", + "asset_id": "Asset ID", + "explore": "Explore", + "account_unavailable": "Account unavailable", + "asset_positive": "Asset ID must be a positive integer.", + "account_lookup_error": "The account could not be loaded. Check its identity and try again.", + "ledger_identity": "Ledger identity", + "current_balance": "Current balance", + "account_activity": "Account activity", + "journals": "JOURNALS", + "choose_account": "Choose an account class, owner, and asset to begin.", + "explorer_error": "Explorer error", + "dashboard_unavailable": "Dashboard unavailable", + "assets_unavailable": "Assets unavailable", + "transactions_unavailable": "Transactions unavailable", + "read_model_unavailable": "The ledger read model could not be reached. Try again shortly.", + "footer_description": "Darano General Ledger · immutable financial history", + "timezone_notice": "All times shown in Asia/Tehran", + "title_overview": "Network overview", + "title_transactions": "Transaction explorer", + "title_assets": "Asset explorer", + "title_accounts": "Account explorer", + "title_holder": "Holder account", +} + +var persian = map[string]string{ + "site_name": "دارانو", + "site_subtitle": "شبکه دفتر کل", + "overview": "نمای کلی", + "transactions": "تراکنش‌ها", + "assets": "دارایی‌ها", + "accounts": "حساب‌ها", + "read_only": "فقط خواندنی", + "general_ledger_explorer": "کاوشگر دفتر کل", + "hero_line_one": "هر جابه‌جایی.", + "hero_line_two": "یک سابقه ماندگار.", + "hero_description": "دفاتر ثبت‌شده را ببینید، هش تراکنش‌های بلاکچین را ردیابی کنید و هر حساب دفتر کل را بدون دسترسی به مسیر نوشتن بازسازی کنید.", + "transaction_placeholder": "هش تراکنش یا شناسه دفتر را وارد کنید", + "transaction_hash": "هش تراکنش", + "explore_transaction": "جست‌وجوی تراکنش", + "committed_journals": "دفاتر ثبت‌شده", + "ledger_entries": "ردیف‌های دفتر کل", + "tracked_accounts": "حساب‌های ردیابی‌شده", + "last_recorded": "آخرین ثبت", + "latest_activity": "آخرین فعالیت دفتر کل", + "newest_first": "جدیدترین ابتدا", + "top_asset_holders": "دارندگان برتر دارایی", + "holder_balance_scope": "در دسترس + مسدود · دارایی‌های اخیر", + "no_asset_holders": "هنوز موجودی مثبت کاربری ثبت نشده است.", + "assets_explorer": "کاوشگر دارایی", + "track_asset_ownership": "مالکیت دارایی را ردیابی کنید.", + "assets_description": "بالاترین موجودی‌های مثبت کاربران را برای دارایی‌های فعال اخیر دفتر کل بررسی کنید.", + "rank": "رتبه", + "holder": "دارنده", + "balance": "موجودی", + "holder_account": "حساب دارنده", + "inspect_holder": "دارنده را بررسی کنید.", + "holder_description": "این حساب موجودی و فعالیت در دسترس و مسدود دارنده را برای یک دارایی ترکیب می‌کند.", + "combined_balance": "موجودی ترکیبی", + "available_and_frozen": "در دسترس + مسدود", + "holder_reference_invalid": "شناسه دارنده و شناسه مثبت دارایی الزامی است.", + "transaction": "تراکنش", + "effect": "اثر", + "source": "منبع", + "entries": "ردیف‌ها", + "recorded": "زمان ثبت", + "no_journals": "هنوز دفتری ثبت نشده است.", + "network": "شبکه", + "transaction_explorer": "کاوشگر تراکنش", + "trace_settlement": "تسویه را ردیابی کنید.", + "transaction_description": "هش دقیق بلاکچین یا شناسه دفتر داخلی را در دفاتر قطعی جست‌وجو کنید.", + "transaction_not_found": "تراکنش پیدا نشد", + "transaction_missing": "هیچ تراکنش قطعی با این هش یا شناسه دفتر پیدا نشد.", + "transaction_lookup_error": "بارگذاری تراکنش ممکن نشد. کمی بعد دوباره تلاش کنید.", + "enter_hash": "برای مشاهده اثرهای مالی تراز، هش یا شناسه دفتر را وارد کنید.", + "latest_transactions": "آخرین تراکنش‌ها", + "page": "صفحه", + "pagination": "صفحه‌های تراکنش", + "previous": "قبلی", + "next": "بعدی", + "user_wallet": "کاربر / کیف پول", + "user_wallet_placeholder": "شناسه دقیق مالک یا کیف پول", + "effect_type": "نوع اثر", + "effect_type_placeholder": "نوع دقیق اثر", + "apply_filters": "اعمال فیلترها", + "clear_filters": "پاک‌کردن", + "committed": "ثبت‌شده", + "journal_id": "شناسه دفتر", + "ledger_sequence": "شماره دفتر", + "balanced_entries": "ردیف‌های تراز", + "asset": "دارایی", + "internal": "داخلی", + "system": "سیستم", + "account_explorer": "کاوشگر حساب", + "rebuild_account": "یک حساب را بازسازی کنید.", + "account_description": "شناسه پایدار حساب و دارایی را انتخاب کنید تا موجودی فعلی از ردیف‌های تغییرناپذیر محاسبه شود.", + "account_class": "نوع حساب", + "owner_type": "نوع مالک", + "owner_id": "شناسه مالک", + "stable_owner_id": "شناسه پایدار مالک", + "asset_id": "شناسه دارایی", + "explore": "جست‌وجو", + "account_unavailable": "حساب در دسترس نیست", + "asset_positive": "شناسه دارایی باید یک عدد صحیح مثبت باشد.", + "account_lookup_error": "بارگذاری حساب ممکن نشد. شناسه آن را بررسی و دوباره تلاش کنید.", + "ledger_identity": "شناسه دفتر کل", + "current_balance": "موجودی فعلی", + "account_activity": "فعالیت حساب", + "journals": "دفتر", + "choose_account": "برای شروع، نوع حساب، مالک و دارایی را انتخاب کنید.", + "explorer_error": "خطای کاوشگر", + "dashboard_unavailable": "داشبورد در دسترس نیست", + "assets_unavailable": "دارایی‌ها در دسترس نیستند", + "transactions_unavailable": "تراکنش‌ها در دسترس نیستند", + "read_model_unavailable": "مدل خواندنی دفتر کل در دسترس نیست. کمی بعد دوباره تلاش کنید.", + "footer_description": "دفتر کل دارانو · تاریخچه مالی تغییرناپذیر", + "timezone_notice": "همه زمان‌ها بر پایه منطقه زمانی تهران نمایش داده می‌شوند", + "title_overview": "نمای کلی شبکه", + "title_transactions": "کاوشگر تراکنش", + "title_assets": "کاوشگر دارایی", + "title_accounts": "کاوشگر حساب", + "title_holder": "حساب دارنده", +} diff --git a/interface/web/i18n_test.go b/interface/web/i18n_test.go new file mode 100644 index 0000000..d04f058 --- /dev/null +++ b/interface/web/i18n_test.go @@ -0,0 +1,23 @@ +package web + +import ( + "net/http/httptest" + "strings" + "testing" +) + +func TestLocaleURLPreservesExplorerQuery(t *testing.T) { + request := httptest.NewRequest("GET", "/accounts?class=USER_AVAILABLE&asset_id=9", nil) + value := localeURL(request, LocalePersian) + for _, expected := range []string{"/accounts?", "class=USER_AVAILABLE", "asset_id=9", "lang=fa"} { + if !strings.Contains(value, expected) { + t.Fatalf("locale URL %q did not preserve %q", value, expected) + } + } +} + +func TestTranslationFallsBackToEnglish(t *testing.T) { + if got := tr(Locale("invalid"), "overview"); got != "Overview" { + t.Fatalf("unexpected fallback translation: %q", got) + } +} diff --git a/interface/web/server.go b/interface/web/server.go new file mode 100644 index 0000000..c7d778e --- /dev/null +++ b/interface/web/server.go @@ -0,0 +1,47 @@ +package web + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +type ServerConfig struct { + Host string + Port int + ReadHeaderTimeout time.Duration + ShutdownTimeout time.Duration +} + +func Run(ctx context.Context, cfg ServerConfig, handler http.Handler) error { + server := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Handler: handler, + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + } + serveErr := make(chan error, 1) + go func() { serveErr <- server.ListenAndServe() }() + + select { + case err := <-serveErr: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve explorer: %w", err) + case <-ctx.Done(): + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + _ = server.Close() + return fmt.Errorf("shut down explorer: %w", err) + } + err := <-serveErr + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("serve explorer: %w", err) + } + return nil +} diff --git a/interface/web/templates.templ b/interface/web/templates.templ new file mode 100644 index 0000000..75f4ebe --- /dev/null +++ b/interface/web/templates.templ @@ -0,0 +1,421 @@ +package web + +import ( + "strconv" + + "gl/application/explorer" + "gl/domain/ledger" +) + +templ Page(title string, active string, locale Locale, englishURL string, persianURL string, content templ.Component) { + + + + + + + + { title } · { tr(locale, "site_name") } + + + + + +
+ + + { tr(locale, "site_name") } { tr(locale, "site_subtitle") } + + +
+ @content +
+ { tr(locale, "footer_description") } + { tr(locale, "timezone_notice") } +
+ + +} + +templ TransactionSearch(value string, locale Locale) { + +} + +templ DashboardContent(data explorer.Dashboard, locale Locale) { +
+
+
{ tr(locale, "general_ledger_explorer") }
+

{ tr(locale, "hero_line_one") }
{ tr(locale, "hero_line_two") }

+

{ tr(locale, "hero_description") }

+ @TransactionSearch("", locale) +
+
+
{ tr(locale, "committed_journals") }{ count(data.Stats.JournalCount) }
+
{ tr(locale, "ledger_entries") }{ count(data.Stats.EntryCount) }
+
{ tr(locale, "tracked_accounts") }{ count(data.Stats.AccountCount) }
+
{ tr(locale, "last_recorded") }
+
+
+

{ tr(locale, "latest_activity") }

{ tr(locale, "newest_first") }
+ @JournalTable(data.Journals, locale) +
+
+} + +templ AssetsContent(data explorer.Assets, locale Locale) { +
+ +
+
{ tr(locale, "assets_explorer") }
+

{ tr(locale, "track_asset_ownership") }

+

{ tr(locale, "assets_description") }

+
+
+

{ tr(locale, "top_asset_holders") }

{ tr(locale, "holder_balance_scope") }
+ @HolderTable(data.TopHolders, locale) +
+
+} + +templ HolderTable(holders []explorer.Holder, locale Locale) { +
+ if len(holders) == 0 { +
{ tr(locale, "no_asset_holders") }
+ } else { + + + + for _, holder := range holders { + + + + + + + } + +
{ tr(locale, "asset") }{ tr(locale, "rank") }{ tr(locale, "holder") }{ tr(locale, "balance") }
{ tr(locale, "asset") } { strconv.FormatInt(holder.AssetID, 10) }#{ strconv.FormatInt(holder.Rank, 10) }{ holder.OwnerID }{ holder.OwnerType }{ holder.Balance.String() }
+ } +
+} + +templ HolderContent(data HolderPageData, locale Locale) { +
+ +
+
{ tr(locale, "holder_account") }
+

{ tr(locale, "inspect_holder") }

+

{ tr(locale, "holder_description") }

+
+ if data.Error != "" { +
{ tr(locale, "account_unavailable") }{ data.Error }
+ } else if data.Account != nil { + +
+

{ tr(locale, "account_activity") }

{ tr(locale, "available_and_frozen") }
+ @JournalTable(data.Account.Journals, locale) +
+ } +
+} + +templ JournalTable(journals []ledger.Journal, locale Locale) { +
+ if len(journals) == 0 { +
{ tr(locale, "no_journals") }
+ } else { + + + + for _, journal := range journals { + + + + + + + + } + +
{ tr(locale, "transaction") }{ tr(locale, "effect") }{ tr(locale, "source") }{ tr(locale, "entries") }{ tr(locale, "recorded") }
+ { transactionName(journal, locale) } + { journal.EffectKind }{ journal.SourceService }{ strconv.Itoa(len(journal.Entries)) }{ formatTime(journal.RecordedAt) }
+ } +
+} + +templ TransactionContent(data TransactionPageData, locale Locale) { +
+ +
+
{ tr(locale, "transaction_explorer") }
+

{ tr(locale, "trace_settlement") }

+

{ tr(locale, "transaction_description") }

+ @TransactionSearch(data.Query, locale) +
+ if data.Error != "" { +
{ tr(locale, "transaction_not_found") }{ data.Error }
+ } + if data.Query != "" && data.Error == "" { +
+ for _, journal := range data.Journals { + @JournalCard(journal, locale) + } +
+ } + if data.Listing != nil { +
+

{ tr(locale, "latest_transactions") }

{ tr(locale, "page") } { strconv.Itoa(data.Listing.Filter.Page) }
+
+
+
+ + { tr(locale, "clear_filters") } +
+ @JournalTable(data.Listing.Journals, locale) + @Pagination(*data.Listing, locale) +
+ } +
+} + +templ Pagination(listing explorer.TransactionListing, locale Locale) { + +} + +templ JournalCard(journal ledger.Journal, locale Locale) { +
+
+
+ if journal.Blockchain.TransactionHash != "" { + { tr(locale, "transaction_hash") } + } else { + { tr(locale, "journal_id") } + } +

{ transactionReference(journal) }

+
+ { tr(locale, "committed") } +
+
+
{ journal.EffectKind }
+
{ journal.ID }
+
{ formatTime(journal.RecordedAt) }
+
{ journal.SourceService } · { journal.SourceTransactionID }
+
{ journal.Blockchain.Network }
+
{ journal.Blockchain.LedgerSequence }
+
+
+

{ tr(locale, "balanced_entries") }

+ for _, entry := range journal.Entries { +
+ #{ strconv.FormatUint(uint64(entry.LineNumber), 10) } + + if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' { +
{ entry.Amount.String() }
+ } else { +
+{ entry.Amount.String() }
+ } +
+ } +
+
+} + +templ AccountContent(data AccountPageData, locale Locale) { +
+ +
+
{ tr(locale, "account_explorer") }
+

{ tr(locale, "rebuild_account") }

+

{ tr(locale, "account_description") }

+
+ + if data.Error != "" { +
{ tr(locale, "account_unavailable") }{ data.Error }
+ } else if data.Account != nil { + +

{ tr(locale, "account_activity") }

{ strconv.Itoa(len(data.Account.Journals)) } { tr(locale, "journals") }
+ @JournalTable(data.Account.Journals, locale) + } else { +
{ tr(locale, "choose_account") }
+ } +
+} + +templ FailureContent(title string, message string, locale Locale) { +
+
{ tr(locale, "explorer_error") }

{ title }

{ message }

+
+} diff --git a/interface/web/templates_templ.go b/interface/web/templates_templ.go new file mode 100644 index 0000000..617e864 --- /dev/null +++ b/interface/web/templates_templ.go @@ -0,0 +1,2983 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package web + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "strconv" + + "gl/application/explorer" + "gl/domain/ledger" +) + +func Page(title string, active string, locale Locale, englishURL string, persianURL string, content templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 18, Col: 17} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 18, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
\"\" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 130, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_subtitle")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 130, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "footer_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 170, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "timezone_notice")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 171, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TransactionSearch(value string, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var29 := templ.GetChildren(ctx) + if templ_7745c5c3_Var29 == nil { + templ_7745c5c3_Var29 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "general_ledger_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 187, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_one")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 188, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_two")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 188, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 189, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TransactionSearch("", locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed_journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.JournalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 136} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.EntryCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 123} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "tracked_accounts")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 195, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.AccountCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 195, Col: 127} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var41 string + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "last_recorded")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 196, Col: 75} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "newest_first")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 105} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AssetsContent(data explorer.Assets, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var45 := templ.GetChildren(ctx) + if templ_7745c5c3_Var45 == nil { + templ_7745c5c3_Var45 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 92} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var48 string + templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 209, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "track_asset_ownership")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 210, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 211, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "top_asset_holders")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 214, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_balance_scope")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 214, Col: 115} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = HolderTable(data.TopHolders, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func HolderTable(holders []explorer.Holder, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var53 := templ.GetChildren(ctx) + if templ_7745c5c3_Var53 == nil { + templ_7745c5c3_Var53 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(holders) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_asset_holders")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 223, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, holder := range holders { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 40} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 138} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 230, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 230, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "#") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 232, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 232, Col: 140} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(holder.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 233, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func HolderContent(data HolderPageData, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var66 := templ.GetChildren(ctx) + if templ_7745c5c3_Var66 == nil { + templ_7745c5c3_Var66 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var67 string + templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 110} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var69 string + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 141} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 246, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "inspect_holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 247, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 248, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 251, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 251, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Account != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 114} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 171} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var78 string + templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 198} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var79 string + templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 256} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var80 string + templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "combined_balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 255, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var81 string + templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 255, Col: 127} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var82 string + templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 258, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var83 string + templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "available_and_frozen")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 258, Col: 115} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Account.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func JournalTable(journals []ledger.Journal, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var84 := templ.GetChildren(ctx) + if templ_7745c5c3_Var84 == nil { + templ_7745c5c3_Var84 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(journals) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var85 string + templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 268, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, journal := range journals { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var88 string + templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var89 string + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 146} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var90 string + templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 181} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 129} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 278, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var94 string + templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 280, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var96 string + templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func TransactionContent(data TransactionPageData, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var97 := templ.GetChildren(ctx) + if templ_7745c5c3_Var97 == nil { + templ_7745c5c3_Var97 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 292, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var99 string + templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 292, Col: 98} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var100 string + templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 294, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var101 string + templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "trace_settlement")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 295, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var102 string + templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 296, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = TransactionSearch(data.Query, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var103 string + templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_not_found")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var104 string + templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 91} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Query != "" && data.Error == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, journal := range data.Journals { + templ_7745c5c3_Err = JournalCard(journal, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if data.Listing != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var105 string + templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_transactions")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 311, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var106 string + templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "page")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 311, Col: 102} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var107 string + templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(data.Listing.Filter.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 311, Col: 145} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var115 string + templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "clear_filters")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 316, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Listing.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Pagination(*data.Listing, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func Pagination(listing explorer.TransactionListing, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var116 := templ.GetChildren(ctx) + if templ_7745c5c3_Var116 == nil { + templ_7745c5c3_Var116 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func JournalCard(journal ledger.Journal, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var125 := templ.GetChildren(ctx) + if templ_7745c5c3_Var125 == nil { + templ_7745c5c3_Var125 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if journal.Blockchain.TransactionHash != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var126 string + templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_hash")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 346, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var127 string + templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 348, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var128 string + templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(transactionReference(journal)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 350, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var129 string + templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 352, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var131 string + templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 355, Col: 106} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var133 string + templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(journal.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 356, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var135 string + templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 357, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var137 string + templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 358, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var138 string + templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceTransactionID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 358, Col: 125} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var140 string + templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.Network) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 359, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var142 string + templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.LedgerSequence) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 124} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var143 string + templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balanced_entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 363, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, entry := range journal.Entries { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "
#") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var144 string + templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(entry.LineNumber), 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 366, Col: 83} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var144)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var146 string + templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(entry.Account, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 367, Col: 117} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var147 string + templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 367, Col: 151} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var148 string + templ_7745c5c3_Var148, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(entry.Account.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 367, Col: 200} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var148)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var149 string + templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Account.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 367, Col: 231} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var150 string + templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 369, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
+") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var151 string + templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 371, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AccountContent(data AccountPageData, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var152 := templ.GetChildren(ctx) + if templ_7745c5c3_Var152 == nil { + templ_7745c5c3_Var152 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var153 string + templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 381, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, " / ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var154 string + templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 381, Col: 94} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var155 string + templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 383, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var156 string + templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rebuild_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 384, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var157 string + templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 385, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var171 string + templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 403, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var171)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var172 string + templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 403, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Account != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var173 string + templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_identity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var174 string + templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(data.Account.Reference, locale)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 123} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var175 string + templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 180} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var176 string + templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 207} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var177 string + templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 265} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var178 string + templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "current_balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 407, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var179 string + templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 407, Col: 126} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var180 string + templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var181 string + templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(data.Account.Journals))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 120} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var182 string + templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 147} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var182)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = JournalTable(data.Account.Journals, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var183 string + templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "choose_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func FailureContent(title string, message string, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var184 := templ.GetChildren(ctx) + if templ_7745c5c3_Var184 == nil { + templ_7745c5c3_Var184 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var185 string + templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explorer_error")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var186 string + templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var187 string + templ_7745c5c3_Var187, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 132} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var187)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/scripts/air-run.sh b/scripts/air-run.sh new file mode 100644 index 0000000..d44e8d7 --- /dev/null +++ b/scripts/air-run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -u + +./tmp/gl -conf ./gl.cfg.toml & +gl_pid=$! + +./tmp/dashboard -conf ./dashboard.cfg.toml & +dashboard_pid=$! + +shutdown() { + trap - EXIT + kill -TERM "$gl_pid" "$dashboard_pid" 2>/dev/null || true + wait "$gl_pid" 2>/dev/null || true + wait "$dashboard_pid" 2>/dev/null || true +} + +trap 'exit 0' INT TERM +trap shutdown EXIT + +wait -n "$gl_pid" "$dashboard_pid" +status=$? +exit "$status" From a54070cfad6138856e47631af106f378e7b7933b78dd54865d3c13fc38539940 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 15 Aug 2026 00:48:56 +0330 Subject: [PATCH 08/20] test: add ledger load test commands --- cmd/gl-loadtest/README.md | 22 + cmd/gl-loadtest/main.go | 323 ++++++++++++++ cmd/gl-loadtest/main_test.go | 79 ++++ cmd/gl-redistribution-loadtest/README.md | 28 ++ cmd/gl-redistribution-loadtest/main.go | 458 ++++++++++++++++++++ cmd/gl-redistribution-loadtest/main_test.go | 150 +++++++ 6 files changed, 1060 insertions(+) create mode 100644 cmd/gl-loadtest/README.md create mode 100644 cmd/gl-loadtest/main.go create mode 100644 cmd/gl-loadtest/main_test.go create mode 100644 cmd/gl-redistribution-loadtest/README.md create mode 100644 cmd/gl-redistribution-loadtest/main.go create mode 100644 cmd/gl-redistribution-loadtest/main_test.go diff --git a/cmd/gl-loadtest/README.md b/cmd/gl-loadtest/README.md new file mode 100644 index 0000000..371befb --- /dev/null +++ b/cmd/gl-loadtest/README.md @@ -0,0 +1,22 @@ +# GL load test + +This command exercises the write and read paths of the gRPC ledger service. +Each simulated transaction: + +1. appends a `CREATED` lifecycle event; +2. posts a balanced transfer between two generated user wallet accounts; +3. reads the committed journal; +4. rebuilds the receiving wallet balance; +5. appends a `SUCCESSFUL` lifecycle event. + +Ledger accounts are created implicitly by `AppendJournal`; GL does not expose a +separate wallet-creation RPC. + +Start GL, then run: + +```bash +go run ./cmd/gl-loadtest -duration 30s -concurrency 20 -wallets 1000 +``` + +Use a fresh `-run-id` for every dataset. The command generates one automatically +when the flag is omitted. diff --git a/cmd/gl-loadtest/main.go b/cmd/gl-loadtest/main.go new file mode 100644 index 0000000..b495625 --- /dev/null +++ b/cmd/gl-loadtest/main.go @@ -0,0 +1,323 @@ +// Command gl-loadtest runs a stateful gRPC load test against the GL service. +// Wallet accounts are created implicitly by the first journal that references +// each generated owner ID, matching normal GL behavior. +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "os" + "os/signal" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + domain "gl/domain/ledger" + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type client interface { + AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) + AppendTransactionEvent(context.Context, *ledgerv1.AppendTransactionEventRequest, ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error) + GetJournal(context.Context, *ledgerv1.GetJournalRequest, ...grpc.CallOption) (*ledgerv1.Journal, error) + GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) +} + +type config struct { + RunID string + Wallets int + AssetID int64 + Amount string + RequestTimeout time.Duration +} + +type sample struct { + Operation string + Latency time.Duration + Err error +} + +type operationStats struct { + Requests int64 + Errors int64 + Latency []time.Duration +} + +func main() { + target := flag.String("target", "127.0.0.1:8600", "GL gRPC target") + concurrency := flag.Int("concurrency", 20, "number of concurrent transaction workers") + duration := flag.Duration("duration", 30*time.Second, "load duration") + wallets := flag.Int("wallets", 1000, "number of simulated wallet accounts") + assetID := flag.Int64("asset-id", 1, "asset ID used by transfers") + amount := flag.String("amount", "1", "canonical decimal transfer amount") + requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each gRPC call") + runID := flag.String("run-id", "", "idempotency namespace; generated when empty") + flag.Parse() + + if *concurrency < 1 || *duration <= 0 || *wallets < 2 || *assetID <= 0 || *requestTimeout <= 0 || *amount == "" { + fmt.Fprintln(os.Stderr, "concurrency and durations must be positive; wallets must be at least 2; asset-id and amount are required") + os.Exit(2) + } + parsedAmount, err := domain.ParseAmount(*amount) + if err != nil || parsedAmount.IsZero() || strings.HasPrefix(*amount, "-") { + fmt.Fprintf(os.Stderr, "amount must be a positive canonical ledger decimal: %v\n", err) + os.Exit(2) + } + if *runID == "" { + *runID = "gl-load-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36) + } + + connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(os.Stderr, "create gRPC client: %v\n", err) + os.Exit(2) + } + defer connection.Close() + ledger := ledgerv1.NewGeneralLedgerServiceClient(connection) + healthCtx, cancelHealth := context.WithTimeout(context.Background(), *requestTimeout) + health, err := ledger.Health(healthCtx, &basev1.Empty{}) + cancelHealth() + if err != nil || !health.GetServing() || !health.GetDatabaseReady() { + fmt.Fprintf(os.Stderr, "GL is not ready at %s: response=%v error=%v\n", *target, health, err) + os.Exit(1) + } + + testConfig := config{ + RunID: *runID, + Wallets: *wallets, + AssetID: *assetID, + Amount: *amount, + RequestTimeout: *requestTimeout, + } + signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(signalCtx, *duration) + defer cancel() + + fmt.Printf("GL load test: target=%s workers=%d duration=%s wallets=%d asset=%d run=%s\n", *target, *concurrency, *duration, *wallets, *assetID, *runID) + started := time.Now() + samples, transactions := run(ctx, ledger, testConfig, *concurrency) + elapsed := time.Since(started) + failed := printSummary(samples, transactions, elapsed) + if failed { + os.Exit(1) + } +} + +func run(ctx context.Context, ledger client, cfg config, concurrency int) ([]sample, uint64) { + var sequence atomic.Uint64 + results := make(chan []sample, concurrency) + var workers sync.WaitGroup + workers.Add(concurrency) + for range concurrency { + go func() { + defer workers.Done() + workerSamples := make([]sample, 0, 256) + for ctx.Err() == nil { + transaction := sequence.Add(1) + workerSamples = append(workerSamples, runTransaction(ctx, ledger, cfg, transaction)...) + } + results <- workerSamples + }() + } + workers.Wait() + close(results) + + all := make([]sample, 0) + for workerSamples := range results { + all = append(all, workerSamples...) + } + return all, sequence.Load() +} + +func runTransaction(ctx context.Context, ledger client, cfg config, sequence uint64) []sample { + transactionID := fmt.Sprintf("%s-%d", cfg.RunID, sequence) + senderID := fmt.Sprintf("load-wallet-%d", sequence%uint64(cfg.Wallets)) + receiverID := fmt.Sprintf("load-wallet-%d", (sequence+1)%uint64(cfg.Wallets)) + correlationID := "load-correlation-" + transactionID + blockchainHash := transactionHash(transactionID) + occurredAt := timestamppb.Now() + + created := &ledgerv1.AppendTransactionEventRequest{ + SourceService: "gl-loadtest", + IdempotencyKey: "load:" + transactionID + ":event:v1", + SourceTransactionId: transactionID, + TrackingCode: "load-" + strconv.FormatUint(sequence, 10), + EventVersion: 1, + State: ledgerv1.TransactionState_TRANSACTION_STATE_CREATED, + OccurredAt: occurredAt, + CorrelationId: correlationID, + ActorId: senderID, + Metadata: map[string]string{"load_test": cfg.RunID}, + } + samples := []sample{measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error { + _, err := ledger.AppendTransactionEvent(callCtx, created) + return err + })} + + journalRequest := &ledgerv1.AppendJournalRequest{ + SourceService: "gl-loadtest", + IdempotencyKey: "load:" + transactionID + ":transfer:v1", + SourceTransactionId: transactionID, + TrackingCode: created.TrackingCode, + EffectKind: "internal-transfer", + EventVersion: 1, + OccurredAt: occurredAt, + CorrelationId: correlationID, + ActorId: senderID, + Blockchain: &ledgerv1.BlockchainReference{ + Network: "load-test", + TransactionHash: blockchainHash, + LedgerSequence: strconv.FormatUint(sequence, 10), + }, + Metadata: map[string]string{"load_test": cfg.RunID}, + Entries: []*ledgerv1.JournalEntry{ + { + LineNumber: 1, + Account: walletAccount(senderID, cfg.AssetID), + Amount: "-" + cfg.Amount, + Description: "load-test transfer debit", + }, + { + LineNumber: 2, + Account: walletAccount(receiverID, cfg.AssetID), + Amount: cfg.Amount, + Description: "load-test transfer credit", + }, + }, + } + var journalID string + samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_journal", func(callCtx context.Context) error { + response, err := ledger.AppendJournal(callCtx, journalRequest) + if err == nil && response.GetJournal() != nil { + journalID = response.GetJournal().GetJournalId() + } + return err + })) + + if journalID != "" { + samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_journal", func(callCtx context.Context) error { + _, err := ledger.GetJournal(callCtx, &ledgerv1.GetJournalRequest{ + Lookup: &ledgerv1.GetJournalRequest_JournalId{JournalId: journalID}, + }) + return err + })) + } + + samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_balance", func(callCtx context.Context) error { + _, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: walletAccount(receiverID, cfg.AssetID)}) + return err + })) + + successful := &ledgerv1.AppendTransactionEventRequest{ + SourceService: "gl-loadtest", + IdempotencyKey: "load:" + transactionID + ":event:v2", + SourceTransactionId: transactionID, + TrackingCode: created.TrackingCode, + EventVersion: 2, + State: ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL, + OccurredAt: timestamppb.Now(), + CorrelationId: correlationID, + ActorId: senderID, + Blockchain: journalRequest.Blockchain, + Metadata: map[string]string{"load_test": cfg.RunID}, + } + samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error { + _, err := ledger.AppendTransactionEvent(callCtx, successful) + return err + })) + return samples +} + +func walletAccount(ownerID string, assetID int64) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: ownerID, + AssetId: assetID, + } +} + +func transactionHash(transactionID string) string { + value := sha256.Sum256([]byte(transactionID)) + return hex.EncodeToString(value[:]) +} + +func measure(ctx context.Context, timeout time.Duration, operation string, call func(context.Context) error) sample { + callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer cancel() + started := time.Now() + err := call(callCtx) + return sample{Operation: operation, Latency: time.Since(started), Err: err} +} + +func printSummary(samples []sample, transactions uint64, elapsed time.Duration) bool { + stats := make(map[string]*operationStats) + var failed bool + for _, value := range samples { + operation := stats[value.Operation] + if operation == nil { + operation = &operationStats{} + stats[value.Operation] = operation + } + operation.Requests++ + operation.Latency = append(operation.Latency, value.Latency) + if value.Err != nil { + operation.Errors++ + failed = true + } + } + + names := make([]string, 0, len(stats)) + for name := range stats { + names = append(names, name) + } + sort.Strings(names) + fmt.Printf("transactions: %d (%.1f tx/s)\n", transactions, rate(float64(transactions), elapsed)) + fmt.Printf("%-18s %10s %8s %10s %12s %12s %12s\n", "operation", "requests", "errors", "req/s", "p50", "p95", "p99") + for _, name := range names { + value := stats[name] + fmt.Printf("%-18s %10d %8d %10.1f %12s %12s %12s\n", + name, + value.Requests, + value.Errors, + rate(float64(value.Requests), elapsed), + percentile(value.Latency, 50), + percentile(value.Latency, 95), + percentile(value.Latency, 99), + ) + } + return failed +} + +func rate(count float64, elapsed time.Duration) float64 { + if elapsed <= 0 { + return 0 + } + return count / elapsed.Seconds() +} + +func percentile(values []time.Duration, percent int) time.Duration { + if len(values) == 0 { + return 0 + } + ordered := append([]time.Duration(nil), values...) + sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] }) + index := (len(ordered)*percent + 99) / 100 + if index < 1 { + index = 1 + } + return ordered[index-1] +} diff --git a/cmd/gl-loadtest/main_test.go b/cmd/gl-loadtest/main_test.go new file mode 100644 index 0000000..faca7f5 --- /dev/null +++ b/cmd/gl-loadtest/main_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "context" + "testing" + "time" + + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" +) + +type clientStub struct { + journal *ledgerv1.AppendJournalRequest + events []*ledgerv1.AppendTransactionEventRequest + balance *ledgerv1.GetBalanceRequest + lookup *ledgerv1.GetJournalRequest +} + +func (s *clientStub) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) { + s.journal = request + return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: "journal-1"}}, nil +} + +func (s *clientStub) AppendTransactionEvent(_ context.Context, request *ledgerv1.AppendTransactionEventRequest, _ ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error) { + s.events = append(s.events, request) + return &ledgerv1.AppendTransactionEventResponse{}, nil +} + +func (s *clientStub) GetJournal(_ context.Context, request *ledgerv1.GetJournalRequest, _ ...grpc.CallOption) (*ledgerv1.Journal, error) { + s.lookup = request + return &ledgerv1.Journal{JournalId: "journal-1"}, nil +} + +func (s *clientStub) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) { + s.balance = request + return &ledgerv1.GetBalanceResponse{Balance: "1"}, nil +} + +func TestRunTransactionCreatesWalletAccountsAndFullLifecycle(t *testing.T) { + ledger := &clientStub{} + samples := runTransaction(context.Background(), ledger, config{ + RunID: "test-run", Wallets: 10, AssetID: 7, Amount: "2.5", RequestTimeout: time.Second, + }, 3) + + if len(samples) != 5 { + t.Fatalf("expected five RPC measurements, got %d", len(samples)) + } + if ledger.journal == nil || len(ledger.journal.Entries) != 2 { + t.Fatal("balanced journal was not appended") + } + debit, credit := ledger.journal.Entries[0], ledger.journal.Entries[1] + if debit.Amount != "-2.5" || credit.Amount != "2.5" { + t.Fatalf("unexpected journal amounts: %q and %q", debit.Amount, credit.Amount) + } + if debit.Account.OwnerId == credit.Account.OwnerId || debit.Account.AssetId != 7 || credit.Account.AssetId != 7 { + t.Fatalf("unexpected simulated wallets: debit=%+v credit=%+v", debit.Account, credit.Account) + } + if len(ledger.events) != 2 || ledger.events[0].State != ledgerv1.TransactionState_TRANSACTION_STATE_CREATED || ledger.events[1].State != ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL { + t.Fatalf("unexpected transaction lifecycle: %+v", ledger.events) + } + if ledger.lookup.GetJournalId() != "journal-1" || ledger.balance.Account.OwnerId != credit.Account.OwnerId { + t.Fatal("journal and balance reads did not follow the write") + } +} + +func TestTransactionHashIsStableAndUnique(t *testing.T) { + first := transactionHash("run-1") + if len(first) != 64 || first != transactionHash("run-1") || first == transactionHash("run-2") { + t.Fatalf("unexpected transaction hashes: %q", first) + } +} + +func TestPercentileUsesNearestRank(t *testing.T) { + values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond} + if got := percentile(values, 95); got != 4*time.Millisecond { + t.Fatalf("unexpected p95: %s", got) + } +} diff --git a/cmd/gl-redistribution-loadtest/README.md b/cmd/gl-redistribution-loadtest/README.md new file mode 100644 index 0000000..d57bc6f --- /dev/null +++ b/cmd/gl-redistribution-loadtest/README.md @@ -0,0 +1,28 @@ +# GL redistribution load test + +This fixed-count scenario creates ten run-scoped user accounts for an operator- +selected asset. It verifies that all ten start at zero, funds only the first +person with 10,000, and then submits 10,000,000 user-to-user transfers. + +The first nine transfers give every other person 45. The remaining transfers +use reproducible random senders, receivers, and positive integer amounts while +reserving a minimum balance of 45 for every person. The test finishes by reading +all ten balances from GL and fails unless every balance is at least 45 and their +sum is exactly 10,000. + +Start GL, then run: + +```bash +go run ./cmd/gl-redistribution-loadtest +Asset number: 7 +``` + +For automation, pass the asset without the prompt: + +```bash +go run ./cmd/gl-redistribution-loadtest -asset-id 7 -concurrency 32 +``` + +The defaults are 10,000,000 transfers against `127.0.0.1:8600`. Use a fresh +`-run-id` for every dataset; one is generated automatically. `-transactions` +can be reduced for a smoke test, and `-seed` makes the random sequence repeatable. diff --git a/cmd/gl-redistribution-loadtest/main.go b/cmd/gl-redistribution-loadtest/main.go new file mode 100644 index 0000000..9738f90 --- /dev/null +++ b/cmd/gl-redistribution-loadtest/main.go @@ -0,0 +1,458 @@ +// Command gl-redistribution-loadtest proves balance conservation while applying +// a fixed number of random transfers between ten isolated test users. +package main + +import ( + "context" + "flag" + "fmt" + "io" + mathrand "math/rand" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + personCount = 10 + initialSupply = int64(10_000) + minimumBalance = int64(45) + defaultTransactions = uint64(10_000_000) + seedTransactions = uint64(personCount - 1) +) + +type client interface { + Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error) + AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) + GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) +} + +type config struct { + RunID string + AssetID int64 + Transactions uint64 + Concurrency int + RequestTimeout time.Duration + RandomSeed int64 +} + +type transfer struct { + Sender int + Receiver int + Amount int64 +} + +type balanceState struct { + mu sync.Mutex + balances [personCount]int64 +} + +type metrics struct { + attempted atomic.Uint64 + succeeded atomic.Uint64 + errors atomic.Uint64 + latencyNanos atomic.Int64 + maxNanos atomic.Int64 +} + +func main() { + os.Exit(runCLI(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func runCLI(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("gl-redistribution-loadtest", flag.ContinueOnError) + flags.SetOutput(stderr) + target := flags.String("target", "127.0.0.1:8600", "GL gRPC target") + assetID := flags.Int64("asset-id", 0, "asset number; prompted when omitted") + transactions := flags.Uint64("transactions", defaultTransactions, "total user-to-user transfers") + concurrency := flags.Int("concurrency", 32, "number of concurrent transfer workers") + requestTimeout := flags.Duration("request-timeout", 10*time.Second, "timeout for each gRPC call") + runID := flags.String("run-id", "", "isolated idempotency namespace; generated when empty") + randomSeed := flags.Int64("seed", 0, "random seed; generated when zero") + if err := flags.Parse(args); err != nil { + return 2 + } + if *transactions < seedTransactions || *concurrency < 1 || *requestTimeout <= 0 { + fmt.Fprintf(stderr, "transactions must be at least %d; concurrency and request-timeout must be positive\n", seedTransactions) + return 2 + } + + resolvedAssetID, err := resolveAssetID(*assetID, stdin, stdout) + if err != nil { + fmt.Fprintf(stderr, "asset number: %v\n", err) + return 2 + } + if *runID == "" { + *runID = "gl-redistribution-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36) + } + if *randomSeed == 0 { + *randomSeed = time.Now().UnixNano() + } + + connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + fmt.Fprintf(stderr, "create gRPC client: %v\n", err) + return 2 + } + defer connection.Close() + ledger := ledgerv1.NewGeneralLedgerServiceClient(connection) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + cfg := config{ + RunID: *runID, + AssetID: resolvedAssetID, + Transactions: *transactions, + Concurrency: *concurrency, + RequestTimeout: *requestTimeout, + RandomSeed: *randomSeed, + } + if err := execute(ctx, ledger, cfg, stdout); err != nil { + fmt.Fprintf(stderr, "redistribution load test failed: %v\n", err) + return 1 + } + return 0 +} + +func resolveAssetID(value int64, input io.Reader, output io.Writer) (int64, error) { + if value > 0 { + return value, nil + } + if value < 0 { + return 0, fmt.Errorf("must be positive") + } + fmt.Fprint(output, "Asset number: ") + var text string + if _, err := fmt.Fscan(input, &text); err != nil { + return 0, fmt.Errorf("read input: %w", err) + } + assetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64) + if err != nil || assetID <= 0 { + return 0, fmt.Errorf("must be a positive integer") + } + return assetID, nil +} + +func execute(ctx context.Context, ledger client, cfg config, output io.Writer) error { + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + health, err := ledger.Health(callCtx, &basev1.Empty{}) + cancel() + if err != nil { + return fmt.Errorf("health check: %w", err) + } + if !health.GetServing() || !health.GetDatabaseReady() { + return fmt.Errorf("GL is not ready: %v", health) + } + + people := personIDs(cfg.RunID) + startingBalances, err := readBalances(ctx, ledger, cfg, people) + if err != nil { + return fmt.Errorf("read unfunded balances: %w", err) + } + if err := verifyUnfunded(startingBalances); err != nil { + return err + } + if err := fundFirstPerson(ctx, ledger, cfg, people[0]); err != nil { + return fmt.Errorf("fund first person: %w", err) + } + initialBalances, err := readBalances(ctx, ledger, cfg, people) + if err != nil { + return fmt.Errorf("read initial balances: %w", err) + } + if err := verifyInitial(initialBalances); err != nil { + return err + } + + fmt.Fprintf(output, "GL redistribution load test: target users=%d asset=%d transfers=%d workers=%d seed=%d run=%s\n", + personCount, cfg.AssetID, cfg.Transactions, cfg.Concurrency, cfg.RandomSeed, cfg.RunID) + fmt.Fprintln(output, "initial balances: [10000 0 0 0 0 0 0 0 0 0]") + + stats := &metrics{} + started := time.Now() + balances := initialBalances + for person := 1; person < personCount; person++ { + value := transfer{Sender: 0, Receiver: person, Amount: minimumBalance} + startedCall := time.Now() + err := appendTransfer(ctx, ledger, cfg, uint64(person), people, value) + stats.observe(time.Since(startedCall), err) + if err != nil { + return fmt.Errorf("seed person %d: %w", person+1, err) + } + balances[0] -= minimumBalance + balances[person] += minimumBalance + } + + state := &balanceState{balances: balances} + if err := runRandomTransfers(ctx, ledger, cfg, people, state, stats, output); err != nil { + printSummary(output, stats, time.Since(started)) + return err + } + elapsed := time.Since(started) + printSummary(output, stats, elapsed) + + finalBalances, err := readBalances(ctx, ledger, cfg, people) + if err != nil { + return fmt.Errorf("read final balances: %w", err) + } + if err := verifyFinal(finalBalances); err != nil { + return err + } + fmt.Fprintf(output, "final balances: %v\n", finalBalances) + fmt.Fprintf(output, "PASS: all %d people have at least %d; total=%d\n", personCount, minimumBalance, initialSupply) + return nil +} + +func personIDs(runID string) [personCount]string { + var people [personCount]string + for index := range people { + people[index] = fmt.Sprintf("%s-person-%02d", runID, index+1) + } + return people +} + +func readBalances(ctx context.Context, ledger client, cfg config, people [personCount]string) ([personCount]int64, error) { + var balances [personCount]int64 + for index, person := range people { + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + response, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: personAccount(person, cfg.AssetID)}) + cancel() + if err != nil { + return balances, fmt.Errorf("person %d: %w", index+1, err) + } + if response == nil { + return balances, fmt.Errorf("person %d: empty response", index+1) + } + balance, err := strconv.ParseInt(response.GetBalance(), 10, 64) + if err != nil { + return balances, fmt.Errorf("person %d: invalid integer balance %q", index+1, response.GetBalance()) + } + balances[index] = balance + } + return balances, nil +} + +func verifyUnfunded(balances [personCount]int64) error { + for index, balance := range balances { + if balance != 0 { + return fmt.Errorf("person %d must start unfunded; got %d (use a fresh run-id)", index+1, balance) + } + } + return nil +} + +func verifyInitial(balances [personCount]int64) error { + if balances[0] != initialSupply { + return fmt.Errorf("person 1 initial balance: got %d, want %d", balances[0], initialSupply) + } + for index := 1; index < personCount; index++ { + if balances[index] != 0 { + return fmt.Errorf("person %d initial balance: got %d, want 0", index+1, balances[index]) + } + } + return nil +} + +func verifyFinal(balances [personCount]int64) error { + var total int64 + for index, balance := range balances { + if balance < minimumBalance { + return fmt.Errorf("person %d final balance %d is below %d", index+1, balance, minimumBalance) + } + total += balance + } + if total != initialSupply { + return fmt.Errorf("final balance sum: got %d, want %d", total, initialSupply) + } + return nil +} + +func fundFirstPerson(ctx context.Context, ledger client, cfg config, firstPerson string) error { + request := &ledgerv1.AppendJournalRequest{ + SourceService: "gl-redistribution-loadtest", + IdempotencyKey: "redistribution:" + cfg.RunID + ":fund:v1", + SourceTransactionId: cfg.RunID + "-fund", + TrackingCode: cfg.RunID + "-fund", + EffectKind: "load-test-funding", + EventVersion: 1, + OccurredAt: timestamppb.Now(), + ActorId: cfg.RunID, + Metadata: map[string]string{"load_test": cfg.RunID, "phase": "funding"}, + Entries: []*ledgerv1.JournalEntry{ + {LineNumber: 1, Account: treasuryAccount(cfg.RunID, cfg.AssetID), Amount: "-10000", Description: "load-test source"}, + {LineNumber: 2, Account: personAccount(firstPerson, cfg.AssetID), Amount: "10000", Description: "initial person balance"}, + }, + } + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + defer cancel() + _, err := ledger.AppendJournal(callCtx, request) + return err +} + +func runRandomTransfers(ctx context.Context, ledger client, cfg config, people [personCount]string, state *balanceState, stats *metrics, output io.Writer) error { + if cfg.Transactions == seedTransactions { + return nil + } + workerCtx, cancel := context.WithCancel(ctx) + defer cancel() + + var sequence atomic.Uint64 + sequence.Store(seedTransactions) + var workers sync.WaitGroup + var firstErr error + var errorOnce sync.Once + workers.Add(cfg.Concurrency) + for worker := range cfg.Concurrency { + go func(worker int) { + defer workers.Done() + random := mathrand.New(mathrand.NewSource(cfg.RandomSeed + int64(worker+1)*7919)) + for workerCtx.Err() == nil { + number := sequence.Add(1) + if number > cfg.Transactions { + return + } + value := state.reserve(random) + started := time.Now() + err := appendTransfer(workerCtx, ledger, cfg, number, people, value) + stats.observe(time.Since(started), err) + if err != nil { + state.rollback(value) + errorOnce.Do(func() { + firstErr = fmt.Errorf("transfer %d: %w", number, err) + cancel() + }) + return + } + succeeded := stats.succeeded.Load() + if succeeded%1_000_000 == 0 { + fmt.Fprintf(output, "progress: %d/%d transfers\n", succeeded, cfg.Transactions) + } + } + }(worker) + } + workers.Wait() + if firstErr != nil { + return firstErr + } + if err := ctx.Err(); err != nil { + return err + } + if succeeded := stats.succeeded.Load(); succeeded != cfg.Transactions { + return fmt.Errorf("completed %d transfers, want %d", succeeded, cfg.Transactions) + } + return nil +} + +func (s *balanceState) reserve(random *mathrand.Rand) transfer { + s.mu.Lock() + defer s.mu.Unlock() + + eligible := make([]int, 0, personCount) + for index, balance := range s.balances { + if balance > minimumBalance { + eligible = append(eligible, index) + } + } + sender := eligible[random.Intn(len(eligible))] + receiver := random.Intn(personCount - 1) + if receiver >= sender { + receiver++ + } + spendable := s.balances[sender] - minimumBalance + amount := random.Int63n(spendable) + 1 + s.balances[sender] -= amount + s.balances[receiver] += amount + return transfer{Sender: sender, Receiver: receiver, Amount: amount} +} + +func (s *balanceState) rollback(value transfer) { + s.mu.Lock() + defer s.mu.Unlock() + s.balances[value.Sender] += value.Amount + s.balances[value.Receiver] -= value.Amount +} + +func appendTransfer(ctx context.Context, ledger client, cfg config, sequence uint64, people [personCount]string, value transfer) error { + number := strconv.FormatUint(sequence, 10) + amount := strconv.FormatInt(value.Amount, 10) + transactionID := cfg.RunID + "-transfer-" + number + request := &ledgerv1.AppendJournalRequest{ + SourceService: "gl-redistribution-loadtest", + IdempotencyKey: "redistribution:" + cfg.RunID + ":transfer:" + number + ":v1", + SourceTransactionId: transactionID, + TrackingCode: transactionID, + EffectKind: "random-internal-transfer", + EventVersion: 1, + OccurredAt: timestamppb.Now(), + CorrelationId: cfg.RunID, + ActorId: people[value.Sender], + Metadata: map[string]string{"load_test": cfg.RunID, "sequence": number}, + Entries: []*ledgerv1.JournalEntry{ + {LineNumber: 1, Account: personAccount(people[value.Sender], cfg.AssetID), Amount: "-" + amount, Description: "random transfer debit"}, + {LineNumber: 2, Account: personAccount(people[value.Receiver], cfg.AssetID), Amount: amount, Description: "random transfer credit"}, + }, + } + callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout) + defer cancel() + _, err := ledger.AppendJournal(callCtx, request) + return err +} + +func personAccount(ownerID string, assetID int64) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, + OwnerType: "user", + OwnerId: ownerID, + AssetId: assetID, + } +} + +func treasuryAccount(runID string, assetID int64) *ledgerv1.AccountReference { + return &ledgerv1.AccountReference{ + AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY, + OwnerType: "load-test", + OwnerId: runID, + AssetId: assetID, + } +} + +func (m *metrics) observe(latency time.Duration, err error) { + m.attempted.Add(1) + m.latencyNanos.Add(latency.Nanoseconds()) + for { + current := m.maxNanos.Load() + if latency.Nanoseconds() <= current || m.maxNanos.CompareAndSwap(current, latency.Nanoseconds()) { + break + } + } + if err != nil { + m.errors.Add(1) + return + } + m.succeeded.Add(1) +} + +func printSummary(output io.Writer, stats *metrics, elapsed time.Duration) { + attempted := stats.attempted.Load() + average := time.Duration(0) + if attempted > 0 { + average = time.Duration(stats.latencyNanos.Load() / int64(attempted)) + } + rate := float64(0) + if elapsed > 0 { + rate = float64(stats.succeeded.Load()) / elapsed.Seconds() + } + fmt.Fprintf(output, "transfers: attempted=%d succeeded=%d errors=%d elapsed=%s rate=%.1f tx/s avg=%s max=%s\n", + attempted, stats.succeeded.Load(), stats.errors.Load(), elapsed.Round(time.Millisecond), rate, average, time.Duration(stats.maxNanos.Load())) +} diff --git a/cmd/gl-redistribution-loadtest/main_test.go b/cmd/gl-redistribution-loadtest/main_test.go new file mode 100644 index 0000000..5500693 --- /dev/null +++ b/cmd/gl-redistribution-loadtest/main_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "context" + "fmt" + mathrand "math/rand" + "strconv" + "strings" + "sync" + "testing" + "time" + + basev1 "gl/gen/base/v1" + ledgerv1 "gl/gen/ledger/v1" + + "google.golang.org/grpc" +) + +type memoryClient struct { + mu sync.Mutex + balances map[string]int64 + journals int +} + +func newMemoryClient() *memoryClient { + return &memoryClient{balances: make(map[string]int64)} +} + +func (c *memoryClient) Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error) { + return &ledgerv1.HealthResponse{Serving: true, DatabaseReady: true}, nil +} + +func (c *memoryClient) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + for _, entry := range request.GetEntries() { + amount, err := strconv.ParseInt(entry.GetAmount(), 10, 64) + if err != nil { + return nil, err + } + c.balances[accountKey(entry.GetAccount())] += amount + } + c.journals++ + return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: fmt.Sprintf("journal-%d", c.journals)}}, nil +} + +func (c *memoryClient) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + return &ledgerv1.GetBalanceResponse{Account: request.GetAccount(), Balance: strconv.FormatInt(c.balances[accountKey(request.GetAccount())], 10)}, nil +} + +func accountKey(account *ledgerv1.AccountReference) string { + return fmt.Sprintf("%d/%s/%s/%d", account.GetAccountClass(), account.GetOwnerType(), account.GetOwnerId(), account.GetAssetId()) +} + +func TestResolveAssetIDPromptsWhenMissing(t *testing.T) { + var output bytes.Buffer + assetID, err := resolveAssetID(0, strings.NewReader("42\n"), &output) + if err != nil { + t.Fatal(err) + } + if assetID != 42 || output.String() != "Asset number: " { + t.Fatalf("asset=%d prompt=%q", assetID, output.String()) + } +} + +func TestResolveAssetIDUsesFlagWithoutPrompt(t *testing.T) { + var output bytes.Buffer + assetID, err := resolveAssetID(7, strings.NewReader(""), &output) + if err != nil { + t.Fatal(err) + } + if assetID != 7 || output.Len() != 0 { + t.Fatalf("asset=%d prompt=%q", assetID, output.String()) + } +} + +func TestRandomReservationsPreserveSupplyAndMinimum(t *testing.T) { + state := &balanceState{balances: [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45}} + random := mathrand.New(mathrand.NewSource(1234)) + distinctAmounts := make(map[int64]struct{}) + for range 100_000 { + value := state.reserve(random) + distinctAmounts[value.Amount] = struct{}{} + if value.Sender == value.Receiver || value.Amount <= 0 { + t.Fatalf("invalid transfer: %+v", value) + } + } + + var total int64 + for index, balance := range state.balances { + if balance < minimumBalance { + t.Fatalf("person %d balance %d is below minimum", index+1, balance) + } + total += balance + } + if total != initialSupply { + t.Fatalf("total=%d, want %d", total, initialSupply) + } + if len(distinctAmounts) < 2 { + t.Fatalf("expected random values, got %v", distinctAmounts) + } +} + +func TestVerifyInitialAndFinalBalances(t *testing.T) { + initial := [personCount]int64{10_000} + if err := verifyInitial(initial); err != nil { + t.Fatal(err) + } + final := [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45} + if err := verifyFinal(final); err != nil { + t.Fatal(err) + } + final[9] = 44 + if err := verifyFinal(final); err == nil { + t.Fatal("expected minimum-balance failure") + } +} + +func TestPersonIDsAreIsolatedByRun(t *testing.T) { + first := personIDs("run-a") + second := personIDs("run-b") + if first[0] == second[0] || first[0] == first[1] { + t.Fatalf("IDs are not isolated: %q %q %q", first[0], second[0], first[1]) + } +} + +func TestExecuteFundsRedistributesAndVerifiesConservation(t *testing.T) { + ledger := newMemoryClient() + var output bytes.Buffer + err := execute(context.Background(), ledger, config{ + RunID: "test-run", + AssetID: 17, + Transactions: 1000, + Concurrency: 4, + RequestTimeout: time.Second, + RandomSeed: 99, + }, &output) + if err != nil { + t.Fatal(err) + } + if ledger.journals != 1001 { + t.Fatalf("journals=%d, want one funding journal and 1000 transfers", ledger.journals) + } + if !strings.Contains(output.String(), "PASS: all 10 people have at least 45; total=10000") { + t.Fatalf("missing successful verification in output:\n%s", output.String()) + } +} From 675def5fe669a8b3865dc9c07d1a8c275e6bd670a3c6c28688e5bace51f08158 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 15 Aug 2026 00:50:25 +0330 Subject: [PATCH 09/20] ci(gl): add Gitea delivery workflows --- .gitea/actions/deploy-ssh/action.yml | 62 +++++++++++++++++++++ .gitea/actions/docker-build-push/action.yml | 31 +++++++++++ .gitea/actions/docker-login/action.yml | 25 +++++++++ .gitea/actions/notify-telegram/action.yml | 38 +++++++++++++ .gitea/workflows/ci-dev.yaml | 49 ++++++++++++++++ .gitea/workflows/ci-stage.yaml | 49 ++++++++++++++++ .gitea/workflows/ci.yaml | 49 ++++++++++++++++ 7 files changed, 303 insertions(+) create mode 100644 .gitea/actions/deploy-ssh/action.yml create mode 100644 .gitea/actions/docker-build-push/action.yml create mode 100644 .gitea/actions/docker-login/action.yml create mode 100644 .gitea/actions/notify-telegram/action.yml create mode 100644 .gitea/workflows/ci-dev.yaml create mode 100644 .gitea/workflows/ci-stage.yaml create mode 100644 .gitea/workflows/ci.yaml diff --git a/.gitea/actions/deploy-ssh/action.yml b/.gitea/actions/deploy-ssh/action.yml new file mode 100644 index 0000000..f257c91 --- /dev/null +++ b/.gitea/actions/deploy-ssh/action.yml @@ -0,0 +1,62 @@ +name: Deploy over SSH +description: SSH into server and update compose services +inputs: + ssh_key: + description: Private SSH key content + required: true + user: + description: SSH username + required: true + host: + description: SSH hostname or IP + required: true + compose_file: + description: Path to compose file on remote server + required: true + compose_project_name: + description: Compose project name on remote server + required: true + services: + description: Space-separated compose services + required: true + registry: + description: OCI registry hostname + required: false + default: oci.reg.darano.ir + registry_username: + description: OCI registry username + required: false + default: admin + registry_password: + description: OCI registry password + required: true +runs: + using: composite + steps: + - name: Deploy over SSH + shell: bash + env: + SSH_KEY: ${{ inputs.ssh_key }} + REMOTE_USER: ${{ inputs.user }} + REMOTE_HOST: ${{ inputs.host }} + COMPOSE_FILE: ${{ inputs.compose_file }} + COMPOSE_PROJECT_NAME: ${{ inputs.compose_project_name }} + SERVICES: ${{ inputs.services }} + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.registry_username }} + REGISTRY_PASSWORD: ${{ inputs.registry_password }} + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + printf '%s' "$REGISTRY_PASSWORD" | ssh -i ~/.ssh/deploy_key \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + "$REMOTE_USER@$REMOTE_HOST" \ + "docker login '$REGISTRY' -u '$REGISTRY_USERNAME' --password-stdin && \ + export COMPOSE_PROJECT_NAME='$COMPOSE_PROJECT_NAME' && \ + docker compose -f '$COMPOSE_FILE' up $SERVICES -d --pull=always; \ + rc=\$?; \ + docker logout '$REGISTRY'; \ + exit \$rc" diff --git a/.gitea/actions/docker-build-push/action.yml b/.gitea/actions/docker-build-push/action.yml new file mode 100644 index 0000000..8d39666 --- /dev/null +++ b/.gitea/actions/docker-build-push/action.yml @@ -0,0 +1,31 @@ +name: Docker Build +description: Build image and load it into the local Docker daemon +inputs: + image: + description: Full image name without a tag + required: true + branch: + description: Branch tag + required: true + commit_sha: + description: Commit SHA tag + required: true +runs: + using: composite + steps: + - name: Build + shell: bash + env: + IMAGE: ${{ inputs.image }} + BRANCH: ${{ inputs.branch }} + COMMIT_SHA: ${{ inputs.commit_sha }} + run: | + set -euo pipefail + + docker buildx build \ + -f build/Dockerfile \ + --build-arg "GO_PROXY=https://go.reg.darano.ir" \ + --load \ + -t "$IMAGE:$BRANCH" \ + -t "$IMAGE:$COMMIT_SHA" \ + . diff --git a/.gitea/actions/docker-login/action.yml b/.gitea/actions/docker-login/action.yml new file mode 100644 index 0000000..1cc16f1 --- /dev/null +++ b/.gitea/actions/docker-login/action.yml @@ -0,0 +1,25 @@ +name: Docker Login +description: Login to OCI registry +inputs: + registry: + description: Registry hostname + required: false + default: oci.reg.darano.ir + username: + description: Registry username + required: false + default: admin + password: + description: Registry password + required: true +runs: + using: composite + steps: + - name: Login to registry + shell: bash + env: + REG_USER: ${{ inputs.username }} + REG_PASS: ${{ inputs.password }} + REGISTRY: ${{ inputs.registry }} + run: | + echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin diff --git a/.gitea/actions/notify-telegram/action.yml b/.gitea/actions/notify-telegram/action.yml new file mode 100644 index 0000000..82f5c2f --- /dev/null +++ b/.gitea/actions/notify-telegram/action.yml @@ -0,0 +1,38 @@ +name: Notify Telegram +description: Send CI/CD status message via Bale bot +inputs: + bot_token: + description: Bale bot token + required: true + chat_id: + description: Target chat ID + required: true + status: + description: Job status + required: true + repository: + description: Repository name + required: true + sha: + description: Commit SHA + required: true +runs: + using: composite + steps: + - name: Send notification + shell: bash + env: + BOT_TOKEN: ${{ inputs.bot_token }} + CHAT_ID: ${{ inputs.chat_id }} + STATUS: ${{ inputs.status }} + REPO: ${{ inputs.repository }} + SHA: ${{ inputs.sha }} + run: | + if [ "$STATUS" = "success" ]; then + MSG="✅ CI/CD passed: ${REPO}@${SHA}" + else + MSG="❌ CI/CD failed: ${REPO}@${SHA}" + fi + curl -s -X POST "https://tapi.bale.ai/bot${BOT_TOKEN}/sendMessage" \ + -d chat_id="${CHAT_ID}" \ + -d text="$MSG" diff --git a/.gitea/workflows/ci-dev.yaml b/.gitea/workflows/ci-dev.yaml new file mode 100644 index 0000000..66fcfe3 --- /dev/null +++ b/.gitea/workflows/ci-dev.yaml @@ -0,0 +1,49 @@ +--- +name: CI/CD Dev +on: + push: + branches: + - dev +jobs: + dev: + runs-on: ubuntu-latest + steps: + - name: Checkout Git + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + submodules: recursive + - name: Login to registry + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + - name: Build + uses: ./.gitea/actions/docker-build-push + with: + image: oci.reg.darano.ir/gl/app + branch: dev + commit_sha: ${{ gitea.sha }} + - name: Push + run: | + docker push oci.reg.darano.ir/gl/app:dev + docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }} + - name: Deploy + uses: ./.gitea/actions/deploy-ssh + with: + ssh_key: ${{ secrets.DEV_SERVER_KEY }} + user: ${{ secrets.DEV_SERVER_USER }} + host: ${{ secrets.DEV_SERVER_HOST }} + compose_file: /services/srv-dev/compose.yml + compose_project_name: srv-dev + services: gl + registry_password: ${{ secrets.REG_PASS }} + - name: Notify Telegram + if: always() + uses: ./.gitea/actions/notify-telegram + with: + bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + chat_id: ${{ secrets.TELEGRAM_CHAT_ID }} + status: ${{ job.status }} + repository: ${{ gitea.repository }} + sha: ${{ gitea.sha }} diff --git a/.gitea/workflows/ci-stage.yaml b/.gitea/workflows/ci-stage.yaml new file mode 100644 index 0000000..3ffc818 --- /dev/null +++ b/.gitea/workflows/ci-stage.yaml @@ -0,0 +1,49 @@ +--- +name: CI/CD Stage +on: + push: + branches: + - stage +jobs: + stage: + runs-on: ubuntu-latest + steps: + - name: Checkout Git + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + submodules: recursive + - name: Login to registry + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + - name: Build + uses: ./.gitea/actions/docker-build-push + with: + image: oci.reg.darano.ir/gl/app + branch: stage + commit_sha: ${{ gitea.sha }} + - name: Push + run: | + docker push oci.reg.darano.ir/gl/app:stage + docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }} + - name: Deploy + uses: ./.gitea/actions/deploy-ssh + with: + ssh_key: ${{ secrets.STAGE_SERVER_KEY }} + user: ${{ secrets.STAGE_SERVER_USER }} + host: ${{ secrets.STAGE_SERVER_HOST }} + compose_file: /services/srv-stage/compose.yml + compose_project_name: srv-stage + services: gl + registry_password: ${{ secrets.REG_PASS }} + - name: Notify Telegram + if: always() + uses: ./.gitea/actions/notify-telegram + with: + bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + chat_id: ${{ secrets.TELEGRAM_CHAT_ID }} + status: ${{ job.status }} + repository: ${{ gitea.repository }} + sha: ${{ gitea.sha }} diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..ba17ec1 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,49 @@ +--- +name: CI/CD +on: + push: + branches: + - main +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Checkout Git + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + submodules: recursive + - name: Login to registry + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + - name: Build + uses: ./.gitea/actions/docker-build-push + with: + image: oci.reg.darano.ir/gl/app + branch: main + commit_sha: ${{ gitea.sha }} + - name: Push + run: | + docker push oci.reg.darano.ir/gl/app:main + docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }} + - name: Deploy + uses: ./.gitea/actions/deploy-ssh + with: + ssh_key: ${{ secrets.PROD_SERVER_KEY }} + user: ${{ secrets.PROD_SERVER_USER }} + host: ${{ secrets.PROD_SERVER_HOST }} + compose_file: /services/srv-main/compose.yml + compose_project_name: srv-main + services: gl + registry_password: ${{ secrets.REG_PASS }} + - name: Notify Telegram + if: always() + uses: ./.gitea/actions/notify-telegram + with: + bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + chat_id: ${{ secrets.TELEGRAM_CHAT_ID }} + status: ${{ job.status }} + repository: ${{ gitea.repository }} + sha: ${{ gitea.sha }} From 32e7b6b749e6952c04e7f8633d2e4b7b455e83ab89e6fb24ab24964674d912a0 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 15 Aug 2026 00:53:58 +0330 Subject: [PATCH 10/20] feat: add persistent dashboard dark mode --- README.md | 3 + interface/web/assets.go | 9 + interface/web/assets/theme.js | 54 + interface/web/handler.go | 1 + interface/web/handler_test.go | 17 +- interface/web/i18n.go | 4 + interface/web/templates.templ | 31 +- interface/web/templates_templ.go | 2597 +++++++++++++++--------------- 8 files changed, 1423 insertions(+), 1293 deletions(-) create mode 100644 interface/web/assets/theme.js diff --git a/README.md b/README.md index 5913013..61e7b24 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ processes together. The read-only network explorer is served on `http://localhost:8080` by default: +The header includes English/Persian localization and a persistent light/dark +theme toggle that initially follows the operating-system preference. + - `/` shows network totals and recent committed journals; - `/assets` ranks the top positive user holders for recently active assets; - `/holders` combines a user's available and frozen balance and activity for one asset; diff --git a/interface/web/assets.go b/interface/web/assets.go index 4dcc58b..ee9a90b 100644 --- a/interface/web/assets.go +++ b/interface/web/assets.go @@ -10,7 +10,16 @@ import ( //go:embed assets/favicon.ico var favicon []byte +//go:embed assets/theme.js +var themeScript []byte + func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) { response.Header().Set("Cache-Control", "public, max-age=86400") http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon)) } + +func (h *Handler) theme(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Cache-Control", "public, max-age=3600") + response.Header().Set("Content-Type", "application/javascript; charset=utf-8") + http.ServeContent(response, request, "theme.js", time.Time{}, bytes.NewReader(themeScript)) +} diff --git a/interface/web/assets/theme.js b/interface/web/assets/theme.js new file mode 100644 index 0000000..70e7d34 --- /dev/null +++ b/interface/web/assets/theme.js @@ -0,0 +1,54 @@ +(() => { + const storageKey = "gl-theme"; + const root = document.documentElement; + + const storedTheme = () => { + try { + const value = localStorage.getItem(storageKey); + return value === "dark" || value === "light" ? value : ""; + } catch (_) { + return ""; + } + }; + + const systemTheme = () => + window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + + const updateControls = (theme) => { + document.querySelectorAll("[data-theme-toggle]").forEach((control) => { + const dark = theme === "dark"; + control.setAttribute("aria-pressed", String(dark)); + control.setAttribute("aria-label", dark ? control.dataset.lightLabel : control.dataset.darkLabel); + const icon = control.querySelector("[data-theme-icon]"); + if (icon) icon.textContent = dark ? "☀" : "☾"; + }); + }; + + const applyTheme = (theme) => { + root.dataset.theme = theme; + root.style.colorScheme = theme; + updateControls(theme); + }; + + applyTheme(storedTheme() || systemTheme()); + + document.addEventListener("DOMContentLoaded", () => updateControls(root.dataset.theme)); + document.addEventListener("htmx:afterSwap", () => updateControls(root.dataset.theme)); + document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const control = event.target.closest("[data-theme-toggle]"); + if (!control) return; + const theme = root.dataset.theme === "dark" ? "light" : "dark"; + try { + localStorage.setItem(storageKey, theme); + } catch (_) { + // The selected theme still applies for this page when storage is unavailable. + } + applyTheme(theme); + }); + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener?.("change", () => { + if (!storedTheme()) applyTheme(systemTheme()); + }); +})(); diff --git a/interface/web/handler.go b/interface/web/handler.go index 00e4d26..14aed1d 100644 --- a/interface/web/handler.go +++ b/interface/web/handler.go @@ -61,6 +61,7 @@ type HolderPageData struct { func NewHandler(service Explorer) *Handler { handler := &Handler{explorer: service, routes: http.NewServeMux()} handler.routes.HandleFunc("GET /favicon.ico", handler.favicon) + handler.routes.HandleFunc("GET /theme.js", handler.theme) handler.routes.HandleFunc("GET /{$}", handler.dashboard) handler.routes.HandleFunc("GET /assets", handler.assets) handler.routes.HandleFunc("GET /transactions", handler.transaction) diff --git a/interface/web/handler_test.go b/interface/web/handler_test.go index e6daf0b..f199e8a 100644 --- a/interface/web/handler_test.go +++ b/interface/web/handler_test.go @@ -72,7 +72,7 @@ func TestDashboardRendersFullTemplPage(t *testing.T) { NewHandler(service).ServeHTTP(response, request) body := response.Body.String() - for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { + for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { if !strings.Contains(body, expected) { t.Fatalf("response did not contain %q", expected) } @@ -183,6 +183,21 @@ func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) { } } +func TestThemeScriptIsServedFromEmbeddedAsset(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/theme.js", nil) + response := httptest.NewRecorder() + + NewHandler(&explorerStub{}).ServeHTTP(response, request) + + body := response.Body.String() + if response.Code != http.StatusOK || !strings.Contains(body, "localStorage") || !strings.Contains(body, "prefers-color-scheme") { + t.Fatalf("unexpected theme script response: status=%d body=%s", response.Code, body) + } + if !strings.Contains(response.Header().Get("Content-Type"), "javascript") { + t.Fatalf("unexpected theme script content type: %s", response.Header().Get("Content-Type")) + } +} + func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) { service := &explorerStub{transaction: []ledger.Journal{{ ID: "journal-1", EffectKind: "withdrawal", diff --git a/interface/web/i18n.go b/interface/web/i18n.go index fda10c0..34df9a6 100644 --- a/interface/web/i18n.go +++ b/interface/web/i18n.go @@ -95,6 +95,8 @@ var english = map[string]string{ "assets": "Assets", "accounts": "Accounts", "read_only": "READ ONLY", + "dark_mode": "Use dark mode", + "light_mode": "Use light mode", "general_ledger_explorer": "General ledger explorer", "hero_line_one": "Every movement.", "hero_line_two": "One durable record.", @@ -194,6 +196,8 @@ var persian = map[string]string{ "assets": "دارایی‌ها", "accounts": "حساب‌ها", "read_only": "فقط خواندنی", + "dark_mode": "استفاده از حالت تیره", + "light_mode": "استفاده از حالت روشن", "general_ledger_explorer": "کاوشگر دفتر کل", "hero_line_one": "هر جابه‌جایی.", "hero_line_two": "یک سابقه ماندگار.", diff --git a/interface/web/templates.templ b/interface/web/templates.templ index 75f4ebe..ce90fb9 100644 --- a/interface/web/templates.templ +++ b/interface/web/templates.templ @@ -17,12 +17,14 @@ templ Page(title string, active string, locale Locale, englishURL string, persia { title } · { tr(locale, "site_name") } +
\"\" ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
\"\" ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 130, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -121,7 +121,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_subtitle")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 130, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -139,7 +139,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "overview")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 138, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -157,7 +157,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "overview")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 136, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 140, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -176,7 +176,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 139, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 143, Col: 73} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -194,7 +194,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 141, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 145, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -213,7 +213,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 144, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 148, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -231,7 +231,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 146, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 150, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -250,7 +250,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 149, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 153, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -268,7 +268,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 151, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 155, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -286,91 +286,130 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "read_only")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 153, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 157, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if locale == LocaleEnglish { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "EN ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "EN ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - if locale == LocalePersian { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "فا") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "فا") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" hx-boost=\"false\" lang=\"en\">EN ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "EN ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if locale == LocalePersian { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "فا") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "فا") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -378,33 +417,33 @@ func Page(title string, active string, locale Locale, englishURL string, persian if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "footer_description")) + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "footer_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 170, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 175, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "timezone_notice")) + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "timezone_notice")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 171, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 176, Col: 41} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -428,64 +467,64 @@ func TransactionSearch(value string, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var27 := templ.GetChildren(ctx) + if templ_7745c5c3_Var27 == nil { + templ_7745c5c3_Var27 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" placeholder=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "transaction_placeholder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 184, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" aria-label=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "transaction_hash")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 184, Col: 131} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" autocomplete=\"off\" required> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -509,64 +548,64 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var29 := templ.GetChildren(ctx) - if templ_7745c5c3_Var29 == nil { - templ_7745c5c3_Var29 = templ.NopComponent + templ_7745c5c3_Var32 := templ.GetChildren(ctx) + if templ_7745c5c3_Var32 == nil { + templ_7745c5c3_Var32 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "general_ledger_explorer")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 187, Col: 63} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_one")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 188, Col: 36} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_two")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 188, Col: 72} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_description")) + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "general_ledger_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 189, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 192, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_one")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 36} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_two")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -574,150 +613,150 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed_journals")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 87} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var36 string - templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.JournalCount)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 136} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var38 string - templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.EntryCount)) + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed_journals")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 123} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 198, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var39 string - templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "tracked_accounts")) + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.JournalCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 195, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 198, Col: 136} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.AccountCount)) + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_entries")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 195, Col: 127} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "last_recorded")) + templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.EntryCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 196, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 123} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(data.Stats.LastRecordedAt)) + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "tracked_accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 196, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var43 string - templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_activity")) + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.AccountCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 127} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var44 string - templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "newest_first")) + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "last_recorded")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 204, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "newest_first")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 204, Col: 105} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -725,7 +764,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -749,103 +788,103 @@ func AssetsContent(data explorer.Assets, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var45 := templ.GetChildren(ctx) - if templ_7745c5c3_Var45 == nil { - templ_7745c5c3_Var45 = templ.NopComponent + templ_7745c5c3_Var48 := templ.GetChildren(ctx) + if templ_7745c5c3_Var48 == nil { + templ_7745c5c3_Var48 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 92} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_explorer")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 209, Col: 55} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 211, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 212, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "top_asset_holders")) + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 214, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 214, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_balance_scope")) + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "track_asset_ownership")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 214, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 215, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 216, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "top_asset_holders")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 219, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_balance_scope")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 219, Col: 115} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -853,7 +892,7 @@ func AssetsContent(data explorer.Assets, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -877,193 +916,193 @@ func HolderTable(holders []explorer.Holder, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var53 := templ.GetChildren(ctx) - if templ_7745c5c3_Var53 == nil { - templ_7745c5c3_Var53 = templ.NopComponent + templ_7745c5c3_Var56 := templ.GetChildren(ctx) + if templ_7745c5c3_Var56 == nil { + templ_7745c5c3_Var56 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(holders) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_asset_holders")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 223, Col: 54} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 40} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var56 string - templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 71} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var57 string - templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_asset_holders")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 228, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, holder := range holders { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 237, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var67 string + templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 237, Col: 140} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var58 string - templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 226, Col: 138} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 138} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var59 string - templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 230, Col: 51} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var60 string - templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 230, Col: 93} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "#") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var61 string - templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var63 string - templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 232, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "#") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var64 string - templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 232, Col: 140} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 236, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var68 string + templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(holder.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 238, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1087,243 +1126,243 @@ func HolderContent(data HolderPageData, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var66 := templ.GetChildren(ctx) - if templ_7745c5c3_Var66 == nil { - templ_7745c5c3_Var66 = templ.NopComponent + templ_7745c5c3_Var69 := templ.GetChildren(ctx) + if templ_7745c5c3_Var69 == nil { + templ_7745c5c3_Var69 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var67 string - templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var68 string - templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 110} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var69 string - templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 141} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var71 string - templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "inspect_holder")) + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 247, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 249, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 248, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 249, Col: 141} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_account")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 251, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "inspect_holder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 252, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 253, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var73 string - templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 251, Col: 66} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var74 string - templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 251, Col: 89} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if data.Account != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var75 string - templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 56} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var76 string - templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerID) + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 114} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var77 string - templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 171} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, " · ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Account != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var78 string - templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 198} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var79 string - templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 254, Col: 256} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 114} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var80 string - templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "combined_balance")) + templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 255, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 171} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " · ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var81 string - templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 255, Col: 127} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 198} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var82 string - templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 258, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 256} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var83 string - templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "available_and_frozen")) + templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "combined_balance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 258, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 260, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var84 string + templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 260, Col: 127} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var85 string + templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "available_and_frozen")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 115} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1331,12 +1370,12 @@ func HolderContent(data HolderPageData, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1360,81 +1399,42 @@ func JournalTable(journals []ledger.Journal, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var84 := templ.GetChildren(ctx) - if templ_7745c5c3_Var84 == nil { - templ_7745c5c3_Var84 = templ.NopComponent + templ_7745c5c3_Var87 := templ.GetChildren(ctx) + if templ_7745c5c3_Var87 == nil { + templ_7745c5c3_Var87 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(journals) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var85 string - templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_journals")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 268, Col: 49} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var86 string - templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 46} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var87 string - templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 79} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var88 string - templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_journals")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 273, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, journal := range journals { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var89 string - templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 146} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) if templ_7745c5c3_Err != nil { @@ -1445,108 +1445,147 @@ func JournalTable(journals []ledger.Journal, locale Locale) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var90 string - templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 271, Col: 181} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 79} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var91 string + templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 112} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 146} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 181} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var92 string - templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 129} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var93 string - templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 278, Col: 50} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var94 string - templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 92} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var95 string - templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 280, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 129} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var96 string - templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 283, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var97 string + templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 284, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 285, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var99 string + templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 286, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1570,77 +1609,77 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var97 := templ.GetChildren(ctx) - if templ_7745c5c3_Var97 == nil { - templ_7745c5c3_Var97 = templ.NopComponent + templ_7745c5c3_Var100 := templ.GetChildren(ctx) + if templ_7745c5c3_Var100 == nil { + templ_7745c5c3_Var100 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var98 string - templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 292, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var99 string - templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 292, Col: 98} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var100 string - templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_explorer")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 294, Col: 60} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var102 string - templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) + templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 296, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 297, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var103 string + templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_explorer")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 299, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var104 string + templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "trace_settlement")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var105 string + templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 301, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1648,44 +1687,44 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var103 string - templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_not_found")) + var templ_7745c5c3_Var106 string + templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_not_found")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 305, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var104 string - templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + var templ_7745c5c3_Var107 string + templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 305, Col: 91} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if data.Query != "" && data.Error == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1695,111 +1734,72 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if data.Listing != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var105 string - templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_transactions")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 311, Col: 69} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var106 string - templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "page")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 311, Col: 102} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var107 string - templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(data.Listing.Filter.Page)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 311, Col: 145} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "\" placeholder=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var116 string + templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "effect_type_placeholder")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 319, Col: 206} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var116) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\" autocomplete=\"off\">
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var118 string + templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "clear_filters")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 321, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1856,12 +1895,12 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1885,146 +1924,146 @@ func Pagination(listing explorer.TransactionListing, locale Locale) templ.Compon }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var116 := templ.GetChildren(ctx) - if templ_7745c5c3_Var116 == nil { - templ_7745c5c3_Var116 = templ.NopComponent + templ_7745c5c3_Var119 := templ.GetChildren(ctx) + if templ_7745c5c3_Var119 == nil { + templ_7745c5c3_Var119 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 165, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var124 string + templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(listing.Filter.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 337, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if listing.HasNext { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var126 string + templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "next")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 339, Col: 117} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var127 string + templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "next")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 341, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2048,190 +2087,151 @@ func JournalCard(journal ledger.Journal, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var125 := templ.GetChildren(ctx) - if templ_7745c5c3_Var125 == nil { - templ_7745c5c3_Var125 = templ.NopComponent + templ_7745c5c3_Var128 := templ.GetChildren(ctx) + if templ_7745c5c3_Var128 == nil { + templ_7745c5c3_Var128 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if journal.Blockchain.TransactionHash != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var126 string - templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_hash")) + var templ_7745c5c3_Var129 string + templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_hash")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 346, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 351, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var127 string - templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) + var templ_7745c5c3_Var130 string + templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 348, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 353, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var128 string - templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(transactionReference(journal)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 350, Col: 52} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var129 string - templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 352, Col: 49} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var131 string - templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(transactionReference(journal)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 355, Col: 106} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 355, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var132 string - templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) + templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 356, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 357, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var134 string - templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 357, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 106} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var136 string - templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(journal.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 358, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 361, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var138 string - templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceTransactionID) + templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 358, Col: 125} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 362, Col: 101} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138)) if templ_7745c5c3_Err != nil { @@ -2242,9 +2242,9 @@ func JournalCard(journal ledger.Journal, locale Locale) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var139 string - templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 359, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 363, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139)) if templ_7745c5c3_Err != nil { @@ -2255,183 +2255,222 @@ func JournalCard(journal ledger.Journal, locale Locale) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var140 string - templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.Network) + templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 359, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 363, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 189, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 190, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var143 string - templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balanced_entries")) + templ_7745c5c3_Var143, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.Network) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 363, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 364, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var143)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 191, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var145 string + templ_7745c5c3_Var145, templ_7745c5c3_Err = templ.JoinStringErrs(journal.Blockchain.LedgerSequence) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 365, Col: 124} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var145)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var146 string + templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balanced_entries")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 368, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, entry := range journal.Entries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 192, "
#") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var144 string - templ_7745c5c3_Var144, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(entry.LineNumber), 10)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 366, Col: 83} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var144)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 193, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var146 string - templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(entry.Account, locale)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 367, Col: 117} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "
#") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var147 string - templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(entry.LineNumber), 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 367, Col: 151} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 371, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var150 string + templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 151} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var151 string + templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(entry.Account.AssetID, 10)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 200} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var152 string + templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Account.OwnerType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 231} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var150 string - templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + var templ_7745c5c3_Var153 string + templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 369, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 374, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
+") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "
+") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var151 string - templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + var templ_7745c5c3_Var154 string + templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 371, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 376, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2455,149 +2494,118 @@ func AccountContent(data AccountPageData, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var152 := templ.GetChildren(ctx) - if templ_7745c5c3_Var152 == nil { - templ_7745c5c3_Var152 = templ.NopComponent + templ_7745c5c3_Var155 := templ.GetChildren(ctx) + if templ_7745c5c3_Var155 == nil { + templ_7745c5c3_Var155 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var153 string - templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 381, Col: 61} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, " / ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var154 string - templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 381, Col: 94} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var155 string - templ_7745c5c3_Var155, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_explorer")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 383, Col: 56} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var155)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var157 string - templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_description")) + templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 385, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 386, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var158 string - templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_class")) + templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 388, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 388, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, class := range accountClasses() { if string(class) == data.Input.Class { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 226, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 227, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var171 string - templ_7745c5c3_Var171, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 403, Col: 66} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var171)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 228, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var172 string - templ_7745c5c3_Var172, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 403, Col: 89} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var172)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 229, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if data.Account != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var173 string - templ_7745c5c3_Var173, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_identity")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 65} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var173)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var174 string - templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(data.Account.Reference, locale)) + templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 123} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 408, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var175 string - templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 180} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 408, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, " · ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if data.Account != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var176 string - templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_identity")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 207} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var177 string - templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(data.Account.Reference, locale)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 406, Col: 265} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 123} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var178 string - templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "current_balance")) + templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 407, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 180} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " · ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var179 string - templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 407, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 207} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var180 string - templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 265} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var181 string - templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(data.Account.Journals))) + templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "current_balance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 120} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var182 string - templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journals")) + templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 409, Col: 147} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 126} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var182)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var183 string + templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 414, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var184 string + templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(data.Account.Journals))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 414, Col: 120} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var185 string + templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journals")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 414, Col: 147} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2886,25 +2925,25 @@ func AccountContent(data AccountPageData, locale Locale) templ.Component { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var183 string - templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "choose_account")) + var templ_7745c5c3_Var186 string + templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "choose_account")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 417, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2928,51 +2967,51 @@ func FailureContent(title string, message string, locale Locale) templ.Component }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var184 := templ.GetChildren(ctx) - if templ_7745c5c3_Var184 == nil { - templ_7745c5c3_Var184 = templ.NopComponent + templ_7745c5c3_Var187 := templ.GetChildren(ctx) + if templ_7745c5c3_Var187 == nil { + templ_7745c5c3_Var187 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var185 string - templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explorer_error")) + var templ_7745c5c3_Var188 string + templ_7745c5c3_Var188, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explorer_error")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 424, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var188)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var186 string - templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var189 string + templ_7745c5c3_Var189, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 424, Col: 100} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var189)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var187 string - templ_7745c5c3_Var187, templ_7745c5c3_Err = templ.JoinStringErrs(message) + var templ_7745c5c3_Var190 string + templ_7745c5c3_Var190, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 419, Col: 132} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 424, Col: 132} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var187)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var190)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 83720ba9d1d7ffb6fe5e8a3669c7bcde08a477a8dccba6032e960af1ab6c3946 Mon Sep 17 00:00:00 2001 From: Navid Date: Sat, 29 Aug 2026 09:57:58 +0330 Subject: [PATCH 11/20] feat: expose append-only ledger health --- DESIGN.md | 40 ++++++++++++++++++------------ application/health/service.go | 3 ++- application/health/service_test.go | 2 +- interface/grpc/health_test.go | 2 +- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 941c331..69e399c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,19 +1,20 @@ # General Ledger service design -Status: accepted for the first implementation slice on 2026-08-14. +Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28. ## Purpose and ownership -`GL` is Darano's durable financial journal. It records every committed wallet -value movement independently of Stellar so balances and transaction history can -be reconstructed during a blockchain or provider outage. +`GL` is Darano's primary financial source of truth. It records every wallet +value movement as an append-only journal. Kuknos, through its Stellar-compatible +interface, is the secondary settlement and verification source of truth. GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does not write GL tables directly, and GL does not write wallet tables. In normal -operation Wallet remains the transaction orchestrator and Stellar remains the -external settlement network. Promoting GL from a mirror to an operational -fallback is an explicit, audited mode change; an outage must never make a failed -blockchain operation appear successful automatically. +operation a transaction requires both GL and Kuknos. If GL is unhealthy, all +value-changing transaction admission and processing halt until it recovers. If +Kuknos is unavailable, an authorized operator may explicitly enable GL-only +operation through an AdminPanel toggle or configuration. Kuknos must never be +disabled automatically. ## Non-negotiable invariants @@ -114,13 +115,20 @@ does not hold a wallet database transaction open. ## Operating modes and failure semantics -- `MIRROR`: normal mode. Wallet follows its existing settlement policy and GL - asynchronously records every committed effect. -- `DEGRADED_LEDGER`: explicitly enabled by an authorized operator. Eligible - internal operations may settle against GL while blockchain-bound operations - remain pending. The initial implementation does not activate this mode. -- `RECONCILE`: outbound posting is paused or restricted while tooling compares - Wallet, GL, and blockchain state and appends approved reversals/corrections. +- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful + only after its required GL journal and Kuknos settlement evidence exist. +- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator + through AdminPanel or configuration. GL remains mandatory and authoritative; + eligible transactions may proceed without Kuknos. Every mode transition must + be immutable, attributable, time-bounded where configured, and emitted via + OpenTelemetry. This mode is not implemented yet. +- `RECONCILE`: transaction processing is paused or restricted while tooling + compares GL and Kuknos and appends approved reversals/corrections. Existing + ledger records are never edited or deleted. + +GL failure is always fail-closed. Public readiness reports a critical state and +the incident and recovery are emitted through OpenTelemetry. Kuknos failure is +also fail-closed unless `KUKNOS_DISABLED` has been explicitly authorized. GL rejects unbalanced journals, invalid precision, unknown account/asset combinations, duplicate line numbers, missing source identity, conflicting @@ -155,7 +163,7 @@ are adapter concerns. ## Initial non-goals -- Replacing Stellar automatically on health-check failure. +- Disabling Kuknos automatically on health-check failure. - Editing or deleting posted journals. - Storing binary floats or using Wallet's mutable transaction table as GL. - Sharing a database schema between Wallet and GL. diff --git a/application/health/service.go b/application/health/service.go index 34f383d..764a920 100644 --- a/application/health/service.go +++ b/application/health/service.go @@ -21,9 +21,10 @@ func NewService(database Database) *Service { } func (s *Service) Check(ctx context.Context) Status { - status := Status{Serving: true} + status := Status{} if s.database != nil { status.DatabaseReady = s.database.Ping(ctx) == nil } + status.Serving = status.DatabaseReady return status } diff --git a/application/health/service_test.go b/application/health/service_test.go index 26134c4..b8d2d9e 100644 --- a/application/health/service_test.go +++ b/application/health/service_test.go @@ -22,7 +22,7 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { got := NewService(tc.db).Check(context.Background()) - if !got.Serving || got.DatabaseReady != tc.ready { + if got.Serving != tc.ready || got.DatabaseReady != tc.ready { t.Fatalf("unexpected status: %+v", got) } }) diff --git a/interface/grpc/health_test.go b/interface/grpc/health_test.go index 2707180..e955442 100644 --- a/interface/grpc/health_test.go +++ b/interface/grpc/health_test.go @@ -13,7 +13,7 @@ func TestHealth(t *testing.T) { if err != nil { t.Fatal(err) } - if !response.Serving || response.DatabaseReady { + if response.Serving || response.DatabaseReady { t.Fatalf("unexpected response: %+v", response) } } From dedb9281eb360d8c642fc6d51c8b040123dfed8ad74a5b327f110fc6c3221d0b Mon Sep 17 00:00:00 2001 From: Navid Date: Sat, 29 Aug 2026 10:02:13 +0330 Subject: [PATCH 12/20] chore: ignore macOS metadata --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a899429..998901b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ go.work.sum # env file .env +# macOS metadata +.DS_Store + # ---> JupyterNotebooks # gitignore template for Jupyter Notebooks # website: http://jupyter.org/ From 9b6c438cc7e09cc513c3268e72b05c27097ff8381a2ccae3a9b1fc3aee9e3124 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 29 Aug 2026 12:59:48 +0330 Subject: [PATCH 13/20] feat(gl): add structured grpc observability --- cmd/dashboard/main.go | 2 ++ cmd/gl/main.go | 2 ++ dashboard.cfg.toml | 2 +- go.mod | 10 ------ go.sum | 23 -------------- infrastructure/observability/logger.go | 30 ++++++++++++++++++ interface/grpc/server.go | 42 +++++++++++++++++++++++++- 7 files changed, 76 insertions(+), 35 deletions(-) create mode 100644 infrastructure/observability/logger.go diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index 0825463..b1dd2ca 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -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() diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 8f5eb0a..45c723a 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -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() diff --git a/dashboard.cfg.toml b/dashboard.cfg.toml index dda9ac5..4c9f4fd 100644 --- a/dashboard.cfg.toml +++ b/dashboard.cfg.toml @@ -2,7 +2,7 @@ environment = "local" [http] host = "0.0.0.0" -port = 8080 +port = 8601 read-header-timeout = "5s" shutdown-timeout = "10s" diff --git a/go.mod b/go.mod index a6d5cab..357f00d 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index fac2ebf..12fe333 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/infrastructure/observability/logger.go b/infrastructure/observability/logger.go new file mode 100644 index 0000000..1850a4e --- /dev/null +++ b/infrastructure/observability/logger.go @@ -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 +} diff --git a/interface/grpc/server.go b/interface/grpc/server.go index bf45ca6..985cf64 100644 --- a/interface/grpc/server.go +++ b/interface/grpc/server.go @@ -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) + } +} From 3f5fd860bfdca0d85801610c6f05c10bdf97d92ddecf6ef0e5b209cd86fd2ec5 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 29 Aug 2026 15:03:17 +0330 Subject: [PATCH 14/20] feat(gl): add read-only ledger reconciliation scan --- application/reconciliation/service.go | 53 ++++++++++++++++++++++ application/reconciliation/service_test.go | 43 ++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 application/reconciliation/service.go create mode 100644 application/reconciliation/service_test.go diff --git a/application/reconciliation/service.go b/application/reconciliation/service.go new file mode 100644 index 0000000..c979868 --- /dev/null +++ b/application/reconciliation/service.go @@ -0,0 +1,53 @@ +// 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)} + 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 + } + report.Valid++ + } + if len(journals) < pageSize { + break + } + } + return report, nil +} diff --git a/application/reconciliation/service_test.go b/application/reconciliation/service_test.go new file mode 100644 index 0000000..2f2aea5 --- /dev/null +++ b/application/reconciliation/service_test.go @@ -0,0 +1,43 @@ +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) + } +} From b14c5e2696fb5471afe75b53ec7da3a0d32fcc2926d8707005dd1664ae64108c Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 29 Aug 2026 15:56:20 +0330 Subject: [PATCH 15/20] feat(gl): detect duplicate transaction versions --- application/reconciliation/service.go | 7 +++++++ application/reconciliation/service_test.go | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/application/reconciliation/service.go b/application/reconciliation/service.go index c979868..875442a 100644 --- a/application/reconciliation/service.go +++ b/application/reconciliation/service.go @@ -32,6 +32,7 @@ func Run(ctx context.Context, repository Repository, pageSize int) (Report, erro 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 { @@ -43,6 +44,12 @@ func Run(ctx context.Context, repository Repository, pageSize int) (Report, erro report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()}) continue } + key := journal.SourceService + "\x00" + journal.SourceTransactionID + "\x00" + fmt.Sprint(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 { diff --git a/application/reconciliation/service_test.go b/application/reconciliation/service_test.go index 2f2aea5..1085ce4 100644 --- a/application/reconciliation/service_test.go +++ b/application/reconciliation/service_test.go @@ -41,3 +41,16 @@ func TestRunScansAndReportsInvalidJournals(t *testing.T) { 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) + } +} From d5c9b338a44c2082d2343acfaf1d1eaf809983eb7bdc9b70e426feb4bc3be291 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 29 Aug 2026 16:32:40 +0330 Subject: [PATCH 16/20] feat(gl): compare external settlement evidence --- application/reconciliation/evidence.go | 85 +++++++++++++++++++++ application/reconciliation/evidence_test.go | 39 ++++++++++ application/reconciliation/service.go | 2 +- 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 application/reconciliation/evidence.go create mode 100644 application/reconciliation/evidence_test.go diff --git a/application/reconciliation/evidence.go b/application/reconciliation/evidence.go new file mode 100644 index 0000000..4192259 --- /dev/null +++ b/application/reconciliation/evidence.go @@ -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) +} diff --git a/application/reconciliation/evidence_test.go b/application/reconciliation/evidence_test.go new file mode 100644 index 0000000..6884870 --- /dev/null +++ b/application/reconciliation/evidence_test.go @@ -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) + } +} diff --git a/application/reconciliation/service.go b/application/reconciliation/service.go index 875442a..7b635de 100644 --- a/application/reconciliation/service.go +++ b/application/reconciliation/service.go @@ -44,7 +44,7 @@ func Run(ctx context.Context, repository Repository, pageSize int) (Report, erro report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()}) continue } - key := journal.SourceService + "\x00" + journal.SourceTransactionID + "\x00" + fmt.Sprint(journal.EventVersion) + 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 From 12c478cf3a245e831134ab073f5a4648543c7defeeb30a076667a7a8eca75d27 Mon Sep 17 00:00:00 2001 From: Navid Date: Tue, 15 Sep 2026 13:59:13 +0330 Subject: [PATCH 17/20] feat: add durable Kuknos settlement coordination --- .dashboard.air.toml | 39 ++ .gitignore | 1 + .gl.air.toml | 39 ++ application/health/service.go | 38 +- application/health/service_test.go | 33 + application/settlement/service.go | 62 ++ application/settlement/service_test.go | 43 ++ application/settlement/worker.go | 57 ++ application/settlement/worker_test.go | 60 ++ build/Dockerfile | 9 +- cmd/dashboard-loadtest/main_test.go | 16 +- cmd/gl/main.go | 64 +- cmd/healthcheck/main.go | 30 + dashboard.cfg.toml | 4 +- domain/ledger/settlement.go | 67 ++ gen/base/v1/msg.pb.go | 2 +- gen/ledger/v1/msg.pb.go | 592 +++++++++++++++++- gen/ledger/v1/srv.pb.go | 53 +- gen/ledger/v1/srv_grpc.pb.go | 134 +++- gl.cfg.toml | 15 +- go.mod | 15 +- go.sum | 23 + infrastructure/config/config.go | 59 +- infrastructure/config/config_test.go | 51 +- infrastructure/kuknos/submitter.go | 55 ++ infrastructure/postgres/database.go | 4 + .../000003_kuknos_settlements.up.sql | 22 + .../000004_settlement_operator_audit.up.sql | 13 + ...05_settlement_reconciliation_issues.up.sql | 13 + .../postgres/settlement_repository.go | 204 ++++++ .../postgres/settlement_repository_test.go | 41 ++ interface/grpc/health.go | 27 +- interface/grpc/settlement.go | 60 ++ scripts/air-run.sh | 23 - 34 files changed, 1846 insertions(+), 122 deletions(-) create mode 100644 .dashboard.air.toml create mode 100644 .gl.air.toml create mode 100644 application/settlement/service.go create mode 100644 application/settlement/service_test.go create mode 100644 application/settlement/worker.go create mode 100644 application/settlement/worker_test.go create mode 100644 cmd/healthcheck/main.go create mode 100644 domain/ledger/settlement.go create mode 100644 infrastructure/kuknos/submitter.go create mode 100644 infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql create mode 100644 infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql create mode 100644 infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql create mode 100644 infrastructure/postgres/settlement_repository.go create mode 100644 infrastructure/postgres/settlement_repository_test.go create mode 100644 interface/grpc/settlement.go delete mode 100644 scripts/air-run.sh diff --git a/.dashboard.air.toml b/.dashboard.air.toml new file mode 100644 index 0000000..d38ee93 --- /dev/null +++ b/.dashboard.air.toml @@ -0,0 +1,39 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["-conf", "./dashboard.cfg.toml"] +bin = "./tmp/dashboard" +cmd = "go build -o ./tmp/dashboard ./cmd/dashboard" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"] +exclude_file = [] +exclude_regex = ["_test.go", ".pb.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "toml"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +main_only = false +time = true + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/.gitignore b/.gitignore index 998901b..cde82a1 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,4 @@ cython_debug/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* +.tmp-go-cache \ No newline at end of file diff --git a/.gl.air.toml b/.gl.air.toml new file mode 100644 index 0000000..71bc718 --- /dev/null +++ b/.gl.air.toml @@ -0,0 +1,39 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["-conf", "./gl.cfg.toml"] +bin = "./tmp/gl" +cmd = "go build -o ./tmp/gl ./cmd/gl" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"] +exclude_file = [] +exclude_regex = ["_test.go", ".pb.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "toml"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +main_only = false +time = true + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/application/health/service.go b/application/health/service.go index 764a920..ff710b8 100644 --- a/application/health/service.go +++ b/application/health/service.go @@ -1,23 +1,41 @@ // Package health provides the service readiness use case. package health -import "context" +import ( + "context" + + "gl/domain/ledger" +) type Database interface { Ping(context.Context) error } +type SettlementMonitor interface { + Stats(context.Context) (ledger.SettlementStats, error) +} + type Status struct { - Serving bool - DatabaseReady bool + Serving bool + DatabaseReady bool + SettlementEnabled bool + SettlementReady bool + SettlementStats ledger.SettlementStats } type Service struct { - database Database + database Database + settlement SettlementMonitor + settlementEnabled bool } -func NewService(database Database) *Service { - return &Service{database: database} +func NewService(database Database, settlement ...SettlementMonitor) *Service { + service := &Service{database: database} + if len(settlement) > 0 && settlement[0] != nil { + service.settlement = settlement[0] + service.settlementEnabled = true + } + return service } func (s *Service) Check(ctx context.Context) Status { @@ -26,5 +44,13 @@ func (s *Service) Check(ctx context.Context) Status { status.DatabaseReady = s.database.Ping(ctx) == nil } status.Serving = status.DatabaseReady + status.SettlementEnabled = s.settlementEnabled + if status.DatabaseReady && s.settlementEnabled { + stats, err := s.settlement.Stats(ctx) + if err == nil { + status.SettlementStats = stats + status.SettlementReady = true + } + } return status } diff --git a/application/health/service_test.go b/application/health/service_test.go index b8d2d9e..ff0d3e1 100644 --- a/application/health/service_test.go +++ b/application/health/service_test.go @@ -4,12 +4,23 @@ import ( "context" "errors" "testing" + + "gl/domain/ledger" ) type databaseStub struct{ err error } func (s databaseStub) Ping(context.Context) error { return s.err } +type settlementStub struct { + stats ledger.SettlementStats + err error +} + +func (s settlementStub) Stats(context.Context) (ledger.SettlementStats, error) { + return s.stats, s.err +} + func TestCheckReportsDatabaseReadiness(t *testing.T) { for _, tc := range []struct { name string @@ -28,3 +39,25 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) { }) } } + +func TestCheckReportsSettlementBacklog(t *testing.T) { + monitor := settlementStub{stats: ledger.SettlementStats{Pending: 2, Retryable: 3, OldestPendingSeconds: 60}} + got := NewService(databaseStub{}, monitor).Check(context.Background()) + if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || !got.SettlementReady || got.SettlementStats != monitor.stats { + t.Fatalf("unexpected status: %+v", got) + } +} + +func TestCheckExposesManualReviewWithoutStoppingSettlementQueue(t *testing.T) { + got := NewService(databaseStub{}, settlementStub{stats: ledger.SettlementStats{ManualReview: 1}}).Check(context.Background()) + if !got.Serving || !got.SettlementReady || got.SettlementStats.ManualReview != 1 { + t.Fatalf("unexpected status: %+v", got) + } +} + +func TestCheckReportsSettlementQueryFailure(t *testing.T) { + got := NewService(databaseStub{}, settlementStub{err: errors.New("query failed")}).Check(context.Background()) + if !got.Serving || got.SettlementReady { + t.Fatalf("unexpected status: %+v", got) + } +} diff --git a/application/settlement/service.go b/application/settlement/service.go new file mode 100644 index 0000000..169b204 --- /dev/null +++ b/application/settlement/service.go @@ -0,0 +1,62 @@ +package settlement + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "gl/domain/ledger" +) + +type EnqueueRepository interface { + Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) + Get(context.Context, string, string) (ledger.Settlement, error) + RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error) +} + +func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) { + if settlementID == "" || actorID == "" || reason == "" { + return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required") + } + now := time.Now().UTC() + if s.now != nil { + now = s.now().UTC() + } + return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now) +} + +type Service struct { + repository EnqueueRepository + now func() time.Time +} + +func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} } + +func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) { + if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" { + return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required") + } + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err) + } + now := time.Now().UTC() + if s.now != nil { + now = s.now().UTC() + } + encoded := hex.EncodeToString(id) + record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now} + if err := record.Validate(); err != nil { + return ledger.Settlement{}, false, err + } + return s.repository.Enqueue(ctx, record) +} + +func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) { + if settlementID == "" && idempotencyKey == "" { + return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required") + } + return s.repository.Get(ctx, settlementID, idempotencyKey) +} diff --git a/application/settlement/service_test.go b/application/settlement/service_test.go new file mode 100644 index 0000000..f9f5b9f --- /dev/null +++ b/application/settlement/service_test.go @@ -0,0 +1,43 @@ +package settlement + +import ( + "context" + "testing" + "time" + + "gl/domain/ledger" +) + +type serviceRepository struct { + retriedID, actor, reason string +} + +func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) { + return ledger.Settlement{}, false, nil +} +func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) { + return ledger.Settlement{}, nil +} +func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) { + r.retriedID, r.actor, r.reason = id, actor, reason + return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil +} + +func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) { + service := NewService(&serviceRepository{}) + if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil { + t.Fatal("expected missing reason to be rejected") + } +} + +func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) { + repository := &serviceRepository{} + service := NewService(repository) + got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved") + if err != nil { + t.Fatal(err) + } + if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" { + t.Fatalf("unexpected retry: %+v repo=%+v", got, repository) + } +} diff --git a/application/settlement/worker.go b/application/settlement/worker.go new file mode 100644 index 0000000..1a6c50d --- /dev/null +++ b/application/settlement/worker.go @@ -0,0 +1,57 @@ +package settlement + +import ( + "context" + "fmt" + "time" + + "gl/domain/ledger" +) + +type Repository interface { + ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) + Save(context.Context, ledger.Settlement, string) error +} + +type Submitter interface { + Submit(context.Context, ledger.Settlement) (string, error) +} + +type Worker struct { + Repository Repository + Submitter Submitter + WorkerID string + MaxAttempts int + Now func() time.Time +} + +func (w Worker) RunOnce(ctx context.Context, limit int) error { + if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 { + return fmt.Errorf("settlement worker is not configured") + } + now := time.Now().UTC() + if w.Now != nil { + now = w.Now().UTC() + } + records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now) + if err != nil { + return err + } + for _, record := range records { + record.Attempts++ + hash, submitErr := w.Submitter.Submit(ctx, record) + if submitErr != nil { + record = record.Retry(now, w.MaxAttempts, submitErr) + } else { + // Horizon returns success only after the transaction is accepted into + // a ledger, so a successful response is already confirmation. + record.Status = ledger.SettlementConfirmed + record.TransactionHash = hash + record.LastError = "" + } + if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil { + return err + } + } + return nil +} diff --git a/application/settlement/worker_test.go b/application/settlement/worker_test.go new file mode 100644 index 0000000..4b277c1 --- /dev/null +++ b/application/settlement/worker_test.go @@ -0,0 +1,60 @@ +package settlement + +import ( + "context" + "errors" + "testing" + "time" + + "gl/domain/ledger" +) + +type memoryRepository struct{ record ledger.Settlement } + +func (m *memoryRepository) ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) { + return []ledger.Settlement{m.record}, nil +} +func (m *memoryRepository) Save(_ context.Context, record ledger.Settlement, _ string) error { + m.record = record + return nil +} + +type submitter struct { + hash string + err error +} + +func (s submitter) Submit(context.Context, ledger.Settlement) (string, error) { return s.hash, s.err } + +func TestWorkerPersistsSubmission(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{hash: "hash-42"}, WorkerID: "worker-1", Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementConfirmed || repository.record.TransactionHash != "hash-42" || repository.record.Attempts != 1 { + t.Fatalf("unexpected settlement: %+v", repository.record) + } +} + +func TestWorkerSchedulesRetryAfterFirstFailedAttempt(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementRetryable || repository.record.Attempts != 1 || !repository.record.AvailableAt.Equal(time.Unix(3, 0)) { + t.Fatalf("unexpected settlement: %+v", repository.record) + } +} + +func TestWorkerMovesExhaustedSubmissionToManualReview(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementRetryable, Attempts: 2, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementManualReview { + t.Fatalf("status=%s", repository.record.Status) + } +} diff --git a/build/Dockerfile b/build/Dockerfile index 58b9b55..7e2e055 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -15,7 +15,9 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ COPY . . RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ - go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl + go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl && \ + go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard && \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck FROM alpine:3.24.1 @@ -26,11 +28,14 @@ RUN apk add --no-cache ca-certificates \ WORKDIR /app COPY --from=builder /out/gl /app/gl +COPY --from=builder /out/dashboard /app/dashboard +COPY --from=builder /out/healthcheck /app/healthcheck COPY gl.cfg.toml /app/gl.cfg.toml +COPY dashboard.cfg.toml /app/dashboard.cfg.toml USER darano -EXPOSE 8600 +EXPOSE 8600 8601 ENTRYPOINT ["/app/gl"] CMD ["-conf", "/app/gl.cfg.toml"] diff --git a/cmd/dashboard-loadtest/main_test.go b/cmd/dashboard-loadtest/main_test.go index 12d4d23..98bc1b5 100644 --- a/cmd/dashboard-loadtest/main_test.go +++ b/cmd/dashboard-loadtest/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "net" "net/http" "net/http/httptest" "sync/atomic" @@ -10,7 +11,7 @@ import ( ) func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) _, _ = response.Write([]byte("ok")) })) @@ -49,7 +50,7 @@ func TestPercentileUsesNearestRank(t *testing.T) { func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { var canceled atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { time.Sleep(20 * time.Millisecond) if request.Context().Err() != nil { canceled.Store(true) @@ -66,3 +67,14 @@ func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { t.Fatalf("in-flight request was not drained cleanly: %+v", result) } } + +func newIPv4TestServer(handler http.Handler) *httptest.Server { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + panic(err) + } + server := httptest.NewUnstartedServer(handler) + server.Listener = listener + server.Start() + return server +} diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 45c723a..122f6f3 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -7,10 +7,13 @@ import ( "os" "os/signal" "syscall" + "time" "gl/application/health" applicationledger "gl/application/ledger" + applicationsettlement "gl/application/settlement" "gl/infrastructure/config" + "gl/infrastructure/kuknos" "gl/infrastructure/observability" "gl/infrastructure/postgres" grpcadapter "gl/interface/grpc" @@ -42,7 +45,21 @@ func main() { } repository := postgres.NewJournalRepository(database) - handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil)) + settlementRepository := postgres.NewSettlementRepository(database) + settlementService := applicationsettlement.NewService(settlementRepository) + var healthService *health.Service + if cfg.Settlement.Enabled { + healthService = health.NewService(database, settlementRepository) + } else { + healthService = health.NewService(database) + } + handler := grpcadapter.NewHandler(healthService, applicationledger.NewService(repository, nil), settlementService).WithSettlementAdminToken(cfg.Settlement.AdminToken) + if cfg.Settlement.Enabled { + worker := applicationsettlement.Worker{Repository: settlementRepository, Submitter: kuknos.Submitter{Endpoint: cfg.Settlement.Endpoint}, WorkerID: cfg.Settlement.WorkerID, MaxAttempts: cfg.Settlement.MaxAttempts} + go runSettlementWorker(ctx, worker, cfg.Settlement.PollInterval, cfg.Settlement.BatchSize) + go runSettlementReconciliation(ctx, settlementRepository) + slog.Info("Kuknos settlement worker enabled", "endpoint", cfg.Settlement.Endpoint, "worker_id", cfg.Settlement.WorkerID) + } slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, @@ -54,3 +71,48 @@ func main() { os.Exit(1) } } + +func runSettlementReconciliation(ctx context.Context, repository *postgres.SettlementRepository) { + run := func() { + stats, err := repository.ReconcileConfirmationEvidence(ctx, 10*time.Minute) + if err != nil { + slog.ErrorContext(ctx, "settlement reconciliation failed", "error", err) + return + } + if stats.Detected > 0 || stats.Resolved > 0 || stats.Open > 0 { + slog.WarnContext(ctx, "settlement reconciliation completed", "detected", stats.Detected, "resolved", stats.Resolved, "open", stats.Open) + } + } + run() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} + +func runSettlementWorker(ctx context.Context, worker applicationsettlement.Worker, interval time.Duration, batchSize int) { + if interval <= 0 { + interval = 5 * time.Second + } + if batchSize <= 0 { + batchSize = 20 + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := worker.RunOnce(ctx, batchSize); err != nil { + slog.ErrorContext(ctx, "settlement worker cycle failed", "error", err) + } + } + } +} diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..ca703eb --- /dev/null +++ b/cmd/healthcheck/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "flag" + "os" + "time" + + "gl/infrastructure/config" + "gl/infrastructure/postgres" +) + +func main() { + configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file") + flag.Parse() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cfg, err := config.Load(*configPath) + if err != nil { + os.Exit(1) + } + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + os.Exit(1) + } + defer database.Close() + if err := database.Ping(ctx); err != nil { + os.Exit(1) + } +} diff --git a/dashboard.cfg.toml b/dashboard.cfg.toml index 4c9f4fd..6f13756 100644 --- a/dashboard.cfg.toml +++ b/dashboard.cfg.toml @@ -6,10 +6,10 @@ port = 8601 read-header-timeout = "5s" shutdown-timeout = "10s" -[database] +[db] host = "127.0.0.1" port = 5432 name = "gl_db" user = "postgres" -password = "postgres" +password = "" ssl-mode = "disable" diff --git a/domain/ledger/settlement.go b/domain/ledger/settlement.go new file mode 100644 index 0000000..0b9b586 --- /dev/null +++ b/domain/ledger/settlement.go @@ -0,0 +1,67 @@ +package ledger + +import ( + "fmt" + "time" +) + +type SettlementStatus string + +const ( + SettlementPending SettlementStatus = "PENDING" + SettlementSubmitted SettlementStatus = "SUBMITTED" + SettlementConfirmed SettlementStatus = "CONFIRMED" + SettlementRetryable SettlementStatus = "RETRYABLE" + SettlementManualReview SettlementStatus = "MANUAL_REVIEW" +) + +type Settlement struct { + ID string + SourceService string + SourceTxID string + IdempotencyKey string + Status SettlementStatus + Network string + TransactionHash string + Attempts int + AvailableAt time.Time + LastError string + SignedTransactionXDR string +} + +type SettlementStats struct { + Pending int64 + Retryable int64 + ManualReview int64 + OldestPendingSeconds int64 +} + +func (s Settlement) Validate() error { + if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" { + return fmt.Errorf("settlement identity fields are required") + } + if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview { + return fmt.Errorf("invalid settlement status %q", s.Status) + } + if s.Attempts < 0 || s.AvailableAt.IsZero() { + return fmt.Errorf("invalid settlement retry state") + } + return nil +} + +func (s Settlement) Retry(now time.Time, maxAttempts int, err error) Settlement { + s.LastError = err.Error() + s.Status = SettlementRetryable + if maxAttempts > 0 && s.Attempts >= maxAttempts { + s.Status = SettlementManualReview + } + delay := time.Second + for i := 1; i < s.Attempts && delay < 15*time.Minute; i++ { + delay *= 2 + } + if delay > 15*time.Minute { + delay = 15 * time.Minute + } + s.AvailableAt = now.UTC().Add(delay) + return s +} diff --git a/gen/base/v1/msg.pb.go b/gen/base/v1/msg.pb.go index c90445a..af9a4c7 100644 --- a/gen/base/v1/msg.pb.go +++ b/gen/base/v1/msg.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: base/v1/msg.proto diff --git a/gen/ledger/v1/msg.pb.go b/gen/ledger/v1/msg.pb.go index 2c39792..0afadbc 100644 --- a/gen/ledger/v1/msg.pb.go +++ b/gen/ledger/v1/msg.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: ledger/v1/msg.proto @@ -1441,11 +1441,17 @@ func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult { } type HealthResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"` - DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"` + DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"` + SettlementEnabled bool `protobuf:"varint,3,opt,name=settlement_enabled,json=settlementEnabled,proto3" json:"settlement_enabled,omitempty"` + SettlementReady bool `protobuf:"varint,4,opt,name=settlement_ready,json=settlementReady,proto3" json:"settlement_ready,omitempty"` + SettlementPending int64 `protobuf:"varint,5,opt,name=settlement_pending,json=settlementPending,proto3" json:"settlement_pending,omitempty"` + SettlementRetryable int64 `protobuf:"varint,6,opt,name=settlement_retryable,json=settlementRetryable,proto3" json:"settlement_retryable,omitempty"` + SettlementManualReview int64 `protobuf:"varint,7,opt,name=settlement_manual_review,json=settlementManualReview,proto3" json:"settlement_manual_review,omitempty"` + OldestPendingSeconds int64 `protobuf:"varint,8,opt,name=oldest_pending_seconds,json=oldestPendingSeconds,proto3" json:"oldest_pending_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HealthResponse) Reset() { @@ -1492,6 +1498,479 @@ func (x *HealthResponse) GetDatabaseReady() bool { return false } +func (x *HealthResponse) GetSettlementEnabled() bool { + if x != nil { + return x.SettlementEnabled + } + return false +} + +func (x *HealthResponse) GetSettlementReady() bool { + if x != nil { + return x.SettlementReady + } + return false +} + +func (x *HealthResponse) GetSettlementPending() int64 { + if x != nil { + return x.SettlementPending + } + return 0 +} + +func (x *HealthResponse) GetSettlementRetryable() int64 { + if x != nil { + return x.SettlementRetryable + } + return 0 +} + +func (x *HealthResponse) GetSettlementManualReview() int64 { + if x != nil { + return x.SettlementManualReview + } + return 0 +} + +func (x *HealthResponse) GetOldestPendingSeconds() int64 { + if x != nil { + return x.OldestPendingSeconds + } + return 0 +} + +type EnqueueSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + SourceTransactionId string `protobuf:"bytes,2,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + IdempotencyKey string `protobuf:"bytes,3,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Network string `protobuf:"bytes,4,opt,name=network,proto3" json:"network,omitempty"` + // Signed transaction XDR. GL persists and submits this payload durably. + SignedTransactionXdr string `protobuf:"bytes,5,opt,name=signed_transaction_xdr,json=signedTransactionXdr,proto3" json:"signed_transaction_xdr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSettlementRequest) Reset() { + *x = EnqueueSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSettlementRequest) ProtoMessage() {} + +func (x *EnqueueSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSettlementRequest.ProtoReflect.Descriptor instead. +func (*EnqueueSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{18} +} + +func (x *EnqueueSettlementRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *EnqueueSettlementRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *EnqueueSettlementRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *EnqueueSettlementRequest) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *EnqueueSettlementRequest) GetSignedTransactionXdr() string { + if x != nil { + return x.SignedTransactionXdr + } + return "" +} + +type EnqueueSettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + AlreadyExisted bool `protobuf:"varint,3,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSettlementResponse) Reset() { + *x = EnqueueSettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSettlementResponse) ProtoMessage() {} + +func (x *EnqueueSettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSettlementResponse.ProtoReflect.Descriptor instead. +func (*EnqueueSettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{19} +} + +func (x *EnqueueSettlementResponse) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *EnqueueSettlementResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *EnqueueSettlementResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type GetSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Lookup: + // + // *GetSettlementRequest_SettlementId + // *GetSettlementRequest_IdempotencyKey + Lookup isGetSettlementRequest_Lookup `protobuf_oneof:"lookup"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSettlementRequest) Reset() { + *x = GetSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSettlementRequest) ProtoMessage() {} + +func (x *GetSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSettlementRequest.ProtoReflect.Descriptor instead. +func (*GetSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{20} +} + +func (x *GetSettlementRequest) GetLookup() isGetSettlementRequest_Lookup { + if x != nil { + return x.Lookup + } + return nil +} + +func (x *GetSettlementRequest) GetSettlementId() string { + if x != nil { + if x, ok := x.Lookup.(*GetSettlementRequest_SettlementId); ok { + return x.SettlementId + } + } + return "" +} + +func (x *GetSettlementRequest) GetIdempotencyKey() string { + if x != nil { + if x, ok := x.Lookup.(*GetSettlementRequest_IdempotencyKey); ok { + return x.IdempotencyKey + } + } + return "" +} + +type isGetSettlementRequest_Lookup interface { + isGetSettlementRequest_Lookup() +} + +type GetSettlementRequest_SettlementId struct { + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3,oneof"` +} + +type GetSettlementRequest_IdempotencyKey struct { + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3,oneof"` +} + +func (*GetSettlementRequest_SettlementId) isGetSettlementRequest_Lookup() {} + +func (*GetSettlementRequest_IdempotencyKey) isGetSettlementRequest_Lookup() {} + +type Settlement struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + SourceService string `protobuf:"bytes,2,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` + Network string `protobuf:"bytes,6,opt,name=network,proto3" json:"network,omitempty"` + TransactionHash string `protobuf:"bytes,7,opt,name=transaction_hash,json=transactionHash,proto3" json:"transaction_hash,omitempty"` + Attempts uint32 `protobuf:"varint,8,opt,name=attempts,proto3" json:"attempts,omitempty"` + LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Settlement) Reset() { + *x = Settlement{} + mi := &file_ledger_v1_msg_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Settlement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Settlement) ProtoMessage() {} + +func (x *Settlement) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Settlement.ProtoReflect.Descriptor instead. +func (*Settlement) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{21} +} + +func (x *Settlement) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *Settlement) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *Settlement) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *Settlement) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *Settlement) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Settlement) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *Settlement) GetTransactionHash() string { + if x != nil { + return x.TransactionHash + } + return "" +} + +func (x *Settlement) GetAttempts() uint32 { + if x != nil { + return x.Attempts + } + return 0 +} + +func (x *Settlement) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +type RetrySettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + ActorId string `protobuf:"bytes,2,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetrySettlementRequest) Reset() { + *x = RetrySettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetrySettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetrySettlementRequest) ProtoMessage() {} + +func (x *RetrySettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetrySettlementRequest.ProtoReflect.Descriptor instead. +func (*RetrySettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{22} +} + +func (x *RetrySettlementRequest) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *RetrySettlementRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *RetrySettlementRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type RetrySettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Settlement *Settlement `protobuf:"bytes,1,opt,name=settlement,proto3" json:"settlement,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetrySettlementResponse) Reset() { + *x = RetrySettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetrySettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetrySettlementResponse) ProtoMessage() {} + +func (x *RetrySettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetrySettlementResponse.ProtoReflect.Descriptor instead. +func (*RetrySettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{23} +} + +func (x *RetrySettlementResponse) GetSettlement() *Settlement { + if x != nil { + return x.Settlement + } + return nil +} + var File_ledger_v1_msg_proto protoreflect.FileDescriptor const file_ledger_v1_msg_proto_rawDesc = "" + @@ -1632,10 +2111,50 @@ const file_ledger_v1_msg_proto_rawDesc = "" + "\ajournal\x18\x02 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" + "\x16ReplayJournalsResponse\x128\n" + - "\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"Q\n" + + "\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"\xfd\x02\n" + "\x0eHealthResponse\x12\x18\n" + "\aserving\x18\x01 \x01(\bR\aserving\x12%\n" + - "\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady*\x9a\x02\n" + + "\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady\x12-\n" + + "\x12settlement_enabled\x18\x03 \x01(\bR\x11settlementEnabled\x12)\n" + + "\x10settlement_ready\x18\x04 \x01(\bR\x0fsettlementReady\x12-\n" + + "\x12settlement_pending\x18\x05 \x01(\x03R\x11settlementPending\x121\n" + + "\x14settlement_retryable\x18\x06 \x01(\x03R\x13settlementRetryable\x128\n" + + "\x18settlement_manual_review\x18\a \x01(\x03R\x16settlementManualReview\x124\n" + + "\x16oldest_pending_seconds\x18\b \x01(\x03R\x14oldestPendingSeconds\"\xee\x01\n" + + "\x18EnqueueSettlementRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x122\n" + + "\x15source_transaction_id\x18\x02 \x01(\tR\x13sourceTransactionId\x12'\n" + + "\x0fidempotency_key\x18\x03 \x01(\tR\x0eidempotencyKey\x12\x18\n" + + "\anetwork\x18\x04 \x01(\tR\anetwork\x124\n" + + "\x16signed_transaction_xdr\x18\x05 \x01(\tR\x14signedTransactionXdr\"\x81\x01\n" + + "\x19EnqueueSettlementResponse\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12'\n" + + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"r\n" + + "\x14GetSettlementRequest\x12%\n" + + "\rsettlement_id\x18\x01 \x01(\tH\x00R\fsettlementId\x12)\n" + + "\x0fidempotency_key\x18\x02 \x01(\tH\x00R\x0eidempotencyKeyB\b\n" + + "\x06lookup\"\xcd\x02\n" + + "\n" + + "Settlement\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12%\n" + + "\x0esource_service\x18\x02 \x01(\tR\rsourceService\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12'\n" + + "\x0fidempotency_key\x18\x04 \x01(\tR\x0eidempotencyKey\x12\x16\n" + + "\x06status\x18\x05 \x01(\tR\x06status\x12\x18\n" + + "\anetwork\x18\x06 \x01(\tR\anetwork\x12)\n" + + "\x10transaction_hash\x18\a \x01(\tR\x0ftransactionHash\x12\x1a\n" + + "\battempts\x18\b \x01(\rR\battempts\x12\x1d\n" + + "\n" + + "last_error\x18\t \x01(\tR\tlastError\"p\n" + + "\x16RetrySettlementRequest\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" + + "\bactor_id\x18\x02 \x01(\tR\aactorId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\"P\n" + + "\x17RetrySettlementResponse\x125\n" + + "\n" + + "settlement\x18\x01 \x01(\v2\x15.ledger.v1.SettlementR\n" + + "settlement*\x9a\x02\n" + "\fAccountClass\x12\x1d\n" + "\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" + "\x1cACCOUNT_CLASS_USER_AVAILABLE\x10\x01\x12\x1d\n" + @@ -1669,7 +2188,7 @@ func file_ledger_v1_msg_proto_rawDescGZIP() []byte { } var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_ledger_v1_msg_proto_goTypes = []any{ (AccountClass)(0), // 0: ledger.v1.AccountClass (TransactionState)(0), // 1: ledger.v1.TransactionState @@ -1691,47 +2210,54 @@ var file_ledger_v1_msg_proto_goTypes = []any{ (*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult (*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse (*HealthResponse)(nil), // 19: ledger.v1.HealthResponse - nil, // 20: ledger.v1.Journal.MetadataEntry - nil, // 21: ledger.v1.AppendJournalRequest.MetadataEntry - nil, // 22: ledger.v1.AppendTransactionEventRequest.MetadataEntry - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp + (*EnqueueSettlementRequest)(nil), // 20: ledger.v1.EnqueueSettlementRequest + (*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse + (*GetSettlementRequest)(nil), // 22: ledger.v1.GetSettlementRequest + (*Settlement)(nil), // 23: ledger.v1.Settlement + (*RetrySettlementRequest)(nil), // 24: ledger.v1.RetrySettlementRequest + (*RetrySettlementResponse)(nil), // 25: ledger.v1.RetrySettlementResponse + nil, // 26: ledger.v1.Journal.MetadataEntry + nil, // 27: ledger.v1.AppendJournalRequest.MetadataEntry + nil, // 28: ledger.v1.AppendTransactionEventRequest.MetadataEntry + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp } var file_ledger_v1_msg_proto_depIdxs = []int32{ 0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass 2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference 3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry - 23, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp - 23, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp + 29, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp 4, // 5: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference - 20, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry + 26, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry 3, // 7: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry - 23, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp 4, // 9: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 21, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry + 27, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry 5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal 1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState - 23, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp 4, // 14: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 22, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry + 28, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry 8, // 16: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest - 23, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp + 29, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp 9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent 2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference - 23, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp - 23, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp + 29, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp + 29, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp 5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal 2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference - 23, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp + 29, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp 2, // 25: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference - 23, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp + 29, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp 6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest 5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal 17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult - 30, // [30:30] is the sub-list for method output_type - 30, // [30:30] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 23, // 30: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement + 31, // [31:31] is the sub-list for method output_type + 31, // [31:31] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_ledger_v1_msg_proto_init() } @@ -1746,13 +2272,17 @@ func file_ledger_v1_msg_proto_init() { (*GetJournalRequest_IdempotencyKey)(nil), } file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[20].OneofWrappers = []any{ + (*GetSettlementRequest_SettlementId)(nil), + (*GetSettlementRequest_IdempotencyKey)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)), NumEnums: 2, - NumMessages: 21, + NumMessages: 27, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/ledger/v1/srv.pb.go b/gen/ledger/v1/srv.pb.go index 3f90d43..ed563a9 100644 --- a/gen/ledger/v1/srv.pb.go +++ b/gen/ledger/v1/srv.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: ledger/v1/srv.proto @@ -25,7 +25,7 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor const file_ledger_v1_srv_proto_rawDesc = "" + "\n" + - "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xbe\x04\n" + + "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xc1\x06\n" + "\x14GeneralLedgerService\x123\n" + "\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" + "\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" + @@ -35,7 +35,10 @@ const file_ledger_v1_srv_proto_rawDesc = "" + "\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" + "\n" + "GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" + - "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponseBy\n" + + "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" + + "\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" + + "\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" + + "\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponseBy\n" + "\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + "Ledger::V1b\x06proto3" @@ -47,13 +50,19 @@ var file_ledger_v1_srv_proto_goTypes = []any{ (*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest (*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest (*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest - (*HealthResponse)(nil), // 7: ledger.v1.HealthResponse - (*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse - (*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse - (*Journal)(nil), // 10: ledger.v1.Journal - (*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse - (*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse - (*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse + (*EnqueueSettlementRequest)(nil), // 7: ledger.v1.EnqueueSettlementRequest + (*GetSettlementRequest)(nil), // 8: ledger.v1.GetSettlementRequest + (*RetrySettlementRequest)(nil), // 9: ledger.v1.RetrySettlementRequest + (*HealthResponse)(nil), // 10: ledger.v1.HealthResponse + (*AppendJournalResponse)(nil), // 11: ledger.v1.AppendJournalResponse + (*AppendTransactionEventResponse)(nil), // 12: ledger.v1.AppendTransactionEventResponse + (*Journal)(nil), // 13: ledger.v1.Journal + (*ListEntriesResponse)(nil), // 14: ledger.v1.ListEntriesResponse + (*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse + (*ReplayJournalsResponse)(nil), // 16: ledger.v1.ReplayJournalsResponse + (*EnqueueSettlementResponse)(nil), // 17: ledger.v1.EnqueueSettlementResponse + (*Settlement)(nil), // 18: ledger.v1.Settlement + (*RetrySettlementResponse)(nil), // 19: ledger.v1.RetrySettlementResponse } var file_ledger_v1_srv_proto_depIdxs = []int32{ 0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty @@ -63,15 +72,21 @@ var file_ledger_v1_srv_proto_depIdxs = []int32{ 4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest 5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest 6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest - 7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse - 8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse - 9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse - 10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal - 11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse - 12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse - 13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse - 7, // [7:14] is the sub-list for method output_type - 0, // [0:7] is the sub-list for method input_type + 7, // 7: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest + 8, // 8: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest + 9, // 9: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest + 10, // 10: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse + 11, // 11: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse + 12, // 12: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse + 13, // 13: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal + 14, // 14: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse + 15, // 15: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse + 16, // 16: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse + 17, // 17: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse + 18, // 18: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement + 19, // 19: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse + 10, // [10:20] is the sub-list for method output_type + 0, // [0:10] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/gen/ledger/v1/srv_grpc.pb.go b/gen/ledger/v1/srv_grpc.pb.go index e16298a..13290b2 100644 --- a/gen/ledger/v1/srv_grpc.pb.go +++ b/gen/ledger/v1/srv_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.2 +// - protoc-gen-go-grpc v1.5.1 // - protoc (unknown) // source: ledger/v1/srv.proto @@ -27,6 +27,9 @@ const ( GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries" GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance" GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals" + GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement" + GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement" + GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement" ) // GeneralLedgerServiceClient is the client API for GeneralLedgerService service. @@ -40,6 +43,10 @@ type GeneralLedgerServiceClient interface { ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) + EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) + GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) + // Restricted operator command. Requires the configured x-admin-token metadata. + RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) } type generalLedgerServiceClient struct { @@ -120,6 +127,36 @@ func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *Rep return out, nil } +func (c *generalLedgerServiceClient) EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EnqueueSettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_EnqueueSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Settlement) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RetrySettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_RetrySettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // GeneralLedgerServiceServer is the server API for GeneralLedgerService service. // All implementations should embed UnimplementedGeneralLedgerServiceServer // for forward compatibility. @@ -131,6 +168,10 @@ type GeneralLedgerServiceServer interface { ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) + EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) + GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) + // Restricted operator command. Requires the configured x-admin-token metadata. + RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) } // UnimplementedGeneralLedgerServiceServer should be embedded to have @@ -141,25 +182,34 @@ type GeneralLedgerServiceServer interface { type UnimplementedGeneralLedgerServiceServer struct{} func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Health not implemented") + return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") } func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AppendJournal not implemented") + return nil, status.Errorf(codes.Unimplemented, "method AppendJournal not implemented") } func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AppendTransactionEvent not implemented") + return nil, status.Errorf(codes.Unimplemented, "method AppendTransactionEvent not implemented") } func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) { - return nil, status.Error(codes.Unimplemented, "method GetJournal not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetJournal not implemented") } func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListEntries not implemented") + return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented") } func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetBalance not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetBalance not implemented") } func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ReplayJournals not implemented") + return nil, status.Errorf(codes.Unimplemented, "method ReplayJournals not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EnqueueSettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented") } func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {} @@ -171,7 +221,7 @@ type UnsafeGeneralLedgerServiceServer interface { } func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) { - // If the following call panics, it indicates UnimplementedGeneralLedgerServiceServer was + // If the following call pancis, it indicates UnimplementedGeneralLedgerServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -307,6 +357,60 @@ func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _GeneralLedgerService_EnqueueSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EnqueueSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_EnqueueSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, req.(*EnqueueSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, req.(*GetSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RetrySettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_RetrySettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, req.(*RetrySettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + // GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -342,6 +446,18 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ReplayJournals", Handler: _GeneralLedgerService_ReplayJournals_Handler, }, + { + MethodName: "EnqueueSettlement", + Handler: _GeneralLedgerService_EnqueueSettlement_Handler, + }, + { + MethodName: "GetSettlement", + Handler: _GeneralLedgerService_GetSettlement_Handler, + }, + { + MethodName: "RetrySettlement", + Handler: _GeneralLedgerService_RetrySettlement_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "ledger/v1/srv.proto", diff --git a/gl.cfg.toml b/gl.cfg.toml index 3c8ae30..ae21f10 100644 --- a/gl.cfg.toml +++ b/gl.cfg.toml @@ -3,12 +3,21 @@ environment = "local" [grpc] host = "0.0.0.0" port = 8600 -shutdown-timeout = "10s" +timeout = "10s" -[database] +[db] host = "127.0.0.1" port = 5432 name = "gl_db" user = "postgres" -password = "postgres" +password = "" ssl-mode = "disable" + +[settlement] +enabled = false +endpoint = "" +worker-id = "gl-local" +poll-interval = "5s" +batch-size = 20 +max-attempts = 8 +admin-token = "" diff --git a/go.mod b/go.mod index 357f00d..617c6fe 100644 --- a/go.mod +++ b/go.mod @@ -13,21 +13,34 @@ 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 ) -tool github.com/a-h/templ +tool ( + github.com/a-h/templ + github.com/a-h/templ/cmd/templ +) diff --git a/go.sum b/go.sum index 12fe333..fac2ebf 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,18 @@ +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= @@ -25,10 +35,17 @@ 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= @@ -40,14 +57,20 @@ 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= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index ad430e9..f0619d1 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -3,6 +3,8 @@ package config import ( "fmt" + "os" + "strings" "time" "github.com/knadh/koanf/parsers/toml" @@ -11,21 +13,22 @@ import ( ) type Config struct { - Environment string `koanf:"environment"` - GRPC GRPCConfig `koanf:"grpc"` - Database DatabaseConfig `koanf:"database"` + Environment string `koanf:"environment"` + GRPC GRPCConfig `koanf:"grpc"` + Database DatabaseConfig `koanf:"db"` + Settlement SettlementConfig `koanf:"settlement"` } type DashboardConfig struct { Environment string `koanf:"environment"` HTTP HTTPConfig `koanf:"http"` - Database DatabaseConfig `koanf:"database"` + Database DatabaseConfig `koanf:"db"` } type GRPCConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` - ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` + ShutdownTimeout time.Duration `koanf:"timeout"` } type HTTPConfig struct { @@ -36,12 +39,23 @@ type HTTPConfig struct { } type DatabaseConfig struct { - Host string `koanf:"host"` - Port int `koanf:"port"` - Name string `koanf:"name"` - User string `koanf:"user"` - Password string `koanf:"password"` - SSLMode string `koanf:"ssl-mode"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Name string `koanf:"name"` + User string `koanf:"user"` + Password string `koanf:"password"` + GormLogLevel int `koanf:"gorm-log-level"` + SSLMode string `koanf:"-"` +} + +type SettlementConfig struct { + Enabled bool `koanf:"enabled"` + Endpoint string `koanf:"endpoint"` + WorkerID string `koanf:"worker-id"` + PollInterval time.Duration `koanf:"poll-interval"` + BatchSize int `koanf:"batch-size"` + MaxAttempts int `koanf:"max-attempts"` + AdminToken string `koanf:"admin-token"` } func Load(path string) (*Config, error) { @@ -52,12 +66,14 @@ func Load(path string) (*Config, error) { Port: 8600, ShutdownTimeout: 10 * time.Second, }, - Database: defaultDatabaseConfig(), + Database: defaultDatabaseConfig(), + Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8}, } if err := load(path, cfg); err != nil { return nil, err } + applyEnvironment(&cfg.Database, &cfg.Settlement) if err := cfg.Validate(); err != nil { return nil, err } @@ -78,12 +94,28 @@ func LoadDashboard(path string) (*DashboardConfig, error) { if err := load(path, cfg); err != nil { return nil, err } + applyEnvironment(&cfg.Database, nil) if err := cfg.Validate(); err != nil { return nil, err } return cfg, nil } +func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) { + if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok { + database.Password = value + } + if settlement == nil { + return + } + if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok { + settlement.Endpoint = strings.TrimRight(value, "/") + } + if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok { + settlement.AdminToken = value + } +} + func load(path string, target any) error { k := koanf.New(".") if err := k.Load(file.Provider(path), toml.Parser()); err != nil { @@ -115,6 +147,9 @@ func (c *Config) Validate() error { if c.GRPC.ShutdownTimeout <= 0 { return fmt.Errorf("grpc shutdown timeout must be positive") } + if c.Environment == "production" && c.Settlement.AdminToken == "" { + return fmt.Errorf("settlement admin token is required in production") + } return validateDatabase(c.Database) } diff --git a/infrastructure/config/config_test.go b/infrastructure/config/config_test.go index 8deb720..7959462 100644 --- a/infrastructure/config/config_test.go +++ b/infrastructure/config/config_test.go @@ -10,7 +10,7 @@ import ( func TestLoadUsesDefaultsAndOverrides(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "gl.toml") - contents := []byte("[grpc]\nport = 0\nshutdown-timeout = \"3s\"\n") + contents := []byte("[grpc]\nport = 0\ntimeout = \"3s\"\n") if err := os.WriteFile(path, contents, 0o600); err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) { func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "dashboard.toml") - contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n") + contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n[db]\nname = \"dashboard_db\"\n") if err := os.WriteFile(path, contents, 0o600); err != nil { t.Fatal(err) } @@ -42,7 +42,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second { t.Fatalf("unexpected http config: %+v", cfg.HTTP) } - if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 { + if cfg.Database.Name != "dashboard_db" || cfg.Database.Port != 5432 { t.Fatalf("unexpected database defaults: %+v", cfg.Database) } } @@ -50,7 +50,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { func TestLoadRejectsInvalidConfiguration(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "gl.toml") - if err := os.WriteFile(path, []byte("[grpc]\nshutdown-timeout = \"0s\"\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte("[grpc]\ntimeout = \"0s\"\n"), 0o600); err != nil { t.Fatal(err) } @@ -64,3 +64,46 @@ func TestLoadReturnsMissingFileError(t *testing.T) { t.Fatal("expected missing file error") } } + +func TestLoadAppliesSecretEnvironmentOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + contents := []byte("environment = \"production\"\n[grpc]\ntimeout = \"3s\"\n[settlement]\nenabled = true\nendpoint = \"https://invalid.example\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret") + t.Setenv("DARANO_GL_ADMIN_TOKEN", "runtime-admin-secret") + t.Setenv("DARANO_GL_SETTLEMENT_ENDPOINT", "https://horizon.example/") + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Database.Password != "runtime-db-secret" { + t.Fatal("database password was not loaded from the environment") + } + if cfg.Settlement.AdminToken != "runtime-admin-secret" { + t.Fatal("admin token was not loaded from the environment") + } + if cfg.Settlement.Endpoint != "https://horizon.example" { + t.Fatalf("unexpected settlement endpoint: %q", cfg.Settlement.Endpoint) + } +} + +func TestLoadDashboardAppliesDatabasePasswordEnvironmentOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dashboard.toml") + if err := os.WriteFile(path, []byte("[http]\nport = 0\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret") + + cfg, err := LoadDashboard(path) + if err != nil { + t.Fatal(err) + } + if cfg.Database.Password != "runtime-db-secret" { + t.Fatal("database password was not loaded from the environment") + } +} diff --git a/infrastructure/kuknos/submitter.go b/infrastructure/kuknos/submitter.go new file mode 100644 index 0000000..fea33ef --- /dev/null +++ b/infrastructure/kuknos/submitter.go @@ -0,0 +1,55 @@ +package kuknos + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "gl/domain/ledger" +) + +// Submitter sends a pre-signed Stellar/Kuknos transaction to Horizon. Signing +// remains outside GL; GL owns durable admission, retry, and reconciliation. +type Submitter struct { + Endpoint string + Client *http.Client +} + +func (s Submitter) Submit(ctx context.Context, record ledger.Settlement) (string, error) { + if s.Endpoint == "" || record.SignedTransactionXDR == "" { + return "", fmt.Errorf("kuknos submission endpoint and signed xdr are required") + } + form := url.Values{"tx": {record.SignedTransactionXDR}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.Endpoint, "/")+"/transactions", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := s.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var body struct { + Hash string `json:"hash"` + Extras struct { + ResultCodes struct { + Transaction string `json:"transaction"` + } `json:"result_codes"` + } `json:"extras"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decode kuknos response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.Hash == "" { + return "", fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, body.Extras.ResultCodes.Transaction) + } + return body.Hash, nil +} diff --git a/infrastructure/postgres/database.go b/infrastructure/postgres/database.go index f5e5a16..939b12b 100644 --- a/infrastructure/postgres/database.go +++ b/infrastructure/postgres/database.go @@ -98,3 +98,7 @@ type txAdapter struct { func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row { return t.Tx.QueryRow(ctx, sql, args...) } + +func (t txAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return t.Tx.Query(ctx, sql, args...) +} diff --git a/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql b/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql new file mode 100644 index 0000000..ea080c7 --- /dev/null +++ b/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE kuknos_settlements ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + status text NOT NULL CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW')), + network text NOT NULL DEFAULT 'kuknos', + signed_transaction_xdr text NOT NULL DEFAULT '', + transaction_hash text NOT NULL DEFAULT '', + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + available_at timestamptz NOT NULL DEFAULT clock_timestamp(), + locked_at timestamptz, + locked_by text NOT NULL DEFAULT '', + last_error text NOT NULL DEFAULT '', + submitted_at timestamptz, + confirmed_at timestamptz, + manual_review_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX kuknos_settlements_due_idx ON kuknos_settlements (status, available_at); diff --git a/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql b/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql new file mode 100644 index 0000000..e91c4e2 --- /dev/null +++ b/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE kuknos_settlement_operator_audits ( + id bigserial PRIMARY KEY, + settlement_id uuid NOT NULL REFERENCES kuknos_settlements(id), + actor_id text NOT NULL CHECK (actor_id <> ''), + action text NOT NULL CHECK (action IN ('RETRY')), + reason text NOT NULL CHECK (reason <> ''), + previous_status text NOT NULL, + resulting_status text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX kuknos_settlement_operator_audits_settlement_idx + ON kuknos_settlement_operator_audits (settlement_id, created_at DESC); diff --git a/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql b/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql new file mode 100644 index 0000000..f17fbbf --- /dev/null +++ b/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE kuknos_reconciliation_issues ( + settlement_id uuid PRIMARY KEY REFERENCES kuknos_settlements(id), + issue_type text NOT NULL CHECK (issue_type IN ('MISSING_LEDGER_CONFIRMATION','TRANSACTION_HASH_MISMATCH')), + expected_hash text NOT NULL DEFAULT '', + actual_hash text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','RESOLVED')), + first_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(), + last_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(), + resolved_at timestamptz +); + +CREATE INDEX kuknos_reconciliation_issues_open_idx + ON kuknos_reconciliation_issues (status, last_detected_at DESC); diff --git a/infrastructure/postgres/settlement_repository.go b/infrastructure/postgres/settlement_repository.go new file mode 100644 index 0000000..236cbe1 --- /dev/null +++ b/infrastructure/postgres/settlement_repository.go @@ -0,0 +1,204 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "gl/domain/ledger" +) + +type SettlementRepository struct{ database Database } + +type ReconciliationStats struct { + Open, Detected, Resolved int64 +} + +type txQueryer interface { + Query(context.Context, string, ...any) (Rows, error) +} + +func NewSettlementRepository(database Database) *SettlementRepository { + return &SettlementRepository{database: database} +} + +func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) { + var stats ledger.SettlementStats + err := r.database.QueryRow(ctx, `SELECT + COUNT(*) FILTER (WHERE status='PENDING'), + COUNT(*) FILTER (WHERE status='RETRYABLE'), + COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'), + COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0) +FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds) + if err != nil { + return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err) + } + return stats, nil +} + +func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) { + if err := s.Validate(); err != nil { + return ledger.Settlement{}, false, err + } + var inserted string + err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements + (id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`, + s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted) + if err == nil { + return s, false, nil + } + if err == pgx.ErrNoRows { + var existingXDR string + if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil { + return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err) + } + if existingXDR != s.SignedTransactionXDR { + return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict + } + existing, err := r.Get(ctx, inserted, "") + return existing, true, err + } + return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err) +} + +func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) { + var s ledger.Settlement + err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR) + if err != nil { + return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err) + } + return s, nil +} + +func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) { + tx, err := r.database.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil { + return nil, fmt.Errorf("release stale settlement locks: %w", err) + } + queryer, ok := tx.(txQueryer) + if !ok { + return nil, fmt.Errorf("settlement transaction does not support row queries") + } + rows, err := queryer.Query(ctx, `WITH due AS ( +SELECT id FROM kuknos_settlements +WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by='' +ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2 +) +UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp() +FROM due WHERE s.id=due.id +RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []ledger.Settlement + for rows.Next() { + var s ledger.Settlement + if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil { + return nil, err + } + result = append(result, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error { + _, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID) + return err +} + +func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) { + tx, err := r.database.Begin(ctx) + if err != nil { + return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err) + } + defer tx.Rollback(ctx) + var previous ledger.SettlementStatus + if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil { + return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err) + } + if previous == ledger.SettlementConfirmed { + return ledger.Settlement{}, fmt.Errorf("confirmed settlement cannot be retried") + } + if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil { + return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil { + return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err) + } + return r.Get(ctx, id, "") +} + +// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements +// whose latest GL lifecycle evidence is missing or has a different hash. +func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) { + if grace <= 0 { + grace = 10 * time.Minute + } + tx, err := r.database.Begin(ctx) + if err != nil { + return ReconciliationStats{}, err + } + defer tx.Rollback(ctx) + var detected int64 + err = tx.QueryRow(ctx, `WITH inconsistent AS ( + SELECT s.id, + CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type, + s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash + FROM kuknos_settlements s + LEFT JOIN LATERAL ( + SELECT id, blockchain_transaction_hash FROM transaction_events + WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id + ORDER BY event_version DESC, recorded_at DESC LIMIT 1 + ) e ON true + WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval + AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash) +), upserted AS ( + INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash) + SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent + ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type, + expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash, + status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL + WHERE kuknos_reconciliation_issues.status<>'OPEN' + OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type + OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash + OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash + RETURNING 1 +) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected) + if err != nil { + return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err) + } + result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp() + WHERE status='OPEN' AND EXISTS ( + SELECT 1 FROM kuknos_settlements s JOIN transaction_events e + ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id + WHERE s.id=i.settlement_id AND s.status='CONFIRMED' + AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>'' + )`) + if err != nil { + return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err) + } + var open int64 + if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil { + return ReconciliationStats{}, err + } + if err := tx.Commit(ctx); err != nil { + return ReconciliationStats{}, err + } + return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil +} diff --git a/infrastructure/postgres/settlement_repository_test.go b/infrastructure/postgres/settlement_repository_test.go new file mode 100644 index 0000000..d2f14ae --- /dev/null +++ b/infrastructure/postgres/settlement_repository_test.go @@ -0,0 +1,41 @@ +package postgres + +import ( + "context" + "testing" + "time" +) + +func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) { + tx := &fakeTx{rows: []Row{ + fakeRow{values: []any{int64(1)}}, + fakeRow{values: []any{int64(2)}}, + }} + repository := NewSettlementRepository(&fakeDatabase{tx: tx}) + + stats, err := repository.ReconcileConfirmationEvidence(context.Background(), 10*time.Minute) + if err != nil { + t.Fatal(err) + } + if stats.Detected != 1 || stats.Resolved != 1 || stats.Open != 2 { + t.Fatalf("unexpected reconciliation stats: %+v", stats) + } + if !tx.committed || tx.execCount != 1 { + t.Fatalf("unexpected transaction state: %+v", tx) + } +} + +func TestReconcileConfirmationEvidenceRollsBackResolutionFailure(t *testing.T) { + tx := &fakeTx{ + rows: []Row{fakeRow{values: []any{int64(1)}}}, + execErrAt: 1, + } + repository := NewSettlementRepository(&fakeDatabase{tx: tx}) + + if _, err := repository.ReconcileConfirmationEvidence(context.Background(), time.Minute); err == nil { + t.Fatal("expected resolution error") + } + if !tx.rolledBack || tx.committed { + t.Fatalf("failed reconciliation was not rolled back: %+v", tx) + } +} diff --git a/interface/grpc/health.go b/interface/grpc/health.go index 4757e5a..9b8b5e9 100644 --- a/interface/grpc/health.go +++ b/interface/grpc/health.go @@ -5,18 +5,27 @@ import ( "gl/application/health" applicationledger "gl/application/ledger" + applicationsettlement "gl/application/settlement" basev1 "gl/gen/base/v1" ledgerv1 "gl/gen/ledger/v1" ) type Handler struct { ledgerv1.UnimplementedGeneralLedgerServiceServer - health *health.Service - ledger *applicationledger.Service + health *health.Service + ledger *applicationledger.Service + settlement *applicationsettlement.Service + adminToken string } -func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service) *Handler { - return &Handler{health: healthService, ledger: ledgerService} +func (h *Handler) WithSettlementAdminToken(token string) *Handler { h.adminToken = token; return h } + +func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service, settlementService ...*applicationsettlement.Service) *Handler { + var settlement *applicationsettlement.Service + if len(settlementService) > 0 { + settlement = settlementService[0] + } + return &Handler{health: healthService, ledger: ledgerService, settlement: settlement} } func NewHealthHandler(service *health.Service) *Handler { @@ -26,7 +35,13 @@ func NewHealthHandler(service *health.Service) *Handler { func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) { result := h.health.Check(ctx) return &ledgerv1.HealthResponse{ - Serving: result.Serving, - DatabaseReady: result.DatabaseReady, + Serving: result.Serving, + DatabaseReady: result.DatabaseReady, + SettlementEnabled: result.SettlementEnabled, + SettlementReady: result.SettlementReady, + SettlementPending: result.SettlementStats.Pending, + SettlementRetryable: result.SettlementStats.Retryable, + SettlementManualReview: result.SettlementStats.ManualReview, + OldestPendingSeconds: result.SettlementStats.OldestPendingSeconds, }, nil } diff --git a/interface/grpc/settlement.go b/interface/grpc/settlement.go new file mode 100644 index 0000000..6411ca2 --- /dev/null +++ b/interface/grpc/settlement.go @@ -0,0 +1,60 @@ +package grpcadapter + +import ( + "context" + "crypto/subtle" + + "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.EnqueueSettlementRequest) (*ledgerv1.EnqueueSettlementResponse, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil +} + +func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySettlementRequest) (*ledgerv1.RetrySettlementResponse, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + values := metadata.ValueFromIncomingContext(ctx, "x-admin-token") + if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 { + return nil, status.Error(codes.PermissionDenied, "invalid administrative token") + } + record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason()) + if err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil +} + +func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement { + return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError} +} + +func (h *Handler) GetSettlement(ctx context.Context, request *ledgerv1.GetSettlementRequest) (*ledgerv1.Settlement, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + var id, key string + switch lookup := request.GetLookup().(type) { + case *ledgerv1.GetSettlementRequest_SettlementId: + id = lookup.SettlementId + case *ledgerv1.GetSettlementRequest_IdempotencyKey: + key = lookup.IdempotencyKey + } + record, err := h.settlement.Get(ctx, id, key) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + return settlementMessage(record), nil +} diff --git a/scripts/air-run.sh b/scripts/air-run.sh deleted file mode 100644 index d44e8d7..0000000 --- a/scripts/air-run.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -set -u - -./tmp/gl -conf ./gl.cfg.toml & -gl_pid=$! - -./tmp/dashboard -conf ./dashboard.cfg.toml & -dashboard_pid=$! - -shutdown() { - trap - EXIT - kill -TERM "$gl_pid" "$dashboard_pid" 2>/dev/null || true - wait "$gl_pid" 2>/dev/null || true - wait "$dashboard_pid" 2>/dev/null || true -} - -trap 'exit 0' INT TERM -trap shutdown EXIT - -wait -n "$gl_pid" "$dashboard_pid" -status=$? -exit "$status" From 3445ead897796876fc22e5fe4e7ff70d73f10042ba1e251add769b53849273e8 Mon Sep 17 00:00:00 2001 From: Navid Filsaraee Date: Thu, 17 Sep 2026 13:31:42 +0330 Subject: [PATCH 18/20] Move Docker deployment files to deployment directory --- .gitea/actions/docker-build-push/action.yml | 2 +- {build => deployment}/Dockerfile | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {build => deployment}/Dockerfile (100%) diff --git a/.gitea/actions/docker-build-push/action.yml b/.gitea/actions/docker-build-push/action.yml index 8d39666..6f5e5f5 100644 --- a/.gitea/actions/docker-build-push/action.yml +++ b/.gitea/actions/docker-build-push/action.yml @@ -23,7 +23,7 @@ runs: set -euo pipefail docker buildx build \ - -f build/Dockerfile \ + -f deployment/Dockerfile \ --build-arg "GO_PROXY=https://go.reg.darano.ir" \ --load \ -t "$IMAGE:$BRANCH" \ diff --git a/build/Dockerfile b/deployment/Dockerfile similarity index 100% rename from build/Dockerfile rename to deployment/Dockerfile From f7366a198574fe16b16e17816778bf7317e5f76ff5797740848fb1f960b64bbc Mon Sep 17 00:00:00 2001 From: Navid Filsaraee Date: Thu, 17 Sep 2026 14:56:57 +0330 Subject: [PATCH 19/20] Add OpenTelemetry tracing and log correlation --- cmd/dashboard/main.go | 2 + cmd/gl/main.go | 2 + go.mod | 39 +++++++--- go.sum | 95 +++++++++++++++++------ infrastructure/config/config.go | 21 ++++- infrastructure/observability/logger.go | 22 +++++- infrastructure/observability/telemetry.go | 39 ++++++++++ interface/grpc/server.go | 3 +- interface/web/server.go | 4 +- 9 files changed, 187 insertions(+), 40 deletions(-) create mode 100644 infrastructure/observability/telemetry.go diff --git a/cmd/dashboard/main.go b/cmd/dashboard/main.go index b1dd2ca..59f7c7b 100644 --- a/cmd/dashboard/main.go +++ b/cmd/dashboard/main.go @@ -25,6 +25,8 @@ func main() { slog.Error("load dashboard configuration", "error", err) os.Exit(1) } + shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl-dashboard", cfg.Environment) + defer shutdownTelemetry(context.Background()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 122f6f3..7712b08 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -29,6 +29,8 @@ func main() { slog.Error("load configuration", "error", err) os.Exit(1) } + shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl", cfg.Environment) + defer shutdownTelemetry(context.Background()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/go.mod b/go.mod index 617c6fe..dc3b925 100644 --- a/go.mod +++ b/go.mod @@ -8,18 +8,32 @@ require ( github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.4 - google.golang.org/grpc v1.67.1 - google.golang.org/protobuf v1.36.6 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + google.golang.org/grpc v1.81.1 + google.golang.org/protobuf v1.36.11 ) 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/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cli/browser v1.3.0 // indirect github.com/fatih/color v1.16.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.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 @@ -30,14 +44,19 @@ require ( 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 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect ) tool ( diff --git a/go.sum b/go.sum index fac2ebf..4822ea7 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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= @@ -13,12 +17,25 @@ 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -53,30 +70,60 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -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= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index f0619d1..5372718 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -17,12 +17,27 @@ type Config struct { GRPC GRPCConfig `koanf:"grpc"` Database DatabaseConfig `koanf:"db"` Settlement SettlementConfig `koanf:"settlement"` + Telemetry TelemetryConfig `koanf:"telemetry"` } type DashboardConfig struct { - Environment string `koanf:"environment"` - HTTP HTTPConfig `koanf:"http"` - Database DatabaseConfig `koanf:"db"` + Environment string `koanf:"environment"` + HTTP HTTPConfig `koanf:"http"` + Database DatabaseConfig `koanf:"db"` + Telemetry TelemetryConfig `koanf:"telemetry"` +} + +type TelemetryConfig struct { + Enabled bool `koanf:"enabled"` + Protocol string `koanf:"protocol"` + Endpoint string `koanf:"endpoint"` + Insecure bool `koanf:"insecure"` + Headers map[string]string `koanf:"headers"` + Sampler string `koanf:"sampler"` + SamplerArg string `koanf:"sampler-arg"` + ExportTimeout time.Duration `koanf:"export-timeout"` + BatchTimeout time.Duration `koanf:"batch-timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` } type GRPCConfig struct { diff --git a/infrastructure/observability/logger.go b/infrastructure/observability/logger.go index 1850a4e..610be07 100644 --- a/infrastructure/observability/logger.go +++ b/infrastructure/observability/logger.go @@ -1,8 +1,11 @@ package observability import ( + "context" "log/slog" "os" + + "go.opentelemetry.io/otel/trace" ) // Configure installs the process-wide JSON logger used by every GL adapter. @@ -24,7 +27,24 @@ func Configure(service string, level slog.Leveler) *slog.Logger { return attr }, }) - logger := slog.New(handler).With("service", service) + logger := slog.New(&traceHandler{Handler: handler}).With("service", service) slog.SetDefault(logger) return logger } + +type traceHandler struct{ slog.Handler } + +func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error { + spanContext := trace.SpanContextFromContext(ctx) + if spanContext.IsValid() { + record.AddAttrs(slog.String("trace_id", spanContext.TraceID().String()), slog.String("span_id", spanContext.SpanID().String())) + } + return h.Handler.Handle(ctx, record) +} + +func (h *traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &traceHandler{Handler: h.Handler.WithAttrs(attrs)} +} +func (h *traceHandler) WithGroup(name string) slog.Handler { + return &traceHandler{Handler: h.Handler.WithGroup(name)} +} diff --git a/infrastructure/observability/telemetry.go b/infrastructure/observability/telemetry.go new file mode 100644 index 0000000..87d5862 --- /dev/null +++ b/infrastructure/observability/telemetry.go @@ -0,0 +1,39 @@ +package observability + +import ( + "context" + "fmt" + "log/slog" + "os" + "strconv" + "strings" + "time" + + "gl/infrastructure/config" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// Setup configures tracing without making telemetry availability a startup dependency. +func Setup(ctx context.Context, cfg config.TelemetryConfig, service, environment string) func(context.Context) { + if value := os.Getenv("OTEL_SERVICE_NAME"); value != "" { service = value } + if value := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"); value != "" { cfg.Endpoint = value } else if value := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); value != "" { cfg.Endpoint = value } + if value := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"); value != "" { cfg.Protocol = value } + if value := os.Getenv("OTEL_TRACES_SAMPLER"); value != "" { cfg.Sampler = value }; if value := os.Getenv("OTEL_TRACES_SAMPLER_ARG"); value != "" { cfg.SamplerArg = value } + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) + if !cfg.Enabled || cfg.Endpoint == "" || (cfg.Protocol != "grpc" && cfg.Protocol != "http/protobuf") { if cfg.Enabled { slog.Warn("OpenTelemetry disabled due to invalid configuration") }; return func(context.Context) {} } + var exp sdktrace.SpanExporter; var err error + if cfg.Protocol == "grpc" { options := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint), otlptracegrpc.WithTimeout(timeout(cfg.ExportTimeout)), otlptracegrpc.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracegrpc.WithInsecure()) }; exp, err = otlptracegrpc.New(ctx, options...) } else { options := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.Endpoint), otlptracehttp.WithTimeout(timeout(cfg.ExportTimeout)), otlptracehttp.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracehttp.WithInsecure()) }; exp, err = otlptracehttp.New(ctx, options...) } + if err != nil { slog.Warn("OpenTelemetry exporter unavailable; tracing disabled", "error", err); return func(context.Context) {} } + host, _ := os.Hostname(); res, err := resource.New(ctx, resource.WithAttributes(attribute.String("service.name", service), attribute.String("service.namespace", "darano"), attribute.String("deployment.environment.name", environment), attribute.String("host.name", host))); if err != nil { slog.Warn("OpenTelemetry resource setup failed", "error", err); return func(context.Context) {} } + ratio, parseErr := strconv.ParseFloat(cfg.SamplerArg, 64); if cfg.Sampler == "parentbased_traceidratio" && (parseErr != nil || ratio < 0 || ratio > 1) { slog.Warn("OpenTelemetry disabled due to invalid sampler", "error", fmt.Errorf("invalid sampler-arg")); return func(context.Context) {} } + provider := sdktrace.NewTracerProvider(sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), sdktrace.WithBatcher(exp, sdktrace.WithMaxQueueSize(2048), sdktrace.WithBatchTimeout(timeout(cfg.BatchTimeout)), sdktrace.WithExportTimeout(timeout(cfg.ExportTimeout)))) + otel.SetTracerProvider(provider); return func(stop context.Context) { stop, cancel := context.WithTimeout(stop, timeout(cfg.ShutdownTimeout)); defer cancel(); if err := provider.Shutdown(stop); err != nil { slog.Warn("OpenTelemetry shutdown failed", "error", err) } } +} +func timeout(value time.Duration) time.Duration { if value > 0 { return value }; return 5*time.Second } +var _ = strings.TrimSpace diff --git a/interface/grpc/server.go b/interface/grpc/server.go index 985cf64..877a63f 100644 --- a/interface/grpc/server.go +++ b/interface/grpc/server.go @@ -11,6 +11,7 @@ import ( ledgerv1 "gl/gen/ledger/v1" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/reflection" @@ -34,7 +35,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(grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery())) + server := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery())) ledgerv1.RegisterGeneralLedgerServiceServer(server, handler) reflection.Register(server) diff --git a/interface/web/server.go b/interface/web/server.go index c7d778e..69fd076 100644 --- a/interface/web/server.go +++ b/interface/web/server.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "time" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) type ServerConfig struct { @@ -18,7 +20,7 @@ type ServerConfig struct { func Run(ctx context.Context, cfg ServerConfig, handler http.Handler) error { server := &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), - Handler: handler, + Handler: otelhttp.NewHandler(handler, "gl-dashboard"), ReadHeaderTimeout: cfg.ReadHeaderTimeout, } serveErr := make(chan error, 1) From 810448b6e728d8cb96dec3abd5f1d5921ee9219027b3c14b58815756a118e723 Mon Sep 17 00:00:00 2001 From: Navid Filsaraee Date: Mon, 21 Sep 2026 15:33:47 +0330 Subject: [PATCH 20/20] chore: update project changes --- application/explorer/service.go | 41 +- application/explorer/service_test.go | 35 +- infrastructure/observability/logger.go | 11 + infrastructure/postgres/query_repository.go | 36 + interface/web/handler_test.go | 4 +- interface/web/i18n.go | 24 + interface/web/templates.templ | 44 + interface/web/templates_templ.go | 2074 ++++++++++++------- 8 files changed, 1459 insertions(+), 810 deletions(-) diff --git a/application/explorer/service.go b/application/explorer/service.go index d36a946..4420f30 100644 --- a/application/explorer/service.go +++ b/application/explorer/service.go @@ -13,10 +13,11 @@ import ( ) const ( - recentJournalLimit = 12 - transactionPageSize = 20 - topAssetLimit = 5 - topHoldersPerAsset = 5 + recentJournalLimit = 12 + recentSettlementLimit = 12 + transactionPageSize = 20 + topAssetLimit = 5 + topHoldersPerAsset = 5 ) type Stats struct { @@ -26,6 +27,25 @@ type Stats struct { LastRecordedAt time.Time } +type SettlementStats struct { + Pending int64 + Retryable int64 + Confirmed int64 + ManualReview int64 +} + +type Settlement struct { + ID string + SourceService string + SourceTransactionID string + Status ledger.SettlementStatus + Network string + TransactionHash string + Attempts int + LastError string + UpdatedAt time.Time +} + type Holder struct { Rank int64 OwnerType string @@ -47,6 +67,7 @@ type Repository interface { GetByID(context.Context, string) (ledger.Journal, error) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) TopHolders(context.Context, int, int) ([]Holder, error) + SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) } type Service struct { @@ -54,8 +75,10 @@ type Service struct { } type Dashboard struct { - Stats Stats - Journals []ledger.Journal + Stats Stats + Journals []ledger.Journal + SettlementStats SettlementStats + Settlements []Settlement } type Assets struct { @@ -100,7 +123,11 @@ func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) { if err != nil { return Dashboard{}, fmt.Errorf("read recent journals: %w", err) } - return Dashboard{Stats: stats, Journals: journals}, nil + settlementStats, settlements, err := s.repository.SettlementDashboard(ctx, recentSettlementLimit) + if err != nil { + return Dashboard{}, fmt.Errorf("read settlement dashboard: %w", err) + } + return Dashboard{Stats: stats, Journals: journals, SettlementStats: settlementStats, Settlements: settlements}, nil } func (s *Service) Assets(ctx context.Context) (Assets, error) { diff --git a/application/explorer/service_test.go b/application/explorer/service_test.go index da7d873..adec6da 100644 --- a/application/explorer/service_test.go +++ b/application/explorer/service_test.go @@ -10,14 +10,16 @@ import ( ) type repositoryStub struct { - stats Stats - journals []ledger.Journal - transactions []ledger.Journal - journal ledger.Journal - journalErr error - holders []Holder - balance ledger.Amount - lastFilter *ledger.JournalFilter + stats Stats + journals []ledger.Journal + transactions []ledger.Journal + journal ledger.Journal + journalErr error + holders []Holder + balance ledger.Amount + settlementStats SettlementStats + settlements []Settlement + lastFilter *ledger.JournalFilter } func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil } @@ -39,6 +41,23 @@ func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.T func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) { return r.holders, nil } +func (r repositoryStub) SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) { + return r.settlementStats, r.settlements, nil +} + +func TestDashboardIncludesBlockchainSettlements(t *testing.T) { + service := NewService(repositoryStub{ + settlementStats: SettlementStats{Confirmed: 7, Pending: 2}, + settlements: []Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, TransactionHash: "chain-hash"}}, + }) + result, err := service.Dashboard(context.Background()) + if err != nil { + t.Fatal(err) + } + if result.SettlementStats.Confirmed != 7 || len(result.Settlements) != 1 || result.Settlements[0].TransactionHash != "chain-hash" { + t.Fatalf("unexpected settlement dashboard: %+v", result) + } +} func TestAssetsIncludesTopHolders(t *testing.T) { balance, err := ledger.ParseAmount("125.5") diff --git a/infrastructure/observability/logger.go b/infrastructure/observability/logger.go index 610be07..69c6954 100644 --- a/infrastructure/observability/logger.go +++ b/infrastructure/observability/logger.go @@ -19,6 +19,7 @@ func Configure(service string, level slog.Leveler) *slog.Logger { attr.Key = "timestamp" case slog.LevelKey: attr.Key = "severity" + attr.Value = slog.StringValue(levelName(attr.Value)) case slog.MessageKey: attr.Key = "message" case slog.SourceKey: @@ -32,6 +33,16 @@ func Configure(service string, level slog.Leveler) *slog.Logger { return logger } +func levelName(value slog.Value) string { + if value.Kind() == slog.KindInt64 { + return slog.Level(value.Int64()).String() + } + if level, ok := value.Any().(slog.Level); ok { + return level.String() + } + return value.String() +} + type traceHandler struct{ slog.Handler } func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error { diff --git a/infrastructure/postgres/query_repository.go b/infrastructure/postgres/query_repository.go index d090006..c99c716 100644 --- a/infrastructure/postgres/query_repository.go +++ b/infrastructure/postgres/query_repository.go @@ -86,6 +86,42 @@ func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) { return stats, nil } +func (r *JournalRepository) SettlementDashboard(ctx context.Context, limit int) (explorer.SettlementStats, []explorer.Settlement, error) { + if limit <= 0 || limit > 100 { + limit = 12 + } + var stats explorer.SettlementStats + if err := r.database.QueryRow(ctx, `SELECT + count(*) FILTER (WHERE status='PENDING'), + count(*) FILTER (WHERE status='RETRYABLE'), + count(*) FILTER (WHERE status='CONFIRMED'), + count(*) FILTER (WHERE status='MANUAL_REVIEW') + FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.Confirmed, &stats.ManualReview); err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("read settlement stats: %w", err) + } + rows, err := r.database.Query(ctx, `SELECT id, source_service, source_transaction_id, status, + network, transaction_hash, attempts, last_error, updated_at + FROM kuknos_settlements ORDER BY updated_at DESC LIMIT $1`, limit) + if err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("list recent settlements: %w", err) + } + defer rows.Close() + settlements := make([]explorer.Settlement, 0, limit) + for rows.Next() { + var settlement explorer.Settlement + if err := rows.Scan(&settlement.ID, &settlement.SourceService, &settlement.SourceTransactionID, + &settlement.Status, &settlement.Network, &settlement.TransactionHash, &settlement.Attempts, + &settlement.LastError, &settlement.UpdatedAt); err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("scan recent settlement: %w", err) + } + settlements = append(settlements, settlement) + } + if err := rows.Err(); err != nil { + return explorer.SettlementStats{}, nil, fmt.Errorf("iterate recent settlements: %w", err) + } + return stats, settlements, nil +} + func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) { if assetLimit <= 0 || assetLimit > 20 { assetLimit = 5 diff --git a/interface/web/handler_test.go b/interface/web/handler_test.go index f199e8a..fb830d9 100644 --- a/interface/web/handler_test.go +++ b/interface/web/handler_test.go @@ -65,6 +65,8 @@ func TestDashboardRendersFullTemplPage(t *testing.T) { ID: "journal-1", EffectKind: "deposit", SourceService: "wallet", Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"}, }}, + SettlementStats: explorer.SettlementStats{Confirmed: 9, Pending: 2}, + Settlements: []explorer.Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, Network: "kuknos", TransactionHash: "chain-hash", SourceService: "wallet", Attempts: 1}}, }} request := httptest.NewRequest(http.MethodGet, "/", nil) response := httptest.NewRecorder() @@ -72,7 +74,7 @@ func TestDashboardRendersFullTemplPage(t *testing.T) { NewHandler(service).ServeHTTP(response, request) body := response.Body.String() - for _, expected := range []string{"", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { + for _, expected := range []string{"", "DARANO", "1,234", "abc123", "Blockchain settlements", "Confirmed on chain", "chain-hash", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} { if !strings.Contains(body, expected) { t.Fatalf("response did not contain %q", expected) } diff --git a/interface/web/i18n.go b/interface/web/i18n.go index 34df9a6..8f8fbc7 100644 --- a/interface/web/i18n.go +++ b/interface/web/i18n.go @@ -109,6 +109,18 @@ var english = map[string]string{ "tracked_accounts": "Tracked accounts", "last_recorded": "Last recorded", "latest_activity": "Latest ledger activity", + "blockchain_settlements": "Blockchain settlements", + "settlement_summary": "LATEST SETTLEMENT STATE", + "confirmed_on_chain": "Confirmed on chain", + "pending_submission": "Pending submission", + "retryable": "Retryable", + "manual_review": "Manual review", + "blockchain_transaction": "Blockchain transaction", + "status": "Status", + "attempts": "Attempts", + "updated": "Updated", + "not_submitted": "Not submitted", + "no_settlements": "No blockchain settlements have been queued yet.", "newest_first": "NEWEST FIRST", "top_asset_holders": "Top asset holders", "holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS", @@ -210,6 +222,18 @@ var persian = map[string]string{ "tracked_accounts": "حساب‌های ردیابی‌شده", "last_recorded": "آخرین ثبت", "latest_activity": "آخرین فعالیت دفتر کل", + "blockchain_settlements": "تسویه‌های بلاکچین", + "settlement_summary": "آخرین وضعیت تسویه", + "confirmed_on_chain": "تأییدشده روی زنجیره", + "pending_submission": "در انتظار ارسال", + "retryable": "قابل تلاش مجدد", + "manual_review": "نیازمند بررسی دستی", + "blockchain_transaction": "تراکنش بلاکچین", + "status": "وضعیت", + "attempts": "تعداد تلاش", + "updated": "آخرین تغییر", + "not_submitted": "ارسال‌نشده", + "no_settlements": "هنوز تسویه‌ای برای بلاکچین در صف قرار نگرفته است.", "newest_first": "جدیدترین ابتدا", "top_asset_holders": "دارندگان برتر دارایی", "holder_balance_scope": "در دسترس + مسدود · دارایی‌های اخیر", diff --git a/interface/web/templates.templ b/interface/web/templates.templ index ce90fb9..1590951 100644 --- a/interface/web/templates.templ +++ b/interface/web/templates.templ @@ -72,6 +72,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia .pill { display:inline-flex; padding:6px 9px; border-radius:999px; background:var(--surface); font:750 10px/1 ui-monospace,SFMono-Regular,monospace; text-transform:uppercase; } .holder-id { max-width:330px; overflow-wrap:anywhere; font-weight:750; } .holder-id small { display:block; margin-top:5px; color:var(--muted); font:650 10px/1 ui-monospace,SFMono-Regular,monospace; } + td small { display:block; margin-top:5px; color:var(--muted); font:600 10px/1.35 ui-monospace,SFMono-Regular,monospace; overflow-wrap:anywhere; } .empty { padding:45px 24px; text-align:center; color:var(--muted); } .notice + .transaction-list { margin-top:46px; } .pagination { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; direction:ltr; } @@ -200,6 +201,16 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) {
{ tr(locale, "tracked_accounts") }{ count(data.Stats.AccountCount) }
{ tr(locale, "last_recorded") }
+
+

{ tr(locale, "blockchain_settlements") }

{ tr(locale, "settlement_summary") }
+
+
{ tr(locale, "confirmed_on_chain") }{ count(data.SettlementStats.Confirmed) }
+
{ tr(locale, "pending_submission") }{ count(data.SettlementStats.Pending) }
+
{ tr(locale, "retryable") }{ count(data.SettlementStats.Retryable) }
+
{ tr(locale, "manual_review") }{ count(data.SettlementStats.ManualReview) }
+
+ @SettlementTable(data.Settlements, locale) +

{ tr(locale, "latest_activity") }

{ tr(locale, "newest_first") }
@JournalTable(data.Journals, locale) @@ -207,6 +218,39 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) { } +templ SettlementTable(settlements []explorer.Settlement, locale Locale) { +
+ if len(settlements) == 0 { +
{ tr(locale, "no_settlements") }
+ } else { + + + + for _, settlement := range settlements { + + + + + + + + + } + +
{ tr(locale, "blockchain_transaction") }{ tr(locale, "status") }{ tr(locale, "network") }{ tr(locale, "source") }{ tr(locale, "attempts") }{ tr(locale, "updated") }
+ if settlement.TransactionHash != "" { + { short(settlement.TransactionHash, 22) } + } else { + { tr(locale, "not_submitted") } · { short(settlement.ID, 14) } + } + if settlement.LastError != "" { + { short(settlement.LastError, 70) } + } + { string(settlement.Status) }{ settlement.Network }{ settlement.SourceService }{ short(settlement.SourceTransactionID, 18) }{ strconv.Itoa(settlement.Attempts) }{ formatTime(settlement.UpdatedAt) }
+ } +
+} + templ AssetsContent(data explorer.Assets, locale Locale) {
diff --git a/interface/web/templates_templ.go b/interface/web/templates_templ.go index 4e78fc4..fb5a8ea 100644 --- a/interface/web/templates_templ.go +++ b/interface/web/templates_templ.go @@ -101,14 +101,14 @@ func Page(title string, active string, locale Locale, englishURL string, persian if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
\"\" ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
\"\" ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_name")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 135, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -121,7 +121,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "site_subtitle")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 134, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 135, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -139,7 +139,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "overview")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 138, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 139, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -157,7 +157,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "overview")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 140, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 141, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -176,7 +176,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 143, Col: 73} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 144, Col: 73} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -194,7 +194,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 145, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 146, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -213,7 +213,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 148, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 149, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -231,7 +231,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 150, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 151, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -250,7 +250,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 153, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 154, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -268,7 +268,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 155, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 156, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -286,7 +286,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "read_only")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 157, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 158, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -299,7 +299,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "light_mode")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 158, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 159, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) if templ_7745c5c3_Err != nil { @@ -312,7 +312,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "dark_mode")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 158, Col: 153} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 159, Col: 153} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) if templ_7745c5c3_Err != nil { @@ -325,7 +325,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "dark_mode")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 158, Col: 192} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 159, Col: 192} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) if templ_7745c5c3_Err != nil { @@ -343,7 +343,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(englishURL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 161, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 162, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -361,7 +361,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var22 templ.SafeURL templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(englishURL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 163, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 164, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -380,7 +380,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var23 templ.SafeURL templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(persianURL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 166, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 167, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { @@ -398,7 +398,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var24 templ.SafeURL templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(persianURL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 168, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 169, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -424,7 +424,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "footer_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 175, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 176, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -437,7 +437,7 @@ func Page(title string, active string, locale Locale, englishURL string, persian var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "timezone_notice")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 176, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 177, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -479,7 +479,7 @@ func TransactionSearch(value string, locale Locale) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(value) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 184, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 185, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) if templ_7745c5c3_Err != nil { @@ -492,7 +492,7 @@ func TransactionSearch(value string, locale Locale) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "transaction_placeholder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 184, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 185, Col: 85} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) if templ_7745c5c3_Err != nil { @@ -505,7 +505,7 @@ func TransactionSearch(value string, locale Locale) templ.Component { var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "transaction_hash")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 184, Col: 131} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 185, Col: 131} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) if templ_7745c5c3_Err != nil { @@ -518,7 +518,7 @@ func TransactionSearch(value string, locale Locale) templ.Component { var templ_7745c5c3_Var31 string templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explore_transaction")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 185, Col: 86} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 186, Col: 86} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) if templ_7745c5c3_Err != nil { @@ -560,7 +560,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var33 string templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "general_ledger_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 192, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) if templ_7745c5c3_Err != nil { @@ -573,7 +573,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var34 string templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_one")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { @@ -586,7 +586,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var35 string templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_line_two")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 193, Col: 72} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 72} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { @@ -599,7 +599,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var36 string templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "hero_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 194, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 195, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) if templ_7745c5c3_Err != nil { @@ -620,7 +620,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var37 string templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(tr(locale, "general_ledger_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 197, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 198, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) if templ_7745c5c3_Err != nil { @@ -633,7 +633,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var38 string templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed_journals")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 198, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { @@ -646,7 +646,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var39 string templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.JournalCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 198, Col: 136} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 136} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { @@ -659,7 +659,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var40 string templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_entries")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 76} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 76} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) if templ_7745c5c3_Err != nil { @@ -672,7 +672,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var41 string templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.EntryCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 199, Col: 123} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 123} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) if templ_7745c5c3_Err != nil { @@ -685,7 +685,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var42 string templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "tracked_accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 78} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 78} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { @@ -698,7 +698,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var43 string templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.Stats.AccountCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 200, Col: 127} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 127} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { @@ -711,7 +711,7 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var44 string templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "last_recorded")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 75} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 202, Col: 75} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { @@ -724,20 +724,20 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { var templ_7745c5c3_Var45 string templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(data.Stats.LastRecordedAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 201, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 202, Col: 129} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_activity")) + templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "blockchain_settlements")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 204, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 205, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { @@ -748,15 +748,166 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var47 string - templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "newest_first")) + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "settlement_summary")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 204, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 205, Col: 118} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var49 string + templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "confirmed_on_chain")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.Confirmed)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 207, Col: 137} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "pending_submission")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 208, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.Pending)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 208, Col: 135} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "retryable")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 209, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.Retryable)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 209, Col: 128} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "manual_review")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 210, Col: 76} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(count(data.SettlementStats.ManualReview)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 210, Col: 135} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = SettlementTable(data.Settlements, locale).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -764,7 +915,342 @@ func DashboardContent(data explorer.Dashboard, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func SettlementTable(settlements []explorer.Settlement, locale Locale) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var59 := templ.GetChildren(ctx) + if templ_7745c5c3_Var59 == nil { + templ_7745c5c3_Var59 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(settlements) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_settlements")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 224, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, settlement := range settlements { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "blockchain_transaction")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "status")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 124} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 157} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "attempts")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 192} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var66 string + templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "updated")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 227, Col: 226} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if settlement.TransactionHash != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var69 string + templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.TransactionHash, 22)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 233, Col: 169} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "not_submitted")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 68} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.ID, 14)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 100} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if settlement.LastError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.LastError, 70)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 238, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var74 string + templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(string(settlement.Status)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 241, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var75 string + templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(settlement.Network) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 242, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var76 string + templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(settlement.SourceService) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 243, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var77 string + templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(short(settlement.SourceTransactionID, 18)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 243, Col: 102} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var78 string + templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(settlement.Attempts)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 244, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var79 string + templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(settlement.UpdatedAt)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 245, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -788,103 +1274,103 @@ func AssetsContent(data explorer.Assets, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var48 := templ.GetChildren(ctx) - if templ_7745c5c3_Var48 == nil { - templ_7745c5c3_Var48 = templ.NopComponent + templ_7745c5c3_Var80 := templ.GetChildren(ctx) + if templ_7745c5c3_Var80 == nil { + templ_7745c5c3_Var80 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + var templ_7745c5c3_Var81 string + templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 212, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) + var templ_7745c5c3_Var82 string + templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 212, Col: 92} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 92} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var51 string - templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_explorer")) + var templ_7745c5c3_Var83 string + templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 214, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 258, Col: 55} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var52 string - templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "track_asset_ownership")) + var templ_7745c5c3_Var84 string + templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "track_asset_ownership")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 215, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 44} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) + var templ_7745c5c3_Var85 string + templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 216, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 260, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "top_asset_holders")) + var templ_7745c5c3_Var86 string + templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "top_asset_holders")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 219, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_balance_scope")) + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_balance_scope")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 219, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 115} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -892,7 +1378,7 @@ func AssetsContent(data explorer.Assets, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -916,193 +1402,193 @@ func HolderTable(holders []explorer.Holder, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var56 := templ.GetChildren(ctx) - if templ_7745c5c3_Var56 == nil { - templ_7745c5c3_Var56 = templ.NopComponent + templ_7745c5c3_Var88 := templ.GetChildren(ctx) + if templ_7745c5c3_Var88 == nil { + templ_7745c5c3_Var88 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(holders) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var57 string - templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_asset_holders")) + var templ_7745c5c3_Var89 string + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_asset_holders")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 228, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 272, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, holder := range holders { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var58 string - templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + var templ_7745c5c3_Var90 string + templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var59 string - templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) + var templ_7745c5c3_Var91 string + templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rank")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 71} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var60 string - templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + var templ_7745c5c3_Var92 string + templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 104} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 104} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var61 string - templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) + var templ_7745c5c3_Var93 string + templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 231, Col: 138} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 275, Col: 138} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var62 string - templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + var templ_7745c5c3_Var94 string + templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 51} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var63 string - templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 235, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 279, Col: 93} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "#") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "#") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var64 string - templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) + var templ_7745c5c3_Var96 string + templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(holder.Rank, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 236, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 280, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var66 string - templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 237, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 109} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var67 string - templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) + var templ_7745c5c3_Var99 string + templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(holder.OwnerType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 237, Col: 140} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 140} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var68 string - templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(holder.Balance.String()) + var templ_7745c5c3_Var100 string + templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(holder.Balance.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 238, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 282, Col: 60} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1126,243 +1612,243 @@ func HolderContent(data HolderPageData, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var69 := templ.GetChildren(ctx) - if templ_7745c5c3_Var69 == nil { - templ_7745c5c3_Var69 = templ.NopComponent + templ_7745c5c3_Var101 := templ.GetChildren(ctx) + if templ_7745c5c3_Var101 == nil { + templ_7745c5c3_Var101 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var70 string - templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + var templ_7745c5c3_Var102 string + templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 249, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 293, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var71 string - templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) + var templ_7745c5c3_Var103 string + templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "assets")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 249, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 293, Col: 110} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + var templ_7745c5c3_Var104 string + templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 249, Col: 141} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 293, Col: 141} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var73 string - templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_account")) + var templ_7745c5c3_Var105 string + templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_account")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 251, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 295, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var74 string - templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "inspect_holder")) + var templ_7745c5c3_Var106 string + templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "inspect_holder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 252, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 296, Col: 37} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var75 string - templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) + var templ_7745c5c3_Var107 string + templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 253, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 297, Col: 53} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var76 string - templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) + var templ_7745c5c3_Var108 string + templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var77 string - templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + var templ_7745c5c3_Var109 string + templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 256, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if data.Account != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var78 string - templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) + var templ_7745c5c3_Var110 string + templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "holder")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var79 string - templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerID) + var templ_7745c5c3_Var111 string + templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 114} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 114} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var79)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var80 string - templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + var templ_7745c5c3_Var112 string + templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 171} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 171} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, " · ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, " · ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var81 string - templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + var templ_7745c5c3_Var113 string + templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 198} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 198} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var82 string - templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + var templ_7745c5c3_Var114 string + templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 259, Col: 256} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 303, Col: 256} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var83 string - templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "combined_balance")) + var templ_7745c5c3_Var115 string + templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "combined_balance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 260, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 304, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var84 string - templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + var templ_7745c5c3_Var116 string + templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 260, Col: 127} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 304, Col: 127} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var85 string - templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + var templ_7745c5c3_Var117 string + templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 307, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var86 string - templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "available_and_frozen")) + var templ_7745c5c3_Var118 string + templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "available_and_frozen")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 263, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 307, Col: 115} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1370,12 +1856,12 @@ func HolderContent(data HolderPageData, locale Locale) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1399,193 +1885,193 @@ func JournalTable(journals []ledger.Journal, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var87 := templ.GetChildren(ctx) - if templ_7745c5c3_Var87 == nil { - templ_7745c5c3_Var87 = templ.NopComponent + templ_7745c5c3_Var119 := templ.GetChildren(ctx) + if templ_7745c5c3_Var119 == nil { + templ_7745c5c3_Var119 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(journals) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var88 string - templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_journals")) + var templ_7745c5c3_Var120 string + templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "no_journals")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 273, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 317, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, journal := range journals { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 171, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var89 string - templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) + var templ_7745c5c3_Var121 string + templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 46} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var90 string - templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) + var templ_7745c5c3_Var122 string + templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "effect")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 79} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var122)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var91 string - templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) + var templ_7745c5c3_Var123 string + templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "source")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 112} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var123)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var92 string - templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) + var templ_7745c5c3_Var124 string + templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "entries")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 146} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 146} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var124)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var93 string - templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) + var templ_7745c5c3_Var125 string + templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "recorded")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 276, Col: 181} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 320, Col: 181} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 166, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var95 string - templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) + var templ_7745c5c3_Var127 string + templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(transactionName(journal, locale)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 281, Col: 129} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 325, Col: 129} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 167, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var96 string - templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) + var templ_7745c5c3_Var128 string + templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.JoinStringErrs(journal.EffectKind) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 283, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 327, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var128)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 168, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var97 string - templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) + var templ_7745c5c3_Var129 string + templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(journal.SourceService) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 284, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 328, Col: 34} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 169, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var98 string - templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) + var templ_7745c5c3_Var130 string + templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(journal.Entries))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 285, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 329, Col: 47} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 170, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var99 string - templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) + var templ_7745c5c3_Var131 string + templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(formatTime(journal.RecordedAt)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 286, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 330, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 172, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1609,77 +2095,77 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var100 := templ.GetChildren(ctx) - if templ_7745c5c3_Var100 == nil { - templ_7745c5c3_Var100 = templ.NopComponent + templ_7745c5c3_Var132 := templ.GetChildren(ctx) + if templ_7745c5c3_Var132 == nil { + templ_7745c5c3_Var132 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var101 string - templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + var templ_7745c5c3_Var133 string + templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 297, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 341, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var102 string - templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) + var templ_7745c5c3_Var134 string + templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 297, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 341, Col: 98} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var103 string - templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_explorer")) + var templ_7745c5c3_Var135 string + templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 299, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 343, Col: 60} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var104 string - templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "trace_settlement")) + var templ_7745c5c3_Var136 string + templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "trace_settlement")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 300, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 344, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var105 string - templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) + var templ_7745c5c3_Var137 string + templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 301, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 345, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var137)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1687,44 +2173,44 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 181, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var106 string - templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_not_found")) + var templ_7745c5c3_Var138 string + templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_not_found")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 305, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 349, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 182, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var107 string - templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + var templ_7745c5c3_Var139 string + templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 305, Col: 91} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 349, Col: 91} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var107)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 183, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if data.Query != "" && data.Error == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 184, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1734,156 +2220,156 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 185, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if data.Listing != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 186, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var108 string - templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_transactions")) + var templ_7745c5c3_Var140 string + templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "latest_transactions")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 316, Col: 69} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 69} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 187, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var109 string - templ_7745c5c3_Var109, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "page")) + var templ_7745c5c3_Var141 string + templ_7745c5c3_Var141, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "page")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 316, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 102} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var109)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var141)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 188, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var110 string - templ_7745c5c3_Var110, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(data.Listing.Filter.Page)) + var templ_7745c5c3_Var142 string + templ_7745c5c3_Var142, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(data.Listing.Filter.Page)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 316, Col: 145} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 360, Col: 145} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var110)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var142)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var118 string - templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "clear_filters")) + var templ_7745c5c3_Var150 string + templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "clear_filters")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 321, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 365, Col: 79} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 197, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1895,12 +2381,12 @@ func TransactionContent(data TransactionPageData, locale Locale) templ.Component if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1924,146 +2410,146 @@ func Pagination(listing explorer.TransactionListing, locale Locale) templ.Compon }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var119 := templ.GetChildren(ctx) - if templ_7745c5c3_Var119 == nil { - templ_7745c5c3_Var119 = templ.NopComponent + templ_7745c5c3_Var151 := templ.GetChildren(ctx) + if templ_7745c5c3_Var151 == nil { + templ_7745c5c3_Var151 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 214, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2087,390 +2573,390 @@ func JournalCard(journal ledger.Journal, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var128 := templ.GetChildren(ctx) - if templ_7745c5c3_Var128 == nil { - templ_7745c5c3_Var128 = templ.NopComponent + templ_7745c5c3_Var160 := templ.GetChildren(ctx) + if templ_7745c5c3_Var160 == nil { + templ_7745c5c3_Var160 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 173, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 215, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if journal.Blockchain.TransactionHash != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 174, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 216, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var129 string - templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_hash")) + var templ_7745c5c3_Var161 string + templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "transaction_hash")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 351, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 395, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var129)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 175, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 217, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 176, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 218, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var130 string - templ_7745c5c3_Var130, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) + var templ_7745c5c3_Var162 string + templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journal_id")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 353, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 397, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var130)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 177, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 219, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 178, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 220, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var131 string - templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(transactionReference(journal)) + var templ_7745c5c3_Var163 string + templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(transactionReference(journal)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 355, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 399, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 179, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 221, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var132 string - templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed")) + var templ_7745c5c3_Var164 string + templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "committed")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 357, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 401, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 180, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var146 string - templ_7745c5c3_Var146, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balanced_entries")) + var templ_7745c5c3_Var178 string + templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "balanced_entries")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 368, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var146)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 194, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, entry := range journal.Entries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 195, "
#") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, "
#") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var147 string - templ_7745c5c3_Var147, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(entry.LineNumber), 10)) + var templ_7745c5c3_Var179 string + templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatUint(uint64(entry.LineNumber), 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 371, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 415, Col: 83} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var147)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 196, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var149 string - templ_7745c5c3_Var149, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(entry.Account, locale)) + var templ_7745c5c3_Var181 string + templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(entry.Account, locale)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 117} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var149)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 198, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var150 string - templ_7745c5c3_Var150, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + var templ_7745c5c3_Var182 string + templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 151} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 151} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var150)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var182)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 199, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var151 string - templ_7745c5c3_Var151, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(entry.Account.AssetID, 10)) + var templ_7745c5c3_Var183 string + templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(entry.Account.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 200} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 200} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var151)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 200, " · ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, " · ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var152 string - templ_7745c5c3_Var152, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Account.OwnerType) + var templ_7745c5c3_Var184 string + templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Account.OwnerType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 372, Col: 231} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 416, Col: 231} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var152)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 201, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 202, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var153 string - templ_7745c5c3_Var153, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + var templ_7745c5c3_Var185 string + templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 374, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 418, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var153)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 203, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 204, "
+") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "
+") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var154 string - templ_7745c5c3_Var154, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) + var templ_7745c5c3_Var186 string + templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Amount.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 376, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 420, Col: 59} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var154)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 205, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 206, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 207, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2494,429 +2980,429 @@ func AccountContent(data AccountPageData, locale Locale) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var155 := templ.GetChildren(ctx) - if templ_7745c5c3_Var155 == nil { - templ_7745c5c3_Var155 = templ.NopComponent + templ_7745c5c3_Var187 := templ.GetChildren(ctx) + if templ_7745c5c3_Var187 == nil { + templ_7745c5c3_Var187 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 208, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var156 string - templ_7745c5c3_Var156, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) + var templ_7745c5c3_Var188 string + templ_7745c5c3_Var188, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "network")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 386, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 430, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var156)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var188)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 209, " / ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 251, " / ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var157 string - templ_7745c5c3_Var157, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) + var templ_7745c5c3_Var189 string + templ_7745c5c3_Var189, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "accounts")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 386, Col: 94} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 430, Col: 94} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var157)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var189)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 210, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var158 string - templ_7745c5c3_Var158, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_explorer")) + var templ_7745c5c3_Var190 string + templ_7745c5c3_Var190, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_explorer")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 388, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 432, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var158)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var190)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 211, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var159 string - templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rebuild_account")) + var templ_7745c5c3_Var191 string + templ_7745c5c3_Var191, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "rebuild_account")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 389, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 433, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var191)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 212, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var160 string - templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_description")) + var templ_7745c5c3_Var192 string + templ_7745c5c3_Var192, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_description")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 390, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 434, Col: 54} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var192)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 213, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 230, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var174 string - templ_7745c5c3_Var174, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) + var templ_7745c5c3_Var206 string + templ_7745c5c3_Var206, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_unavailable")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 408, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 452, Col: 66} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var174)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var206)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 231, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var175 string - templ_7745c5c3_Var175, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) + var templ_7745c5c3_Var207 string + templ_7745c5c3_Var207, templ_7745c5c3_Err = templ.JoinStringErrs(data.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 408, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 452, Col: 89} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var175)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var207)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 232, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if data.Account != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 233, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var176 string - templ_7745c5c3_Var176, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_identity")) + var templ_7745c5c3_Var208 string + templ_7745c5c3_Var208, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "ledger_identity")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var176)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var208)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 234, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 276, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var177 string - templ_7745c5c3_Var177, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(data.Account.Reference, locale)) + var templ_7745c5c3_Var209 string + templ_7745c5c3_Var209, templ_7745c5c3_Err = templ.JoinStringErrs(accountName(data.Account.Reference, locale)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 123} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 123} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var177)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var209)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 235, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 277, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var178 string - templ_7745c5c3_Var178, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) + var templ_7745c5c3_Var210 string + templ_7745c5c3_Var210, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Reference.OwnerType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 180} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 180} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var178)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var210)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 236, " · ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 278, " · ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var179 string - templ_7745c5c3_Var179, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) + var templ_7745c5c3_Var211 string + templ_7745c5c3_Var211, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "asset")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 207} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 207} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var179)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var211)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 237, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 279, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var180 string - templ_7745c5c3_Var180, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) + var templ_7745c5c3_Var212 string + templ_7745c5c3_Var212, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.FormatInt(data.Account.Reference.AssetID, 10)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 411, Col: 265} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 455, Col: 265} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var180)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var212)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 238, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 280, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var181 string - templ_7745c5c3_Var181, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "current_balance")) + var templ_7745c5c3_Var213 string + templ_7745c5c3_Var213, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "current_balance")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 456, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var181)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var213)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 239, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 281, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var182 string - templ_7745c5c3_Var182, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) + var templ_7745c5c3_Var214 string + templ_7745c5c3_Var214, templ_7745c5c3_Err = templ.JoinStringErrs(data.Account.Balance.String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 412, Col: 126} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 456, Col: 126} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var182)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var214)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 240, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 282, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var183 string - templ_7745c5c3_Var183, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) + var templ_7745c5c3_Var215 string + templ_7745c5c3_Var215, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "account_activity")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 414, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 458, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var183)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var215)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 241, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 283, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var184 string - templ_7745c5c3_Var184, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(data.Account.Journals))) + var templ_7745c5c3_Var216 string + templ_7745c5c3_Var216, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(len(data.Account.Journals))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 414, Col: 120} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 458, Col: 120} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var184)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var216)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 242, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 284, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var185 string - templ_7745c5c3_Var185, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journals")) + var templ_7745c5c3_Var217 string + templ_7745c5c3_Var217, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "journals")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 414, Col: 147} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 458, Col: 147} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var185)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var217)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 243, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 285, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2925,25 +3411,25 @@ func AccountContent(data AccountPageData, locale Locale) templ.Component { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 244, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 286, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var186 string - templ_7745c5c3_Var186, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "choose_account")) + var templ_7745c5c3_Var218 string + templ_7745c5c3_Var218, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "choose_account")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 417, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 461, Col: 58} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var186)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var218)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 245, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 287, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 246, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 288, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -2967,51 +3453,51 @@ func FailureContent(title string, message string, locale Locale) templ.Component }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var187 := templ.GetChildren(ctx) - if templ_7745c5c3_Var187 == nil { - templ_7745c5c3_Var187 = templ.NopComponent + templ_7745c5c3_Var219 := templ.GetChildren(ctx) + if templ_7745c5c3_Var219 == nil { + templ_7745c5c3_Var219 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 247, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 289, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var188 string - templ_7745c5c3_Var188, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explorer_error")) + var templ_7745c5c3_Var220 string + templ_7745c5c3_Var220, templ_7745c5c3_Err = templ.JoinStringErrs(tr(locale, "explorer_error")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 424, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 468, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var188)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var220)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 248, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 290, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var189 string - templ_7745c5c3_Var189, templ_7745c5c3_Err = templ.JoinStringErrs(title) + var templ_7745c5c3_Var221 string + templ_7745c5c3_Var221, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 424, Col: 100} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 468, Col: 100} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var189)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var221)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 249, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 291, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var190 string - templ_7745c5c3_Var190, templ_7745c5c3_Err = templ.JoinStringErrs(message) + var templ_7745c5c3_Var222 string + templ_7745c5c3_Var222, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 424, Col: 132} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `interface/web/templates.templ`, Line: 468, Col: 132} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var190)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var222)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 250, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 292, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }