Compare commits

14 Commits

Author SHA256 Message Date
nfel d33ec51201 feat: dismiss reviewed settlement failures
CI/CD Stage / stage (push) Failing after 37s
2026-10-07 00:32:20 +03:30
nfel 88e227f80c feat(gl): add standalone ledger authority mode
CI/CD Stage / stage (push) Failing after 1m12s
2026-10-06 22:40:07 +03:30
nfel 01d13dbbf2 feat: update settlement workflow
CI/CD Stage / stage (push) Failing after 37s
2026-09-22 16:26:16 +03:30
nfel 4ae5bb615b feat: add Kuknos submission observability
CI/CD Stage / stage (push) Failing after 36s
2026-09-22 16:01:23 +03:30
nfel 837fe5bc5f fix(ci): support SHA-256 repository checkout
CI/CD Stage / stage (push) Failing after 1m20s
2026-09-22 12:41:12 +03:30
nfel 495a5884df Merge feat/refactor-v1 into stage
CI/CD Stage / stage (push) Failing after 31s
2026-09-22 11:45:49 +03:30
nfel 810448b6e7 chore: update project changes 2026-09-21 15:33:47 +03:30
nfel f7366a1985 Add OpenTelemetry tracing and log correlation 2026-09-17 14:56:57 +03:30
nfel 3445ead897 Move Docker deployment files to deployment directory 2026-09-17 13:31:42 +03:30
Navid 12c478cf3a feat: add durable Kuknos settlement coordination 2026-09-15 14:00:55 +03:30
nfel d5c9b338a4 feat(gl): compare external settlement evidence 2026-08-29 16:32:40 +03:30
nfel b14c5e2696 feat(gl): detect duplicate transaction versions 2026-08-29 15:56:20 +03:30
nfel 3f5fd860bf feat(gl): add read-only ledger reconciliation scan 2026-08-29 15:03:17 +03:30
nfel 9b6c438cc7 feat(gl): add structured grpc observability 2026-08-29 12:59:48 +03:30
75 changed files with 6153 additions and 1088 deletions
+39
View File
@@ -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
+1 -1
View File
@@ -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" \
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@v5
uses: https://git.darano.ir/actions/checkout@v6.0.3
with:
token: ${{ gitea.token }}
path: ./
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@v5
uses: https://git.darano.ir/actions/checkout@v6.0.3
with:
token: ${{ gitea.token }}
path: ./
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@v5
uses: https://git.darano.ir/actions/checkout@v6.0.3
with:
token: ${{ gitea.token }}
path: ./
+1
View File
@@ -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
+39
View File
@@ -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
+38 -3
View File
@@ -1,6 +1,6 @@
# General Ledger service design
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28.
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28; standalone GL authority API implemented on 2026-10-05.
## Purpose and ownership
@@ -30,9 +30,10 @@ disabled automatically.
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.
7. In normal Wallet ingestion, 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.
not create monetary entries. For standalone GL-only execution, the GL journal
commit itself is the financial effect; any Wallet copy is a projection.
8. Derived balances are rebuildable solely by ordering and summing immutable
entries. Cached balances are disposable projections, never source data.
@@ -115,6 +116,37 @@ does not hold a wallet database transaction open.
## Operating modes and failure semantics
GL now exposes its own persisted `NORMAL` / `GL_ONLY` operating state through
`GetOperatingMode` and the authenticated `SetOperatingMode` RPC. This is
distinct from Wallet's existing `KUKNOS_DISABLED` configuration, which still
makes Wallet read-only until its callers are integrated with this API.
In `GL_ONLY`, `PostInternalJournal` executes balanced internal effects
synchronously. PostgreSQL locks user accounts and checks available/frozen
balances in the same commit; conflicting spends cannot both succeed. All
journal append paths enforce the GL-only funds rules; ordinary ingestion rejects
new effects in this mode, so internal execution cannot be bypassed. Blockchain
references
and external blockchain account movements are forbidden, new settlement
requests are rejected, and the worker stops claiming existing settlements.
The mode survives restarts and changes have immutable actor/reason/revision
audits and OpenTelemetry spans. Switching drains posting transactions and
rejects in-flight settlement claims or negative user balances. Returning to
`NORMAL` requires explicit operator reconciliation confirmation.
The ledger must be complete before switching: restore/import opening balances
and drain committed Wallet outbox effects and in-flight Wallet processing.
GL-only mode keeps ledger reads/writes available while reporting settlement as
paused; existing normal-mode Wallet clients therefore fail closed rather than
continuing blockchain-dependent processing. Internal executions must not also
be delivered through that outbox as new monetary effects. User authorization,
asset eligibility, fees, and business counterparty mapping remain admission
responsibilities. Actual external payment settlement cannot occur internally.
See [the authority API](deployment/AUTHORITY_API.md) and
[standalone operation](deployment/STANDALONE.md).
The broader Wallet availability policy remains:
- `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
@@ -145,6 +177,9 @@ The first `ledger.v1.GeneralLedgerService` contract provides:
- `AppendTransactionEvent` for non-monetary lifecycle history;
- `GetJournal` and `ListEntries` for audit and replay inspection;
- `GetBalance` for a rebuildable account/asset projection;
- `GetUserBalances` for available, frozen, and total balances per user asset;
- `GetOperatingMode` / `SetOperatingMode` for attributable operator cutover;
- `PostInternalJournal` for atomic internal execution in `GL_ONLY`;
- `Health` for readiness and database health.
Wallet depends on its own `Ledger` port. Its gRPC implementation translates
+20
View File
@@ -6,6 +6,11 @@ blockchain or provider is unavailable.
The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md).
For an independent PostgreSQL + ledger API + dashboard deployment with no
blockchain network access, see [Standalone General Ledger](deployment/STANDALONE.md).
The [authority API](deployment/AUTHORITY_API.md) provides an authenticated,
persisted GL-only toggle, internal journal execution, and per-user balances.
Generate protobufs, test, and build with:
```bash
@@ -39,6 +44,21 @@ theme toggle that initially follows the operating-system preference.
The gRPC service owns schema migrations. The dashboard is read-only, uses its
own `dashboard.cfg.toml`, and does not run migrations.
AdminPanel settlement review supports `IgnoreSettlement` through the authenticated
GL admin connection. Operators with `ledgerops.retry_kuknossettlement` can review an
unlocked `MANUAL_REVIEW` or `RETRYABLE` settlement and record a reason to close it.
GL atomically sets `IGNORED`, records the actor/reason/status transition in
`kuknos_settlement_operator_audits`, and resolves linked open reconciliation issues.
The original error and signed payload remain available as evidence. Ignored
settlements are terminal, cannot be retried, and leave the attention queue.
Dismissal does not confirm a payment or reverse funds; IRT withdrawal reservations
still require financial reconciliation.
Deploy the updated GL service first so its startup applies migration
`000007_ignore_settlement.up.sql` and exposes the RPC, then deploy Wallet and
AdminPanel. An older GL service rejects the command; AdminPanel preserves the
review form and shows the error rather than reporting a successful dismissal.
Run the dashboard load test against a started dashboard with:
```bash
+34 -7
View File
@@ -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) {
+27 -8
View File
@@ -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")
+40 -6
View File
@@ -1,23 +1,45 @@
// 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 SettlementAuthority interface {
SettlementAllowed(context.Context) (bool, 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 +48,17 @@ 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
if authority, ok := s.settlement.(SettlementAuthority); ok {
allowed, authorityErr := authority.SettlementAllowed(ctx)
status.SettlementReady = authorityErr == nil && allowed
}
}
}
return status
}
+45
View File
@@ -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,37 @@ 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)
}
}
type pausedSettlementStub struct{ settlementStub }
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
got := NewService(databaseStub{}, monitor).Check(context.Background())
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
t.Fatalf("unexpected GL-only readiness: %+v", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ledger
import (
"context"
"strings"
domain "gl/domain/ledger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
type AuthorityRepository interface {
OperatingMode(context.Context) (domain.OperatingState, error)
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
}
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
return repository.OperatingMode(ctx)
}
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
defer span.End()
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "mode change failed")
return domain.OperatingState{}, err
}
if state.Revision != revision {
span.AddEvent("ledger.operating_mode_changed")
}
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
return state, nil
}
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return nil, domain.ErrNotFound
}
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
}
// PostInternalJournal executes exact balanced entries without network settlement.
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if strings.TrimSpace(command.ActorID) == "" {
return AppendJournalResult{}, invalid("actor id is required for internal execution")
}
if command.Blockchain != (domain.BlockchainReference{}) {
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
}
for _, entry := range command.Entries {
if entry.Account.Class == domain.AccountClassExternalBlockchain {
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
}
}
command.Metadata = cloneMetadata(command.Metadata)
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
return s.appendJournal(ctx, command)
}
+65
View File
@@ -0,0 +1,65 @@
package ledger
import (
"context"
"errors"
domain "gl/domain/ledger"
"testing"
)
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
for _, tc := range []struct {
name string
change func(*AppendJournalCommand)
}{
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
} {
t.Run(tc.name, func(t *testing.T) {
repository := newRepositoryStub()
command := validAppendCommand()
command.ActorID = "operator"
tc.change(&command)
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
t.Fatalf("invalid internal execution reached repository: %v", err)
}
})
}
}
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("test"))
command := validAppendCommand()
command.ActorID = "operator"
command.Metadata = map[string]string{"correlation": "value"}
result, err := service.PostInternalJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
t.Fatal("internal provenance missing")
}
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
t.Fatal("caller metadata mutated")
}
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
_, err = service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("ordinary API accepted internal marker: %v", err)
}
}
func TestAuthorityValidatesInputs(t *testing.T) {
service := NewService(newRepositoryStub(), nil)
for _, owner := range []string{"", " "} {
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid owner: %v", err)
}
}
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid mode: %v", err)
}
}
+7
View File
@@ -71,6 +71,13 @@ type AppendJournalResult struct {
}
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
}
return s.appendJournal(ctx, command)
}
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)
+85
View File
@@ -0,0 +1,85 @@
package reconciliation
import (
"context"
"fmt"
"gl/domain/ledger"
)
// Evidence is a normalized record supplied by Wallet, Kuknos, or another
// authoritative settlement source. Adapters remain outside the GL domain.
type Evidence struct {
SourceService string
SourceTransactionID string
EventVersion uint32
BlockchainNetwork string
TransactionHash string
}
type EvidenceReport struct {
Compared int
Missing []Evidence
Duplicates []Evidence
Mismatched []EvidenceMismatch
}
type EvidenceMismatch struct {
Evidence Evidence
JournalID string
Field string
Expected string
Actual string
}
// CompareEvidence compares normalized external evidence with immutable GL
// journals. It is read-only and safe to run repeatedly after outages.
func CompareEvidence(ctx context.Context, repository Repository, evidence []Evidence, pageSize int) (EvidenceReport, error) {
if repository == nil {
return EvidenceReport{}, fmt.Errorf("reconciliation repository is required")
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 100
}
journals := make(map[string]ledger.Journal)
for offset := 0; ; offset += pageSize {
page, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
if err != nil {
return EvidenceReport{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
}
for _, journal := range page {
journals[evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)] = journal
}
if len(page) < pageSize {
break
}
}
report := EvidenceReport{Missing: make([]Evidence, 0), Duplicates: make([]Evidence, 0), Mismatched: make([]EvidenceMismatch, 0)}
seen := make(map[string]struct{}, len(evidence))
for _, item := range evidence {
report.Compared++
key := evidenceKey(item.SourceService, item.SourceTransactionID, item.EventVersion)
if _, exists := seen[key]; exists {
report.Duplicates = append(report.Duplicates, item)
continue
}
seen[key] = struct{}{}
journal, exists := journals[key]
if !exists {
report.Missing = append(report.Missing, item)
continue
}
if item.BlockchainNetwork != "" && item.BlockchainNetwork != journal.Blockchain.Network {
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "blockchain_network", Expected: item.BlockchainNetwork, Actual: journal.Blockchain.Network})
}
if item.TransactionHash != "" && item.TransactionHash != journal.Blockchain.TransactionHash {
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "transaction_hash", Expected: item.TransactionHash, Actual: journal.Blockchain.TransactionHash})
}
}
return report, nil
}
func evidenceKey(sourceService, sourceTransactionID string, eventVersion uint32) string {
return sourceService + "\x00" + sourceTransactionID + "\x00" + fmt.Sprint(eventVersion)
}
@@ -0,0 +1,39 @@
package reconciliation
import (
"context"
"testing"
"gl/domain/ledger"
)
func TestCompareEvidenceReportsMissingDuplicateAndBlockchainMismatch(t *testing.T) {
journal := validJournal()
journal.Blockchain = ledger.BlockchainReference{Network: "kuknos", TransactionHash: "hash-1"}
matching := Evidence{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "hash-1"}
evidence := []Evidence{
matching,
matching,
{SourceService: "wallet", SourceTransactionID: "missing", EventVersion: 1},
{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "wrong"},
}
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, evidence, 10)
if err != nil {
t.Fatal(err)
}
if report.Compared != 4 || len(report.Missing) != 1 || len(report.Duplicates) != 2 || len(report.Mismatched) != 0 {
t.Fatalf("unexpected report: %+v", report)
}
}
func TestCompareEvidenceReportsHashMismatch(t *testing.T) {
journal := validJournal()
journal.Blockchain.TransactionHash = "actual"
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, []Evidence{{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, TransactionHash: "expected"}}, 10)
if err != nil {
t.Fatal(err)
}
if len(report.Mismatched) != 1 || report.Mismatched[0].Field != "transaction_hash" {
t.Fatalf("unexpected report: %+v", report)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package reconciliation provides read-only integrity checks for the GL.
package reconciliation
import (
"context"
"fmt"
"gl/domain/ledger"
)
type Repository interface {
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
}
type Report struct {
Scanned int
Valid int
Invalid []Issue
}
type Issue struct {
JournalID string
Error string
}
// Run scans all posted journals without mutating the ledger.
func Run(ctx context.Context, repository Repository, pageSize int) (Report, error) {
if repository == nil {
return Report{}, fmt.Errorf("reconciliation repository is required")
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 100
}
report := Report{Invalid: make([]Issue, 0)}
seen := make(map[string]string)
for offset := 0; ; offset += pageSize {
journals, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
if err != nil {
return Report{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
}
for _, journal := range journals {
report.Scanned++
if err := journal.Validate(); err != nil {
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()})
continue
}
key := evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)
if previous, exists := seen[key]; exists {
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)})
continue
}
seen[key] = journal.ID
report.Valid++
}
if len(journals) < pageSize {
break
}
}
return report, nil
}
@@ -0,0 +1,56 @@
package reconciliation
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
type repositoryStub struct {
pages [][]ledger.Journal
}
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
index := filter.Offset / filter.Limit
if index >= len(r.pages) {
return nil, nil
}
return r.pages[index], nil
}
func validJournal() ledger.Journal {
return ledger.Journal{ID: "j1", SourceService: "wallet", IdempotencyKey: "k1", SourceTransactionID: "t1", EffectKind: "transfer", EventVersion: 1, OccurredAt: ledgerTestTime(), PayloadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Entries: []ledger.Entry{{LineNumber: 1, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("-1")}, {LineNumber: 2, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("1")}}}
}
func ledgerTestTime() (t time.Time) { return time.Unix(1, 0).UTC() }
func mustAmount(value string) ledger.Amount { amount, _ := ledger.ParseAmount(value); return amount }
func TestRunScansAndReportsInvalidJournals(t *testing.T) {
valid := validJournal()
invalid := valid
invalid.Entries = append([]ledger.Entry(nil), valid.Entries...)
invalid.ID = "bad"
invalid.Entries[1].Amount = mustAmount("2")
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{valid}, {invalid}}}, 1)
if err != nil {
t.Fatal(err)
}
if report.Scanned != 2 || report.Valid != 1 || len(report.Invalid) != 1 || report.Invalid[0].JournalID != "bad" {
t.Fatalf("unexpected report: %+v", report)
}
}
func TestRunReportsDuplicateTransactionVersions(t *testing.T) {
first := validJournal()
second := validJournal()
second.ID = "j2"
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{first, second}}}, 10)
if err != nil {
t.Fatal(err)
}
if report.Valid != 1 || len(report.Invalid) != 1 {
t.Fatalf("unexpected report: %+v", report)
}
}
+72
View File
@@ -0,0 +1,72 @@
package settlement
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"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)
IgnoreByOperator(context.Context, string, string, string) (ledger.Settlement, error)
}
func (s *Service) IgnoreByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
settlementID, actorID, reason = strings.TrimSpace(settlementID), strings.TrimSpace(actorID), strings.TrimSpace(reason)
if settlementID == "" || actorID == "" || reason == "" || len([]rune(reason)) > 500 {
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and a reason of at most 500 characters are required")
}
return s.repository.IgnoreByOperator(ctx, settlementID, actorID, reason)
}
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)
}
+65
View File
@@ -0,0 +1,65 @@
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 (r *serviceRepository) IgnoreByOperator(_ context.Context, id, actor, reason string) (ledger.Settlement, error) {
r.retriedID, r.actor, r.reason = id, actor, reason
return ledger.Settlement{ID: id, Status: ledger.SettlementIgnored}, nil
}
func TestIgnoreByOperatorRequiresReviewedAuditIdentity(t *testing.T) {
repository := &serviceRepository{}
service := NewService(repository)
for _, reason := range []string{"", " "} {
if _, err := service.IgnoreByOperator(context.Background(), "id", "actor", reason); err == nil {
t.Fatal("expected blank review reason to be rejected")
}
}
if repository.retriedID != "" {
t.Fatal("invalid dismissal reached repository")
}
got, err := service.IgnoreByOperator(context.Background(), "id", "actor-7", " cannot complete ")
if err != nil || got.Status != ledger.SettlementIgnored || repository.actor != "actor-7" || repository.reason != "cannot complete" {
t.Fatalf("unexpected dismissal: %+v repo=%+v err=%v", got, repository, err)
}
}
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)
}
}
+64
View File
@@ -0,0 +1,64 @@
package settlement
import (
"context"
"errors"
"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 {
var permanent ledger.PermanentSubmissionError
if errors.As(submitErr, &permanent) {
record.Status = ledger.SettlementManualReview
record.LastError = submitErr.Error()
} else {
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
}
+60
View File
@@ -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)
}
}
+14 -2
View File
@@ -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
}
+4
View File
@@ -10,11 +10,13 @@ import (
"gl/application/explorer"
"gl/infrastructure/config"
"gl/infrastructure/observability"
"gl/infrastructure/postgres"
webadapter "gl/interface/web"
)
func main() {
observability.Configure("gl-dashboard", slog.LevelInfo)
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
flag.Parse()
@@ -23,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()
+67 -1
View File
@@ -7,15 +7,20 @@ 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"
)
func main() {
observability.Configure("gl", slog.LevelInfo)
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
flag.Parse()
@@ -24,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()
@@ -40,7 +47,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,
@@ -52,3 +73,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)
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"context"
"flag"
"net"
"os"
"strconv"
"time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"gl/infrastructure/config"
"gl/infrastructure/postgres"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file")
grpcProbe := flag.Bool("grpc", false, "check the serving GL API instead of only its database")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cfg, err := config.Load(*configPath)
if err != nil {
os.Exit(1)
}
if *grpcProbe {
host := cfg.GRPC.Host
if host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
connection, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(cfg.GRPC.Port)), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
os.Exit(1)
}
defer connection.Close()
health, err := ledgerv1.NewGeneralLedgerServiceClient(connection).Health(ctx, &basev1.Empty{})
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
os.Exit(1)
}
return
}
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)
}
}
+3 -3
View File
@@ -2,14 +2,14 @@ environment = "local"
[http]
host = "0.0.0.0"
port = 8080
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"
+145
View File
@@ -0,0 +1,145 @@
# GL authority API
The `ledger.v1.GeneralLedgerService` gRPC service exposes four additional RPCs.
The contract is in `../proto/ledger/v1/{msg,srv}.proto` from the GL directory;
the generated Go client is in `gen/ledger/v1`.
## Operator toggle
In the existing AdminPanel, open **GL journals → GL operating mode**.
The checkbox, required reason, and recovery confirmation call GL directly.
The operator's ID comes from the authenticated session. Permission
`ledgerops.set_operating_mode` is required; superusers already have it.
Operators also need `ledgerops.view_generalledgerjournal` for journal navigation.
Register the permission using AdminPanel's usual Django `migrate` command
and grant it only to designated operators. Configure the existing
`GL_ADMIN_GRPC_ADDRESS` and `GL_ADMIN_GRPC_TOKEN` settings, with the token
matching GL's `DARANO_GL_ADMIN_TOKEN`. These are server-side settings.
The AdminPanel control changes GL's authority state. Existing Wallet flows
still need to call the internal posting API; changing Wallet's old
`kuknos-enabled` configuration alone continues to select read-only behavior.
## Future Wallet routing contract
Wallet remains the entry point in both modes. The public Wallet RPCs, request
and response schemas, authentication, and client UI stay unchanged; clients
must not call GL directly or choose the execution mode.
| GL mode | Transaction execution | User balance response |
| --- | --- | --- |
| `NORMAL` (default) | Existing Wallet admission → GL settlement queue → Stellar/Kuknos | Existing Wallet behavior |
| `GL_ONLY` | Wallet admission → GL `PostInternalJournal`, with no blockchain call | Wallet returns GL `GetUserBalances` values in its existing response format |
This routing is reserved for future activation. The GL backend and AdminPanel
control exist, but the Wallet routing above is not implemented yet. Keep
`NORMAL` selected for the current Wallet deployment. Enabling the control now
pauses settlement and causes existing Wallet financial writes to fail closed;
it does not automatically turn those endpoints into internal execution.
The future implementation must retain Wallet's authorization and business
validation, persist the execution choice and exact command for recovery, and
use the same transaction identity for retries. A GL commit establishes
financial success; subsequent Wallet history updates are recoverable
projections and must not produce a second financial journal through the normal
outbox. Reconciliation must preserve the original execution choice across
mode changes rather than reinterpret an older blockchain transaction as an
internal transfer. Cutover requires draining normal-mode work and checking
opening balances. Returning to normal requires reconciliation before external
settlement resumes.
Internal transfers, freezes, releases, trades, and their fees can execute
inside GL. An actual external blockchain payment cannot be reported as
externally settled while Stellar/Kuknos is unavailable.
`GetOperatingMode(base.v1.Empty)` returns `mode`, `revision`, `actor_id`,
`reason`, and `changed_at`.
`SetOperatingMode` requires exactly one `x-admin-token` metadata value matching
`DARANO_GL_ADMIN_TOKEN`. To enable internal authority, send:
```json
{
"mode": "GL_ONLY",
"expectedRevision": "0",
"actorId": "operator-17",
"reason": "Kuknos outage; opening balances and outbox checked"
}
```
Use the revision from the latest read, rather than always sending zero.
Mode persists across service restarts. A concurrent toggle returns `ABORTED`;
read the state before deciding whether another change is needed. A toggle
with in-flight settlements or negative user balances returns
`FAILED_PRECONDITION`. Returning to `NORMAL` requires
`reconciliationConfirmed: true` after operator reconciliation.
## Internal execution
`PostInternalJournal` accepts the existing `AppendJournalRequest` and requires
the administrative token, an actor ID, and `GL_ONLY` mode for new postings.
For a transfer, debit the sender's `USER_AVAILABLE` account and credit the
recipient's account by the same exact decimal amount. For a freeze, debit
available and credit frozen; for a release, reverse those legs. Multi-asset
trade and fee legs must balance independently per asset.
The API accepts exact balanced entries so it can represent all internal
business effects without depending on Stellar XDR, account sequences, or a
Horizon endpoint. It does not determine business fees, asset eligibility, or
whether the caller's user is allowed to move those funds; trusted application
admission must supply and authorize those legs. It forbids blockchain
references and `EXTERNAL_BLOCKCHAIN` accounts. The ordinary journal API
remains the normal-mode ingestion interface and rejects new effects in
`GL_ONLY`, while allowing identical retries of previously ingested journals.
Success means the journal and every entry committed atomically. Insufficient
available or frozen funds returns `FAILED_PRECONDITION`. Retry uncertain
outcomes with the exact same idempotency key and payload. Identical retries
return `already_existed=true`; conflicting payloads return `ALREADY_EXISTS`.
A concurrency abort returns `ABORTED`; retry that same request rather than
allocating a new transaction identity. Database errors never mean success.
The server reserves journal metadata key `gl_execution` and stores
`GL_ONLY` as immutable execution provenance. Callers do not set it.
## User balances
`GetUserBalances` reads GL entries without any blockchain call:
```json
{"ownerType": "user", "ownerId": "17", "assetId": "7"}
```
Example response:
```json
{
"ownerType": "user",
"ownerId": "17",
"balances": [
{"assetId": "7", "available": "34", "frozen": "6", "total": "40"}
]
}
```
Omit `assetId` to return all journaled assets for that owner. An unknown owner
has an empty list; a requested asset with no entries returns zero amounts.
Amounts are decimal strings. Available and frozen are read in one database
snapshot. The endpoint is available in either operating mode.
The API is an internal service: expose it through an authenticated application
gateway that binds the requested owner to the logged-in user. Do not expose
arbitrary owner lookups or the operator token directly to a browser.
## Verification
The opt-in PostgreSQL test creates and removes its own isolated schema:
```bash
GL_TEST_DATABASE_URL='<disposable PostgreSQL connection URL>' \
go test -race ./infrastructure/postgres -run TestAuthorityWithPostgres -v
```
It exercises mode authentication, revision conflicts, immutable audit records,
settlement suspension, concurrent overspending prevention, idempotent replay,
freezes/releases, internal RPC posting, and user balance reconstruction.
+7 -2
View File
@@ -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"]
+133
View File
@@ -0,0 +1,133 @@
# Standalone General Ledger
GL's journal API and explorer require only GL's own PostgreSQL database.
Stellar, Kuknos, Wallet, and an OpenTelemetry collector are not startup or
posting dependencies. Blockchain references on journals are optional.
## Run independently
From the GL directory, set two runtime secrets and launch:
```bash
export DARANO_GL_DB_PASSWORD='<database password>'
export DARANO_GL_ADMIN_TOKEN='<administrative retry token>'
docker compose -f deployment/compose.standalone.yaml up --build -d --wait
```
The gRPC API is on `127.0.0.1:8600`; the read-only dashboard is on
`http://127.0.0.1:8601`. Images must be built/downloaded before an outage.
The runtime network is internal and has no external network access.
Settlement submission and telemetry export are explicitly disabled. The
settlement queue remains persisted and queryable; disabling its worker does
not confirm queued blockchain transactions.
The named `ledger-data` volume retains journals across container restarts and
ordinary `docker compose down`. Do not use `down -v` on a ledger containing
financial records.
This launch creates a **new, empty ledger**. Before treating it as the source
of truth, restore the existing authoritative GL database, or import approved,
balanced opening journals and replay all subsequent committed movements.
Running a second empty GL does not provide failover for the first.
## What remains available during a blockchain outage
- Atomic, balanced, append-only journal posting with idempotent retries.
- Journal lookup, history, lifecycle events, and balances rebuilt from entries.
- Read-only exploration of the same database.
- Durable storage of settlement requests already carrying signed XDR.
GL readiness reflects its database. Settlement backlog or submission failures
do not prevent ledger reads or writes. Database loss or failed journal commits
must prevent a caller from declaring an unrecorded financial effect successful.
## Financial authority versus settlement
The committed journal is the source of book balances. A blockchain hash is
settlement evidence, not the journal identity. Do not modify a journal to add
later confirmation; retain it as separate settlement/lifecycle evidence.
The persisted `GL_ONLY` operating mode makes GL the execution authority.
`PostInternalJournal` commits balanced internal effects synchronously, checks
that affected available/frozen accounts do not become negative, and serializes
concurrent spending inside PostgreSQL. An identical retry returns the original
journal even after the mode changes. In `GL_ONLY`, new effects must use the
authenticated internal posting API; ordinary ingestion rejects new journals
while still accepting identical retries. PostgreSQL enforces the funds checks
and rejects external blockchain movements at sealing. Business admission still
owns user authorization, asset eligibility, fees, and exact counterparty legs.
GL starts in `NORMAL`; mode is stored in its database and survives restarts.
Disabling the settlement worker in TOML is separate from choosing `GL_ONLY`.
Read the current revision, then use the authenticated `SetOperatingMode` API
to change mode. Each change retains actor, reason, revision, and time in an
immutable audit table. Stale revisions fail. Enabling `GL_ONLY` requires no
in-flight settlement claims and no negative user account balances. Drain the
Wallet outbox, pause and drain in-flight Wallet processing, and confirm the
ledger includes every committed effect before switching.
The existing AdminPanel controls GL mode through its GL journals →
GL operating mode page. Configure `GL_ADMIN_GRPC_ADDRESS` and
`GL_ADMIN_GRPC_TOKEN` (matching GL's `DARANO_GL_ADMIN_TOKEN`), and grant
`ledgerops.set_operating_mode` to designated operators. AdminPanel never
updates GL tables directly; mode changes go through GL's API.
Wallet remains the public entry point in both modes: the future GL-only route
must preserve the existing Wallet endpoints, response formats, and UI. In
normal operation, admitted requests continue through GL settlement to
Stellar/Kuknos. With GL-only routing implemented and enabled, Wallet will
instead submit internal effects directly to GL and serve GL balances through
those same Wallet endpoints. See [the future Wallet routing contract](AUTHORITY_API.md#future-wallet-routing-contract).
That Wallet routing is not implemented yet; keep `NORMAL` selected for the
current deployment. The existing Wallet configuration toggle still selects read-only behavior.
Wallet consumers must route internal execution to `PostInternalJournal`;
the AdminPanel change does not replace Wallet's blockchain-dependent workflows.
GL keeps its own readiness healthy but reports settlement as paused, so existing
normal-mode Wallet clients fail closed until their internal routes are integrated.
A committed GL journal is the financial
success result; Wallet state becomes an idempotent projection of it. Do not
also send the same effect through the normal Wallet outbox under a different
key: that would duplicate the movement. Existing normal-mode committed Wallet
effects continue using their durable outbox.
Internal transfers, freezes, releases, trades, and clearing movements are
represented by balanced entries with no blockchain reference. An operation
that actually sends money outside the internal books cannot become externally
settled during an outage. External blockchain account entries and settlement
submission are rejected in `GL_ONLY`; store any future external intent
separately and keep its settlement pending.
After blockchain recovery, reconcile journals against external evidence before
resuming normal settlement. Returning to `NORMAL` requires an operator to set
`reconciliation_confirmed=true`; this is an explicit assertion, not an automated
proof. While `GL_ONLY` is enabled, the worker leaves the existing settlement
queue untouched. Mode changes reject in-flight claims so a toggle cannot race
an already claimed submission. The existing queue requires signed XDR; it does not
turn arbitrary journals into blockchain transactions. Sequence numbers,
transaction expiry, and exhausted retries may require operator review and
reconstruction. Never blindly resubmit all journals or mark them settled.
## Verify and preserve the ledger
The existing write/read load test can exercise a **disposable** standalone
database without any blockchain endpoint:
```bash
go run ./cmd/gl-loadtest -duration 5s -concurrency 1 -wallets 10
```
This writes synthetic journals and must not run against an authoritative
financial database. Restart GL and confirm the same journal IDs and balances
remain queryable.
Keep PostgreSQL backups and WAL recovery independent of the blockchain
services, and periodically verify a restore. For example, create a logical
backup without deleting or changing ledger records:
```bash
docker compose -f deployment/compose.standalone.yaml exec -T postgres \
pg_dump -U gl -d gl_db -Fc > gl.dump
```
A named volume provides persistence on one host, not disaster recovery.
+65
View File
@@ -0,0 +1,65 @@
name: darano-gl-standalone
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: gl_db
POSTGRES_USER: gl
POSTGRES_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
volumes:
- ledger-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gl -d gl_db"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks: [ledger]
gl:
image: darano-gl:standalone
build:
context: ..
dockerfile: deployment/Dockerfile
args:
GO_PROXY: ${GO_PROXY:-https://proxy.golang.org}
command: ["-conf", "/app/standalone.toml"]
environment:
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
DARANO_GL_ADMIN_TOKEN: ${DARANO_GL_ADMIN_TOKEN:?Set DARANO_GL_ADMIN_TOKEN}
volumes:
- ./gl.standalone.toml:/app/standalone.toml:ro
ports: ["127.0.0.1:8600:8600"]
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/healthcheck", "-conf", "/app/standalone.toml", "-grpc"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
networks: [ledger]
dashboard:
image: darano-gl:standalone
entrypoint: ["/app/dashboard"]
command: ["-conf", "/app/standalone.toml"]
environment:
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
volumes:
- ./dashboard.standalone.toml:/app/standalone.toml:ro
ports: ["127.0.0.1:8601:8601"]
depends_on:
gl:
condition: service_healthy
restart: unless-stopped
networks: [ledger]
volumes:
ledger-data:
networks:
ledger:
internal: true
+16
View File
@@ -0,0 +1,16 @@
environment = "production"
[http]
host = "0.0.0.0"
port = 8601
read-header-timeout = "5s"
shutdown-timeout = "10s"
[db]
host = "postgres"
port = 5432
name = "gl_db"
user = "gl"
[telemetry]
enabled = false
+18
View File
@@ -0,0 +1,18 @@
environment = "production"
[grpc]
host = "0.0.0.0"
port = 8600
timeout = "10s"
[db]
host = "postgres"
port = 5432
name = "gl_db"
user = "gl"
[settlement]
enabled = false
[telemetry]
enabled = false
+36
View File
@@ -0,0 +1,36 @@
package ledger
import (
"errors"
"time"
)
type OperatingMode string
const (
ModeNormal OperatingMode = "NORMAL"
ModeGLOnly OperatingMode = "GL_ONLY"
InternalExecutionMetadata = "gl_execution"
)
var (
ErrModeConflict = errors.New("operating mode revision changed")
ErrModeRequired = errors.New("operation is not permitted in the current ledger mode")
ErrInsufficientFunds = errors.New("insufficient ledger funds")
ErrSettlementInFlight = errors.New("settlement is in flight; drain the worker before switching")
ErrConcurrentPosting = errors.New("concurrent posting conflicted; retry with the same idempotency key")
)
type OperatingState struct {
Mode OperatingMode
Revision int64
ActorID string
Reason string
ChangedAt time.Time
}
type UserAssetBalance struct {
AssetID int64
Available Amount
Frozen Amount
}
+73
View File
@@ -0,0 +1,73 @@
package ledger
import (
"fmt"
"time"
)
type PermanentSubmissionError struct{ Err error }
func (e PermanentSubmissionError) Error() string { return e.Err.Error() }
func (e PermanentSubmissionError) Unwrap() error { return e.Err }
type SettlementStatus string
const (
SettlementPending SettlementStatus = "PENDING"
SettlementSubmitted SettlementStatus = "SUBMITTED"
SettlementConfirmed SettlementStatus = "CONFIRMED"
SettlementRetryable SettlementStatus = "RETRYABLE"
SettlementManualReview SettlementStatus = "MANUAL_REVIEW"
SettlementIgnored SettlementStatus = "IGNORED"
)
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 && s.Status != SettlementIgnored {
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
}
+1 -1
View File
@@ -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
+1150 -126
View File
File diff suppressed because it is too large Load Diff
+69 -33
View File
@@ -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,8 +25,12 @@ 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" +
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xf2\t\n" +
"\x14GeneralLedgerService\x12D\n" +
"\x10GetOperatingMode\x12\x0e.base.v1.Empty\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
"\x10SetOperatingMode\x12\".ledger.v1.SetOperatingModeRequest\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
"\x13PostInternalJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12X\n" +
"\x0fGetUserBalances\x12!.ledger.v1.GetUserBalancesRequest\x1a\".ledger.v1.GetUserBalancesResponse\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" +
@@ -35,43 +39,75 @@ 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.RetrySettlementResponse\x12[\n" +
"\x10IgnoreSettlement\x12\".ledger.v1.IgnoreSettlementRequest\x1a#.ledger.v1.IgnoreSettlementResponseBy\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
(*SetOperatingModeRequest)(nil), // 1: ledger.v1.SetOperatingModeRequest
(*AppendJournalRequest)(nil), // 2: ledger.v1.AppendJournalRequest
(*GetUserBalancesRequest)(nil), // 3: ledger.v1.GetUserBalancesRequest
(*AppendTransactionEventRequest)(nil), // 4: ledger.v1.AppendTransactionEventRequest
(*GetJournalRequest)(nil), // 5: ledger.v1.GetJournalRequest
(*ListEntriesRequest)(nil), // 6: ledger.v1.ListEntriesRequest
(*GetBalanceRequest)(nil), // 7: ledger.v1.GetBalanceRequest
(*ReplayJournalsRequest)(nil), // 8: ledger.v1.ReplayJournalsRequest
(*EnqueueSettlementRequest)(nil), // 9: ledger.v1.EnqueueSettlementRequest
(*GetSettlementRequest)(nil), // 10: ledger.v1.GetSettlementRequest
(*RetrySettlementRequest)(nil), // 11: ledger.v1.RetrySettlementRequest
(*IgnoreSettlementRequest)(nil), // 12: ledger.v1.IgnoreSettlementRequest
(*OperatingModeResponse)(nil), // 13: ledger.v1.OperatingModeResponse
(*AppendJournalResponse)(nil), // 14: ledger.v1.AppendJournalResponse
(*GetUserBalancesResponse)(nil), // 15: ledger.v1.GetUserBalancesResponse
(*HealthResponse)(nil), // 16: ledger.v1.HealthResponse
(*AppendTransactionEventResponse)(nil), // 17: ledger.v1.AppendTransactionEventResponse
(*Journal)(nil), // 18: ledger.v1.Journal
(*ListEntriesResponse)(nil), // 19: ledger.v1.ListEntriesResponse
(*GetBalanceResponse)(nil), // 20: ledger.v1.GetBalanceResponse
(*ReplayJournalsResponse)(nil), // 21: ledger.v1.ReplayJournalsResponse
(*EnqueueSettlementResponse)(nil), // 22: ledger.v1.EnqueueSettlementResponse
(*Settlement)(nil), // 23: ledger.v1.Settlement
(*RetrySettlementResponse)(nil), // 24: ledger.v1.RetrySettlementResponse
(*IgnoreSettlementResponse)(nil), // 25: ledger.v1.IgnoreSettlementResponse
}
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: ledger.v1.GeneralLedgerService.GetOperatingMode:input_type -> base.v1.Empty
1, // 1: ledger.v1.GeneralLedgerService.SetOperatingMode:input_type -> ledger.v1.SetOperatingModeRequest
2, // 2: ledger.v1.GeneralLedgerService.PostInternalJournal:input_type -> ledger.v1.AppendJournalRequest
3, // 3: ledger.v1.GeneralLedgerService.GetUserBalances:input_type -> ledger.v1.GetUserBalancesRequest
0, // 4: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
2, // 5: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
4, // 6: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
5, // 7: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
6, // 8: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
7, // 9: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
8, // 10: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
9, // 11: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
10, // 12: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
11, // 13: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
12, // 14: ledger.v1.GeneralLedgerService.IgnoreSettlement:input_type -> ledger.v1.IgnoreSettlementRequest
13, // 15: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
13, // 16: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
14, // 17: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse
15, // 18: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse
16, // 19: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
14, // 20: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
17, // 21: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
18, // 22: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
19, // 23: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
20, // 24: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
21, // 25: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
22, // 26: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse
23, // 27: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement
24, // 28: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse
25, // 29: ledger.v1.GeneralLedgerService.IgnoreSettlement:output_type -> ledger.v1.IgnoreSettlementResponse
15, // [15:30] is the sub-list for method output_type
0, // [0:15] 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
+323 -9
View File
@@ -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
@@ -20,6 +20,10 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
GeneralLedgerService_GetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/GetOperatingMode"
GeneralLedgerService_SetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/SetOperatingMode"
GeneralLedgerService_PostInternalJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/PostInternalJournal"
GeneralLedgerService_GetUserBalances_FullMethodName = "/ledger.v1.GeneralLedgerService/GetUserBalances"
GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health"
GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal"
GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent"
@@ -27,12 +31,22 @@ 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"
GeneralLedgerService_IgnoreSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/IgnoreSettlement"
)
// 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 {
GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error)
// Operator-only; requires x-admin-token and an optimistic revision.
SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error)
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error)
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)
@@ -40,6 +54,13 @@ 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)
// Operator-reviewed, terminal dismissal. Does not confirm or reverse payment.
// Requires x-admin-token and a recorded reason; only unlocked failed settlements.
IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error)
}
type generalLedgerServiceClient struct {
@@ -50,6 +71,46 @@ func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerSer
return &generalLedgerServiceClient{cc}
}
func (c *generalLedgerServiceClient) GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OperatingModeResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetOperatingMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OperatingModeResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_SetOperatingMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) PostInternalJournal(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_PostInternalJournal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserBalancesResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetUserBalances_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
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)
@@ -120,10 +181,56 @@ 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
}
func (c *generalLedgerServiceClient) IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IgnoreSettlementResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_IgnoreSettlement_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 {
GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error)
// Operator-only; requires x-admin-token and an optimistic revision.
SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error)
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error)
Health(context.Context, *v1.Empty) (*HealthResponse, error)
AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error)
@@ -131,6 +238,13 @@ 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)
// Operator-reviewed, terminal dismissal. Does not confirm or reverse payment.
// Requires x-admin-token and a recorded reason; only unlocked failed settlements.
IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error)
}
// UnimplementedGeneralLedgerServiceServer should be embedded to have
@@ -140,26 +254,50 @@ type GeneralLedgerServiceServer interface {
// pointer dereference when methods are called.
type UnimplementedGeneralLedgerServiceServer struct{}
func (UnimplementedGeneralLedgerServiceServer) GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOperatingMode not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetOperatingMode not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PostInternalJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserBalances not implemented")
}
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) IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IgnoreSettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {}
@@ -171,7 +309,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.
@@ -181,6 +319,78 @@ func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedg
s.RegisterService(&GeneralLedgerService_ServiceDesc, srv)
}
func _GeneralLedgerService_GetOperatingMode_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).GetOperatingMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetOperatingMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, req.(*v1.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_SetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetOperatingModeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_SetOperatingMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, req.(*SetOperatingModeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_PostInternalJournal_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).PostInternalJournal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_PostInternalJournal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, req.(*AppendJournalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetUserBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserBalancesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetUserBalances_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, req.(*GetUserBalancesRequest))
}
return interceptor(ctx, in, info, handler)
}
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 {
@@ -307,6 +517,78 @@ 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)
}
func _GeneralLedgerService_IgnoreSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IgnoreSettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_IgnoreSettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, req.(*IgnoreSettlementRequest))
}
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)
@@ -314,6 +596,22 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "ledger.v1.GeneralLedgerService",
HandlerType: (*GeneralLedgerServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetOperatingMode",
Handler: _GeneralLedgerService_GetOperatingMode_Handler,
},
{
MethodName: "SetOperatingMode",
Handler: _GeneralLedgerService_SetOperatingMode_Handler,
},
{
MethodName: "PostInternalJournal",
Handler: _GeneralLedgerService_PostInternalJournal_Handler,
},
{
MethodName: "GetUserBalances",
Handler: _GeneralLedgerService_GetUserBalances_Handler,
},
{
MethodName: "Health",
Handler: _GeneralLedgerService_Health_Handler,
@@ -342,6 +640,22 @@ 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,
},
{
MethodName: "IgnoreSettlement",
Handler: _GeneralLedgerService_IgnoreSettlement_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "ledger/v1/srv.proto",
+12 -3
View File
@@ -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 = ""
+33 -11
View File
@@ -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,22 @@ 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 github.com/a-h/templ
tool (
github.com/a-h/templ
github.com/a-h/templ/cmd/templ
)
+71 -24
View File
@@ -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=
+64 -14
View File
@@ -3,6 +3,8 @@ package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/knadh/koanf/parsers/toml"
@@ -11,21 +13,37 @@ 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"`
Telemetry TelemetryConfig `koanf:"telemetry"`
}
type DashboardConfig struct {
Environment string `koanf:"environment"`
HTTP HTTPConfig `koanf:"http"`
Database DatabaseConfig `koanf:"database"`
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 {
Host string `koanf:"host"`
Port int `koanf:"port"`
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
ShutdownTimeout time.Duration `koanf:"timeout"`
}
type HTTPConfig struct {
@@ -36,12 +54,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 +81,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 +109,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 +162,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)
}
+47 -4
View File
@@ -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")
}
}
+142
View File
@@ -0,0 +1,142 @@
package kuknos
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"gl/domain/ledger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("gl/infrastructure/kuknos")
// 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) {
ctx, span := tracer.Start(ctx, "kuknos.submit_transaction", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(
attribute.String("kuknos.network", record.Network),
attribute.String("settlement.id", record.ID),
attribute.String("settlement.source_service", record.SourceService),
attribute.String("settlement.source_transaction_id", record.SourceTxID),
attribute.String("settlement.idempotency_key", record.IdempotencyKey),
attribute.Int("settlement.attempt", record.Attempts),
)
if s.Endpoint == "" || record.SignedTransactionXDR == "" {
return s.fail(ctx, span, record, fmt.Errorf("kuknos submission endpoint and signed xdr are required"))
}
endpoint := strings.TrimRight(s.Endpoint, "/") + "/transactions"
form := url.Values{"tx": {record.SignedTransactionXDR}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return s.fail(ctx, span, record, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := s.Client
if client == nil {
client = http.DefaultClient
}
startedAt := time.Now()
slog.InfoContext(ctx, "kuknos transaction submission started",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"endpoint", endpoint,
)
resp, err := client.Do(req)
if err != nil {
return s.fail(ctx, span, record, err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return s.fail(ctx, span, record, fmt.Errorf("read kuknos response: %w", err))
}
rawResponse := string(responseBody)
var response struct {
Hash string `json:"hash"`
Extras struct {
ResultCodes struct {
Transaction string `json:"transaction"`
} `json:"result_codes"`
} `json:"extras"`
}
if err := json.Unmarshal(responseBody, &response); err != nil {
slog.ErrorContext(ctx, "kuknos transaction submission returned an invalid response",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"status_code", resp.StatusCode,
"stellar_response", rawResponse,
"error", err,
)
return s.fail(ctx, span, record, fmt.Errorf("decode kuknos response: %w", err))
}
latency := time.Since(startedAt)
span.SetAttributes(
attribute.Int("http.response.status_code", resp.StatusCode),
attribute.Int64("http.response.body.size", int64(len(responseBody))),
attribute.Int64("kuknos.submission.duration_ms", latency.Milliseconds()),
)
span.AddEvent("kuknos.response", trace.WithAttributes(
attribute.String("stellar_response", rawResponse),
))
span.SetStatus(codes.Ok, "submission response received")
slog.InfoContext(ctx, "kuknos transaction submission response",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"status_code", resp.StatusCode,
"transaction_hash", response.Hash,
"transaction_result_code", response.Extras.ResultCodes.Transaction,
"stellar_response", rawResponse,
"duration_ms", latency.Milliseconds(),
)
if resp.StatusCode < 200 || resp.StatusCode >= 300 || response.Hash == "" {
err := fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, response.Extras.ResultCodes.Transaction)
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
if response.Extras.ResultCodes.Transaction == "tx_bad_seq" {
return "", ledger.PermanentSubmissionError{Err: err}
}
return "", err
}
return response.Hash, nil
}
func (s Submitter) fail(ctx context.Context, span trace.Span, record ledger.Settlement, err error) (string, error) {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
slog.ErrorContext(ctx, "kuknos transaction submission failed",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"error", err,
)
return "", err
}
+61
View File
@@ -0,0 +1,61 @@
package observability
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
// 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"
attr.Value = slog.StringValue(levelName(attr.Value))
case slog.MessageKey:
attr.Key = "message"
case slog.SourceKey:
attr.Key = "source"
}
return attr
},
})
logger := slog.New(&traceHandler{Handler: handler}).With("service", service)
slog.SetDefault(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 {
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)}
}
+39
View File
@@ -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
@@ -0,0 +1,243 @@
package postgres
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
applicationledger "gl/application/ledger"
applicationsettlement "gl/application/settlement"
domain "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
grpcadapter "gl/interface/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Opt-in real PostgreSQL proof. Each run owns a new schema and removes only it.
func TestAuthorityWithPostgres(t *testing.T) {
dsn := os.Getenv("GL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set GL_TEST_DATABASE_URL to a disposable PostgreSQL database")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := fmt.Sprintf("gl_authority_test_%d", time.Now().UnixNano())
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
if _, err := admin.Exec(cleanup, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
database := &Pool{pool: pool}
if err := Migrate(ctx, database); err != nil {
t.Fatal(err)
}
repository := NewJournalRepository(database)
service := applicationledger.NewService(repository, nil)
settlementRepo := NewSettlementRepository(database)
settlements := applicationsettlement.NewService(settlementRepo)
handler := grpcadapter.NewHandler(nil, service, settlements).WithSettlementAdminToken("test-token")
authorized := metadata.NewIncomingContext(ctx, metadata.Pairs("x-admin-token", "test-token"))
account := func(class domain.AccountClass, owner string) domain.AccountReference {
return domain.AccountReference{Class: class, OwnerType: "user", OwnerID: owner, AssetID: 7}
}
command := func(key string, from, to domain.AccountReference, amount string) applicationledger.AppendJournalCommand {
credit, err := domain.ParseAmount(amount)
if err != nil {
t.Fatal(err)
}
return applicationledger.AppendJournalCommand{
SourceService: "internal", SourceTransactionID: key, IdempotencyKey: key, EffectKind: "transfer", EventVersion: 1,
ActorID: "operator", OccurredAt: time.Unix(100, 0).UTC(),
Entries: []applicationledger.EntryCommand{
{LineNumber: 1, Account: from, Amount: credit.Negate().String()},
{LineNumber: 2, Account: to, Amount: credit.String()},
},
}
}
alice := account(domain.AccountClassUserAvailable, "alice")
bob := account(domain.AccountClassUserAvailable, "bob")
opening := command("opening", domain.AccountReference{Class: domain.AccountClassTreasury, AssetID: 7}, alice, "100")
if _, err := service.AppendJournal(ctx, opening); err != nil {
t.Fatal(err)
}
transfer := command("transfer-1", alice, bob, "60")
if _, err := service.PostInternalJournal(ctx, transfer); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("NORMAL admitted internal execution: %v", err)
}
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("unauthorized toggle: %v", err)
}
if _, _, err := settlements.Enqueue(ctx, "wallet", "t1", "s1", "kuknos", "signed-xdr"); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by='in-flight'"); err != nil {
t.Fatal(err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 0, "operator", "outage", false); !errors.Is(err, domain.ErrSettlementInFlight) {
t.Fatalf("in-flight toggle: %v", err)
}
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by=''"); err != nil {
t.Fatal(err)
}
state, err := handler.SetOperatingMode(authorized, &ledgerv1.SetOperatingModeRequest{Mode: "GL_ONLY", ActorId: "operator", Reason: "outage"})
if err != nil || state.GetRevision() != 1 {
t.Fatalf("enable GL_ONLY: %+v %v", state, err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 0, "operator", "stale", true); !errors.Is(err, domain.ErrModeConflict) {
t.Fatalf("stale revision accepted: %v", err)
}
if claimed, err := settlementRepo.ClaimDue(ctx, "worker", 10, time.Now()); err != nil || len(claimed) != 0 {
t.Fatalf("settlements claimed in GL_ONLY: %v %v", claimed, err)
}
if _, _, err := settlements.Enqueue(ctx, "wallet", "t2", "s2", "kuknos", "signed-xdr"); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("settlement admitted: %v", err)
}
// Two simultaneous spends cannot consume the same opening balance.
var wg sync.WaitGroup
results := make(chan error, 2)
for _, key := range []string{"transfer-1", "transfer-2"} {
next := command(key, alice, bob, "60")
wg.Add(1)
go func(next applicationledger.AppendJournalCommand) {
defer wg.Done()
_, err := service.PostInternalJournal(ctx, next)
results <- err
}(next)
}
wg.Wait()
close(results)
successes, insufficient := 0, 0
for err := range results {
if err == nil {
successes++
} else if errors.Is(err, domain.ErrInsufficientFunds) {
insufficient++
} else {
t.Fatal(err)
}
}
if successes != 1 || insufficient != 1 {
t.Fatalf("concurrent spends: success=%d insufficient=%d", successes, insufficient)
}
var successfulKey string
if err := database.QueryRow(ctx, "SELECT idempotency_key FROM journals WHERE metadata->>'gl_execution'='GL_ONLY'").Scan(&successfulKey); err != nil {
t.Fatal(err)
}
transfer = command(successfulKey, alice, bob, "60")
repeated, err := service.PostInternalJournal(ctx, transfer)
if err != nil || !repeated.AlreadyExisted {
t.Fatalf("idempotent internal retry: %+v %v", repeated, err)
}
conflicting := command(successfulKey, alice, bob, "1")
if _, err := service.PostInternalJournal(ctx, conflicting); !errors.Is(err, domain.ErrIdempotencyConflict) {
t.Fatalf("conflicting retry: %v", err)
}
frozen := account(domain.AccountClassUserFrozen, "alice")
if _, err := service.PostInternalJournal(ctx, command("freeze", alice, frozen, "10")); err != nil {
t.Fatal(err)
}
if _, err := service.PostInternalJournal(ctx, command("release", frozen, alice, "4")); err != nil {
t.Fatal(err)
}
// The legacy ingestion API cannot execute new effects in GL_ONLY.
if _, err := service.AppendJournal(ctx, command("legacy-overspend", alice, bob, "100")); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("legacy bypass: %v", err)
}
response, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "alice"})
if err != nil || len(response.GetBalances()) != 1 {
t.Fatalf("user balance: %+v %v", response, err)
}
balance := response.Balances[0]
if balance.Available != "34" || balance.Frozen != "6" || balance.Total != "40" {
t.Fatalf("incorrect balance: %+v", balance)
}
var sum string
if err := database.QueryRow(ctx, `SELECT sum(e.amount)::text FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')`).Scan(&sum); err != nil {
t.Fatal(err)
}
total, err := domain.ParseAmount(sum)
if err != nil || total.String() != "100" {
t.Fatalf("conservation failed: %s %v", sum, err)
}
// Exercise the internal RPC mapping with a real commit.
posted, err := handler.PostInternalJournal(authorized, &ledgerv1.AppendJournalRequest{
SourceService: "internal", SourceTransactionId: "rpc", IdempotencyKey: "rpc", EffectKind: "transfer", EventVersion: 1,
ActorId: "operator", OccurredAt: timestamppb.New(time.Unix(100, 0)),
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "alice", AssetId: 7}, Amount: "-1"},
{LineNumber: 2, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "bob", AssetId: 7}, Amount: "1"},
},
})
if err != nil || posted.GetJournal().GetJournalId() == "" {
t.Fatalf("internal RPC: %+v %v", posted, err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", false); !errors.Is(err, domain.ErrModeConflict) {
t.Fatalf("unreconciled recovery: %v", err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", true); err != nil {
t.Fatal(err)
}
repeated, err = service.PostInternalJournal(ctx, transfer)
if err != nil || !repeated.AlreadyExisted {
t.Fatalf("retry after mode change: %+v %v", repeated, err)
}
if _, err := service.PostInternalJournal(ctx, command("after-recovery", alice, bob, "1")); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("internal post after recovery: %v", err)
}
unknownAsset := int64(99)
zero, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "unknown", AssetId: &unknownAsset})
if err != nil || len(zero.GetBalances()) != 1 || zero.Balances[0].Total != "0" {
t.Fatalf("unknown user balance: %+v %v", zero, err)
}
// New instances read the same persisted authority state.
if persisted, err := NewJournalRepository(database).OperatingMode(ctx); err != nil || persisted.Mode != domain.ModeNormal || persisted.Revision != 2 {
t.Fatalf("persisted mode: %+v %v", persisted, err)
}
if _, err := service.AppendJournal(ctx, command("negative-baseline", alice, bob, "1000")); err != nil {
t.Fatal(err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 2, "operator", "uncorrected books", false); !errors.Is(err, domain.ErrInsufficientFunds) {
t.Fatalf("negative opening balances accepted: %v", err)
}
var audits int64
if err := database.QueryRow(ctx, "SELECT count(*) FROM ledger_mode_changes").Scan(&audits); err != nil || audits != 2 {
t.Fatalf("mode audit: %d %v", audits, err)
}
if _, err := database.Exec(ctx, "DELETE FROM ledger_mode_changes"); err == nil {
t.Fatal("mode audit is mutable")
}
if _, err := database.Exec(ctx, "UPDATE journals SET actor_id='changed'"); err == nil {
t.Fatal("journals are mutable")
}
}
@@ -0,0 +1,141 @@
package postgres
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5/pgconn"
"gl/domain/ledger"
)
func authorityError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "PGL01":
return ledger.ErrModeRequired
case "PGL02":
return ledger.ErrInsufficientFunds
case "40P01", "40001":
return ledger.ErrConcurrentPosting
}
}
return nil
}
func (r *JournalRepository) OperatingMode(ctx context.Context) (ledger.OperatingState, error) {
return scanOperatingState(r.database.QueryRow(ctx, operatingStateSQL))
}
func scanOperatingState(row Row) (ledger.OperatingState, error) {
var state ledger.OperatingState
var mode string
err := row.Scan(&mode, &state.Revision, &state.ActorID, &state.Reason, &state.ChangedAt)
state.Mode = ledger.OperatingMode(mode)
return state, err
}
const operatingStateSQL = "SELECT mode, revision, actor_id, reason, changed_at FROM ledger_operating_state WHERE singleton"
func (r *JournalRepository) SetOperatingMode(ctx context.Context, mode ledger.OperatingMode, revision int64, actor, reason string, reconciled bool) (ledger.OperatingState, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.OperatingState{}, err
}
defer tx.Rollback(ctx)
current, err := scanOperatingState(tx.QueryRow(ctx, operatingStateSQL+" FOR UPDATE"))
if err != nil {
return ledger.OperatingState{}, err
}
if current.Revision != revision {
return ledger.OperatingState{}, ledger.ErrModeConflict
}
if current.Mode == mode {
return current, nil
}
if mode == ledger.ModeNormal && !reconciled {
return ledger.OperatingState{}, fmt.Errorf("%w: reconciliation must be confirmed before returning to NORMAL", ledger.ErrModeConflict)
}
if mode == ledger.ModeGLOnly {
var inFlight bool
if err := tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM kuknos_settlements WHERE locked_by <> '')").Scan(&inFlight); err != nil {
return ledger.OperatingState{}, err
}
if inFlight {
return ledger.OperatingState{}, ledger.ErrSettlementInFlight
}
var negativeBalance bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (
SELECT a.id FROM ledger_accounts a JOIN journal_entries e ON e.account_id=a.id
JOIN journals j ON j.id=e.journal_id AND j.sealed_at IS NOT NULL
WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')
GROUP BY a.id HAVING sum(e.amount)<0
)`).Scan(&negativeBalance); err != nil {
return ledger.OperatingState{}, err
}
if negativeBalance {
return ledger.OperatingState{}, ledger.ErrInsufficientFunds
}
}
state, err := scanOperatingState(tx.QueryRow(ctx, `UPDATE ledger_operating_state
SET mode=$1, revision=revision+1, actor_id=$2, reason=$3, changed_at=clock_timestamp()
WHERE singleton RETURNING mode, revision, actor_id, reason, changed_at`, string(mode), actor, reason))
if err != nil {
return ledger.OperatingState{}, err
}
if _, err := tx.Exec(ctx, `INSERT INTO ledger_mode_changes
(revision, previous_mode, mode, actor_id, reason, reconciliation_confirmed, changed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, state.Revision, string(current.Mode), string(mode), actor, reason, reconciled, state.ChangedAt); err != nil {
return ledger.OperatingState{}, err
}
if err := tx.Commit(ctx); err != nil {
return ledger.OperatingState{}, err
}
slog.WarnContext(ctx, "ledger operating mode changed", "previous_mode", current.Mode, "mode", mode, "revision", state.Revision, "actor_id", actor, "reason", reason)
return state, nil
}
// One statement provides a consistent snapshot across available and frozen funds.
func (r *JournalRepository) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]ledger.UserAssetBalance, error) {
rows, err := r.database.Query(ctx, `SELECT a.asset_id,
COALESCE(sum(e.amount) FILTER (WHERE a.class='USER_AVAILABLE'),0)::text,
COALESCE(sum(e.amount) FILTER (WHERE a.class='USER_FROZEN'),0)::text
FROM ledger_accounts a
LEFT JOIN journal_entries e ON e.account_id=a.id
LEFT JOIN journals j ON j.id=e.journal_id AND j.sealed_at IS NOT NULL
WHERE a.owner_type=$1 AND a.owner_id=$2
AND a.class IN ('USER_AVAILABLE','USER_FROZEN')
AND ($3::bigint IS NULL OR a.asset_id=$3)
AND (e.journal_id IS NULL OR j.id IS NOT NULL)
GROUP BY a.asset_id ORDER BY a.asset_id`, ownerType, ownerID, assetID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ledger.UserAssetBalance{}
for rows.Next() {
var balance ledger.UserAssetBalance
var available, frozen string
if err := rows.Scan(&balance.AssetID, &available, &frozen); err != nil {
return nil, err
}
balance.Available, err = ledger.ParseAmount(available)
if err != nil {
return nil, err
}
balance.Frozen, err = ledger.ParseAmount(frozen)
if err != nil {
return nil, err
}
result = append(result, balance)
}
if err := rows.Err(); err != nil {
return nil, err
}
if assetID != nil && len(result) == 0 {
result = append(result, ledger.UserAssetBalance{AssetID: *assetID})
}
return result, nil
}
+4
View File
@@ -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...)
}
+29 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"gl/domain/ledger"
@@ -47,6 +48,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
defer func() {
if err != nil {
_ = tx.Rollback(ctx)
if classified := authorityError(err); classified != nil {
err = classified
}
}
}()
@@ -75,13 +79,34 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
}
if err != nil {
var postgresError *pgconn.PgError
if classified := authorityError(err); classified != nil {
return AppendResult{}, classified
}
if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
return AppendResult{}, ledger.ErrAlreadyReversed
}
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
}
for _, entry := range journal.Entries {
// Resolve and lock accounts in stable order across every writer.
entries := append([]ledger.Entry(nil), journal.Entries...)
sort.Slice(entries, func(i, j int) bool {
left, right := entries[i].Account, entries[j].Account
if left.Class != right.Class {
return left.Class < right.Class
}
if left.OwnerType != right.OwnerType {
return left.OwnerType < right.OwnerType
}
if left.OwnerID != right.OwnerID {
return left.OwnerID < right.OwnerID
}
if left.AssetID != right.AssetID {
return left.AssetID < right.AssetID
}
return entries[i].LineNumber < entries[j].LineNumber
})
for _, entry := range entries {
var accountID int64
err = tx.QueryRow(ctx, insertAccountSQL,
entry.Account.Class,
@@ -114,6 +139,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
}
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
if classified := authorityError(err); classified != nil {
return AppendResult{}, classified
}
return AppendResult{}, fmt.Errorf("seal journal: %w", err)
}
if err = tx.Commit(ctx); err != nil {
@@ -31,6 +31,10 @@ func (r fakeRow) Scan(dest ...any) error {
*target = value.(string)
case *int64:
*target = value.(int64)
case *int:
*target = value.(int)
case *ledger.SettlementStatus:
*target = value.(ledger.SettlementStatus)
case *bool:
*target = value.(bool)
case *uint32:
@@ -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);
@@ -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);
@@ -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);
@@ -0,0 +1,95 @@
CREATE TABLE ledger_operating_state (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
mode text NOT NULL CHECK (mode IN ('NORMAL','GL_ONLY')),
revision bigint NOT NULL CHECK (revision >= 0),
actor_id text NOT NULL, reason text NOT NULL,
changed_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO ledger_operating_state (mode,revision,actor_id,reason) VALUES ('NORMAL',0,'system','initial mode');
CREATE TABLE ledger_mode_changes (
revision bigint PRIMARY KEY,
previous_mode text NOT NULL CHECK (previous_mode IN ('NORMAL','GL_ONLY')),
mode text NOT NULL CHECK (mode IN ('NORMAL','GL_ONLY')),
actor_id text NOT NULL CHECK (btrim(actor_id) <> ''),
reason text NOT NULL CHECK (btrim(reason) <> ''),
reconciliation_confirmed boolean NOT NULL, changed_at timestamptz NOT NULL
);
CREATE TRIGGER mode_changes_reject_mutation BEFORE UPDATE OR DELETE ON ledger_mode_changes
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER operating_state_reject_delete BEFORE DELETE ON ledger_operating_state
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
-- A shared lock is held through each posting; toggles drain writers.
CREATE FUNCTION guard_journal_authority() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_mode text;
BEGIN
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton FOR SHARE;
IF NEW.metadata->>'gl_execution'='GL_ONLY' AND current_mode<>'GL_ONLY'
AND NOT EXISTS (SELECT 1 FROM journals WHERE idempotency_key=NEW.idempotency_key) THEN
RAISE EXCEPTION 'GL_ONLY mode is required' USING ERRCODE='PGL01';
END IF;
IF current_mode='GL_ONLY' AND COALESCE(NEW.metadata->>'gl_execution','')<>'GL_ONLY'
AND NOT EXISTS (SELECT 1 FROM journals WHERE idempotency_key=NEW.idempotency_key) THEN
RAISE EXCEPTION 'new GL_ONLY effects must use internal execution' USING ERRCODE='PGL01';
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER journals_guard_authority BEFORE INSERT ON journals
FOR EACH ROW EXECUTE FUNCTION guard_journal_authority();
-- Both credits and debits lock accounts, including normal recorders.
CREATE OR REPLACE 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;
PERFORM id FROM ledger_accounts WHERE id=NEW.account_id
AND class IN ('USER_AVAILABLE','USER_FROZEN') FOR UPDATE;
RETURN NEW;
END; $$;
CREATE OR REPLACE FUNCTION guard_journal_seal() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_mode text;
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;
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton;
IF current_mode='GL_ONLY' THEN
IF NEW.blockchain_network<>'' OR NEW.blockchain_transaction_hash<>'' OR NEW.blockchain_ledger_sequence<>''
OR EXISTS (SELECT 1 FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id
WHERE e.journal_id=NEW.id AND a.class='EXTERNAL_BLOCKCHAIN') THEN
RAISE EXCEPTION 'external blockchain movements are unavailable in GL_ONLY' USING ERRCODE='PGL01';
END IF;
IF EXISTS (
SELECT a.id FROM ledger_accounts a JOIN journal_entries e ON e.account_id=a.id JOIN journals j ON j.id=e.journal_id
WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')
AND a.id IN (SELECT account_id FROM journal_entries WHERE journal_id=NEW.id)
AND (j.sealed_at IS NOT NULL OR j.id=NEW.id)
GROUP BY a.id HAVING sum(e.amount)<0
) THEN
RAISE EXCEPTION 'insufficient ledger funds' USING ERRCODE='PGL02';
END IF;
END IF;
RETURN NEW;
END; $$;
CREATE FUNCTION guard_settlement_authority() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_mode text;
BEGIN
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton FOR SHARE;
IF current_mode='GL_ONLY'
AND NOT EXISTS (SELECT 1 FROM kuknos_settlements WHERE idempotency_key=NEW.idempotency_key) THEN
RAISE EXCEPTION 'blockchain settlement is disabled in GL_ONLY' USING ERRCODE='PGL01';
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER settlements_guard_authority BEFORE INSERT ON kuknos_settlements
FOR EACH ROW EXECUTE FUNCTION guard_settlement_authority();
@@ -0,0 +1,7 @@
ALTER TABLE kuknos_settlements DROP CONSTRAINT kuknos_settlements_status_check;
ALTER TABLE kuknos_settlements ADD CONSTRAINT kuknos_settlements_status_check
CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW','IGNORED'));
ALTER TABLE kuknos_settlement_operator_audits DROP CONSTRAINT kuknos_settlement_operator_audits_action_check;
ALTER TABLE kuknos_settlement_operator_audits ADD CONSTRAINT kuknos_settlement_operator_audits_action_check
CHECK (action IN ('RETRY','IGNORE'));
@@ -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
@@ -0,0 +1,251 @@
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}
}
// Existing normal-mode Wallet clients require SettlementReady; a GL-only
// cutover must not let them continue admitting blockchain-dependent effects.
func (r *SettlementRepository) SettlementAllowed(ctx context.Context) (bool, error) {
var mode string
err := r.database.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton").Scan(&mode)
return mode == string(ledger.ModeNormal), err
}
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
}
if classified := authorityError(err); classified != nil {
return ledger.Settlement{}, false, classified
}
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)
var mode string
if err := tx.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton FOR SHARE").Scan(&mode); err != nil {
return nil, err
}
if mode == string(ledger.ModeGLOnly) {
return nil, nil
}
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 || previous == ledger.SettlementIgnored {
return ledger.Settlement{}, fmt.Errorf("confirmed or ignored 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, "")
}
func (r *SettlementRepository) IgnoreByOperator(ctx context.Context, id, actorID, reason string) (ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("begin operator dismissal: %w", err)
}
defer tx.Rollback(ctx)
var previous, lockedBy string
if err := tx.QueryRow(ctx, `SELECT status, locked_by FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous, &lockedBy); err != nil {
return ledger.Settlement{}, fmt.Errorf("lock settlement for dismissal: %w", err)
}
if lockedBy != "" || (previous != string(ledger.SettlementManualReview) && previous != string(ledger.SettlementRetryable)) {
return ledger.Settlement{}, fmt.Errorf("only unlocked settlements requiring review or retry can be ignored")
}
// Preserve the error and signed payload as evidence; this is not a payment confirmation.
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='IGNORED', updated_at=clock_timestamp() WHERE id=$1`, id); err != nil {
return ledger.Settlement{}, fmt.Errorf("ignore 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,'IGNORE',$3,$4,'IGNORED')`, id, actorID, reason, previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("audit operator dismissal: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues SET status='RESOLVED', resolved_at=clock_timestamp() WHERE settlement_id=$1 AND status='OPEN'`, id); err != nil {
return ledger.Settlement{}, fmt.Errorf("resolve dismissed settlement issues: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return ledger.Settlement{}, fmt.Errorf("commit operator dismissal: %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
}
@@ -0,0 +1,84 @@
package postgres
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
func TestIgnoreByOperatorRejectsActiveLockedAndTerminalSettlements(t *testing.T) {
for _, state := range []struct{ status, lock string }{
{"PENDING", ""}, {"SUBMITTED", ""}, {"CONFIRMED", ""}, {"IGNORED", ""}, {"RETRYABLE", "worker"},
} {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{state.status, state.lock}}}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil {
t.Fatalf("dismissed ineligible state: %+v", state)
}
if tx.execCount != 0 || tx.committed || !tx.rolledBack {
t.Fatalf("ineligible dismissal changed state: %+v", tx)
}
}
}
func TestIgnoreByOperatorAuditsAndResolvesAtomically(t *testing.T) {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"MANUAL_REVIEW", ""}}}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx, directRow: fakeRow{values: []any{
"id", "wallet", "110", "wallet:110:kuknos", ledger.SettlementIgnored, "kuknos", "", 1, time.Now(), "tx_bad_seq", "signed-xdr",
}}})
got, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed")
if err != nil || got.Status != ledger.SettlementIgnored || got.LastError != "tx_bad_seq" || !tx.committed || tx.execCount != 3 {
t.Fatalf("unexpected dismissal: %+v tx=%+v err=%v", got, tx, err)
}
for _, failedStep := range []int{2, 3} {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"RETRYABLE", ""}}}, execErrAt: failedStep}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil || tx.committed || !tx.rolledBack {
t.Fatalf("audit or issue failure did not roll back: %+v err=%v", tx, err)
}
}
}
func TestIgnoredSettlementCannotBeRetried(t *testing.T) {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{ledger.SettlementIgnored}}}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.RetryByOperator(context.Background(), "id", "actor", "retry", time.Now()); err == nil || tx.execCount != 0 {
t.Fatal("ignored settlement was queued again")
}
}
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)
}
}
+75
View File
@@ -0,0 +1,75 @@
package grpcadapter
import (
"context"
"crypto/subtle"
domain "gl/domain/ledger"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func (h *Handler) requireAdmin(ctx context.Context) error {
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token")
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 {
return status.Error(codes.PermissionDenied, "invalid administrative token")
}
return nil
}
func modeMessage(state domain.OperatingState) *ledgerv1.OperatingModeResponse {
return &ledgerv1.OperatingModeResponse{Mode: string(state.Mode), Revision: state.Revision, ActorId: state.ActorID, Reason: state.Reason, ChangedAt: timestampMessage(state.ChangedAt)}
}
func (h *Handler) GetOperatingMode(ctx context.Context, _ *basev1.Empty) (*ledgerv1.OperatingModeResponse, error) {
state, err := h.ledger.OperatingMode(ctx)
if err != nil {
return nil, rpcError(err)
}
return modeMessage(state), nil
}
func (h *Handler) SetOperatingMode(ctx context.Context, request *ledgerv1.SetOperatingModeRequest) (*ledgerv1.OperatingModeResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
state, err := h.ledger.SetOperatingMode(ctx, domain.OperatingMode(request.GetMode()), request.GetExpectedRevision(), request.GetActorId(), request.GetReason(), request.GetReconciliationConfirmed())
if err != nil {
return nil, rpcError(err)
}
return modeMessage(state), nil
}
func (h *Handler) PostInternalJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
command, err := appendJournalCommand(request)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
result, err := h.ledger.PostInternalJournal(ctx, command)
if err != nil {
return nil, rpcError(err)
}
return &ledgerv1.AppendJournalResponse{Journal: journalMessage(result.Journal), AlreadyExisted: result.AlreadyExisted}, nil
}
func (h *Handler) GetUserBalances(ctx context.Context, request *ledgerv1.GetUserBalancesRequest) (*ledgerv1.GetUserBalancesResponse, error) {
if request == nil {
return nil, status.Error(codes.InvalidArgument, "request is required")
}
balances, err := h.ledger.UserBalances(ctx, request.GetOwnerType(), request.GetOwnerId(), request.AssetId)
if err != nil {
return nil, rpcError(err)
}
response := &ledgerv1.GetUserBalancesResponse{OwnerType: request.GetOwnerType(), OwnerId: request.GetOwnerId()}
for _, balance := range balances {
response.Balances = append(response.Balances, &ledgerv1.UserAssetBalance{
AssetId: balance.AssetID, Available: balance.Available.String(), Frozen: balance.Frozen.String(), Total: balance.Available.Add(balance.Frozen).String(),
})
}
return response, nil
}
+33
View File
@@ -0,0 +1,33 @@
package grpcadapter
import (
"context"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"testing"
)
func TestAuthorityCommandsRequireOneConfiguredAdministrativeToken(t *testing.T) {
for _, tc := range []struct {
name, configured string
tokens []string
}{
{"unconfigured", "", []string{"token"}},
{"missing", "token", nil},
{"wrong", "token", []string{"wrong"}},
{"duplicate", "token", []string{"token", "token"}},
} {
t.Run(tc.name, func(t *testing.T) {
handler := NewHandler(nil, nil).WithSettlementAdminToken(tc.configured)
ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{"x-admin-token": tc.tokens})
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("toggle: %v", err)
}
if _, err := handler.PostInternalJournal(ctx, &ledgerv1.AppendJournalRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("internal posting: %v", err)
}
})
}
}
+21 -6
View File
@@ -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
}
+4
View File
@@ -398,6 +398,10 @@ func rpcError(err error) error {
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, domain.ErrAlreadyReversed):
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, domain.ErrModeConflict), errors.Is(err, domain.ErrConcurrentPosting):
return status.Error(codes.Aborted, err.Error())
case errors.Is(err, domain.ErrModeRequired), errors.Is(err, domain.ErrInsufficientFunds), errors.Is(err, domain.ErrSettlementInFlight):
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):
+42 -1
View File
@@ -4,13 +4,18 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"runtime/debug"
"time"
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"
"google.golang.org/grpc/status"
)
type ServerConfig struct {
@@ -30,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()
server := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
reflection.Register(server)
@@ -67,3 +72,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)
}
}
+75
View File
@@ -0,0 +1,75 @@
package grpcadapter
import (
"context"
"errors"
"gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"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 {
if errors.Is(err, ledger.ErrModeRequired) {
return nil, rpcError(err)
}
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")
}
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
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 (h *Handler) IgnoreSettlement(ctx context.Context, request *ledgerv1.IgnoreSettlementRequest) (*ledgerv1.IgnoreSettlementResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
record, err := h.settlement.IgnoreByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
if err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &ledgerv1.IgnoreSettlementResponse{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
}
+21
View File
@@ -0,0 +1,21 @@
package grpcadapter
import (
"context"
"testing"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func TestIgnoreSettlementRequiresAdminToken(t *testing.T) {
handler := NewHandler(nil, nil).WithSettlementAdminToken("secret")
for _, token := range []string{"", "wrong"} {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-admin-token", token))
if _, err := handler.IgnoreSettlement(ctx, &ledgerv1.IgnoreSettlementRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("unauthorized dismissal: %v", err)
}
}
}
+3 -1
View File
@@ -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{"<!doctype html>", "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{"<!doctype html>", "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)
}
+24
View File
@@ -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": "در دسترس + مسدود · دارایی‌های اخیر",
+3 -1
View File
@@ -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)
+44
View File
@@ -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) {
<div class="stat"><span class="stat-label">{ tr(locale, "tracked_accounts") }</span><strong>{ count(data.Stats.AccountCount) }</strong></div>
<div class="stat"><span class="stat-label">{ tr(locale, "last_recorded") }</span><time>{ formatTime(data.Stats.LastRecordedAt) }</time></div>
</section>
<section class="dashboard-section">
<div class="section-head"><h2>{ tr(locale, "blockchain_settlements") }</h2><span>{ tr(locale, "settlement_summary") }</span></div>
<section class="stats" aria-label={ tr(locale, "blockchain_settlements") }>
<div class="stat"><span class="stat-label">{ tr(locale, "confirmed_on_chain") }</span><strong>{ count(data.SettlementStats.Confirmed) }</strong></div>
<div class="stat"><span class="stat-label">{ tr(locale, "pending_submission") }</span><strong>{ count(data.SettlementStats.Pending) }</strong></div>
<div class="stat"><span class="stat-label">{ tr(locale, "retryable") }</span><strong>{ count(data.SettlementStats.Retryable) }</strong></div>
<div class="stat"><span class="stat-label">{ tr(locale, "manual_review") }</span><strong>{ count(data.SettlementStats.ManualReview) }</strong></div>
</section>
@SettlementTable(data.Settlements, locale)
</section>
<section>
<div class="section-head"><h2>{ tr(locale, "latest_activity") }</h2><span>{ tr(locale, "newest_first") }</span></div>
@JournalTable(data.Journals, locale)
@@ -207,6 +218,39 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) {
</main>
}
templ SettlementTable(settlements []explorer.Settlement, locale Locale) {
<div class="panel">
if len(settlements) == 0 {
<div class="empty">{ tr(locale, "no_settlements") }</div>
} else {
<table>
<thead><tr><th>{ tr(locale, "blockchain_transaction") }</th><th>{ tr(locale, "status") }</th><th>{ tr(locale, "network") }</th><th>{ tr(locale, "source") }</th><th>{ tr(locale, "attempts") }</th><th>{ tr(locale, "updated") }</th></tr></thead>
<tbody>
for _, settlement := range settlements {
<tr>
<td class="mono">
if settlement.TransactionHash != "" {
<a class="hash-link" href={ templ.URL(transactionURL(settlement.TransactionHash)) } title={ settlement.TransactionHash }>{ short(settlement.TransactionHash, 22) }</a>
} else {
<span title={ settlement.ID }>{ tr(locale, "not_submitted") } · { short(settlement.ID, 14) }</span>
}
if settlement.LastError != "" {
<small>{ short(settlement.LastError, 70) }</small>
}
</td>
<td><span class="pill">{ string(settlement.Status) }</span></td>
<td>{ settlement.Network }</td>
<td>{ settlement.SourceService }<small class="mono">{ short(settlement.SourceTransactionID, 18) }</small></td>
<td class="mono">{ strconv.Itoa(settlement.Attempts) }</td>
<td class="mono">{ formatTime(settlement.UpdatedAt) }</td>
</tr>
}
</tbody>
</table>
}
</div>
}
templ AssetsContent(data explorer.Assets, locale Locale) {
<main id="explorer-content" class="shell">
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "assets") }</div>
File diff suppressed because one or more lines are too long
-23
View File
@@ -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"