Compare commits

...

7 Commits

42 changed files with 2160 additions and 143 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
+4
View File
@@ -28,6 +28,9 @@ go.work.sum
# env file
.env
# macOS metadata
.DS_Store
# ---> JupyterNotebooks
# gitignore template for Jupyter Notebooks
# website: http://jupyter.org/
@@ -243,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
+24 -16
View File
@@ -1,19 +1,20 @@
# General Ledger service design
Status: accepted for the first implementation slice on 2026-08-14.
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28.
## Purpose and ownership
`GL` is Darano's durable financial journal. It records every committed wallet
value movement independently of Stellar so balances and transaction history can
be reconstructed during a blockchain or provider outage.
`GL` is Darano's primary financial source of truth. It records every wallet
value movement as an append-only journal. Kuknos, through its Stellar-compatible
interface, is the secondary settlement and verification source of truth.
GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does
not write GL tables directly, and GL does not write wallet tables. In normal
operation Wallet remains the transaction orchestrator and Stellar remains the
external settlement network. Promoting GL from a mirror to an operational
fallback is an explicit, audited mode change; an outage must never make a failed
blockchain operation appear successful automatically.
operation a transaction requires both GL and Kuknos. If GL is unhealthy, all
value-changing transaction admission and processing halt until it recovers. If
Kuknos is unavailable, an authorized operator may explicitly enable GL-only
operation through an AdminPanel toggle or configuration. Kuknos must never be
disabled automatically.
## Non-negotiable invariants
@@ -114,13 +115,20 @@ does not hold a wallet database transaction open.
## Operating modes and failure semantics
- `MIRROR`: normal mode. Wallet follows its existing settlement policy and GL
asynchronously records every committed effect.
- `DEGRADED_LEDGER`: explicitly enabled by an authorized operator. Eligible
internal operations may settle against GL while blockchain-bound operations
remain pending. The initial implementation does not activate this mode.
- `RECONCILE`: outbound posting is paused or restricted while tooling compares
Wallet, GL, and blockchain state and appends approved reversals/corrections.
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
only after its required GL journal and Kuknos settlement evidence exist.
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
through AdminPanel or configuration. GL remains mandatory and authoritative;
eligible transactions may proceed without Kuknos. Every mode transition must
be immutable, attributable, time-bounded where configured, and emitted via
OpenTelemetry. This mode is not implemented yet.
- `RECONCILE`: transaction processing is paused or restricted while tooling
compares GL and Kuknos and appends approved reversals/corrections. Existing
ledger records are never edited or deleted.
GL failure is always fail-closed. Public readiness reports a critical state and
the incident and recovery are emitted through OpenTelemetry. Kuknos failure is
also fail-closed unless `KUKNOS_DISABLED` has been explicitly authorized.
GL rejects unbalanced journals, invalid precision, unknown account/asset
combinations, duplicate line numbers, missing source identity, conflicting
@@ -155,7 +163,7 @@ are adapter concerns.
## Initial non-goals
- Replacing Stellar automatically on health-check failure.
- Disabling Kuknos automatically on health-check failure.
- Editing or deleting posted journals.
- Storing binary floats or using Wallet's mutable transaction table as GL.
- Sharing a database schema between Wallet and GL.
+34 -7
View File
@@ -1,29 +1,56 @@
// Package health provides the service readiness use case.
package health
import "context"
import (
"context"
"gl/domain/ledger"
)
type Database interface {
Ping(context.Context) error
}
type SettlementMonitor interface {
Stats(context.Context) (ledger.SettlementStats, error)
}
type Status struct {
Serving bool
DatabaseReady bool
Serving bool
DatabaseReady bool
SettlementEnabled bool
SettlementReady bool
SettlementStats ledger.SettlementStats
}
type Service struct {
database Database
database Database
settlement SettlementMonitor
settlementEnabled bool
}
func NewService(database Database) *Service {
return &Service{database: database}
func NewService(database Database, settlement ...SettlementMonitor) *Service {
service := &Service{database: database}
if len(settlement) > 0 && settlement[0] != nil {
service.settlement = settlement[0]
service.settlementEnabled = true
}
return service
}
func (s *Service) Check(ctx context.Context) Status {
status := Status{Serving: true}
status := Status{}
if s.database != nil {
status.DatabaseReady = s.database.Ping(ctx) == nil
}
status.Serving = status.DatabaseReady
status.SettlementEnabled = s.settlementEnabled
if status.DatabaseReady && s.settlementEnabled {
stats, err := s.settlement.Stats(ctx)
if err == nil {
status.SettlementStats = stats
status.SettlementReady = true
}
}
return status
}
+34 -1
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
@@ -22,9 +33,31 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
got := NewService(tc.db).Check(context.Background())
if !got.Serving || got.DatabaseReady != tc.ready {
if got.Serving != tc.ready || got.DatabaseReady != tc.ready {
t.Fatalf("unexpected status: %+v", got)
}
})
}
}
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)
}
}
+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)
}
}
+62
View File
@@ -0,0 +1,62 @@
package settlement
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"gl/domain/ledger"
)
type EnqueueRepository interface {
Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error)
Get(context.Context, string, string) (ledger.Settlement, error)
RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error)
}
func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
if settlementID == "" || actorID == "" || reason == "" {
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required")
}
now := time.Now().UTC()
if s.now != nil {
now = s.now().UTC()
}
return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now)
}
type Service struct {
repository EnqueueRepository
now func() time.Time
}
func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} }
func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) {
if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" {
return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required")
}
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err)
}
now := time.Now().UTC()
if s.now != nil {
now = s.now().UTC()
}
encoded := hex.EncodeToString(id)
record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now}
if err := record.Validate(); err != nil {
return ledger.Settlement{}, false, err
}
return s.repository.Enqueue(ctx, record)
}
func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) {
if settlementID == "" && idempotencyKey == "" {
return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required")
}
return s.repository.Get(ctx, settlementID, idempotencyKey)
}
+43
View File
@@ -0,0 +1,43 @@
package settlement
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
type serviceRepository struct {
retriedID, actor, reason string
}
func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) {
return ledger.Settlement{}, false, nil
}
func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) {
return ledger.Settlement{}, nil
}
func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) {
r.retriedID, r.actor, r.reason = id, actor, reason
return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil
}
func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) {
service := NewService(&serviceRepository{})
if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil {
t.Fatal("expected missing reason to be rejected")
}
}
func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) {
repository := &serviceRepository{}
service := NewService(repository)
got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved")
if err != nil {
t.Fatal(err)
}
if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" {
t.Fatalf("unexpected retry: %+v repo=%+v", got, repository)
}
}
+57
View File
@@ -0,0 +1,57 @@
package settlement
import (
"context"
"fmt"
"time"
"gl/domain/ledger"
)
type Repository interface {
ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error)
Save(context.Context, ledger.Settlement, string) error
}
type Submitter interface {
Submit(context.Context, ledger.Settlement) (string, error)
}
type Worker struct {
Repository Repository
Submitter Submitter
WorkerID string
MaxAttempts int
Now func() time.Time
}
func (w Worker) RunOnce(ctx context.Context, limit int) error {
if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 {
return fmt.Errorf("settlement worker is not configured")
}
now := time.Now().UTC()
if w.Now != nil {
now = w.Now().UTC()
}
records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now)
if err != nil {
return err
}
for _, record := range records {
record.Attempts++
hash, submitErr := w.Submitter.Submit(ctx, record)
if submitErr != nil {
record = record.Retry(now, w.MaxAttempts, submitErr)
} else {
// Horizon returns success only after the transaction is accepted into
// a ledger, so a successful response is already confirmation.
record.Status = ledger.SettlementConfirmed
record.TransactionHash = hash
record.LastError = ""
}
if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil {
return err
}
}
return nil
}
+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)
}
}
+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"]
+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
}
+2
View File
@@ -10,11 +10,13 @@ import (
"gl/application/explorer"
"gl/infrastructure/config"
"gl/infrastructure/observability"
"gl/infrastructure/postgres"
webadapter "gl/interface/web"
)
func main() {
observability.Configure("gl-dashboard", slog.LevelInfo)
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
flag.Parse()
+65 -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()
@@ -40,7 +45,21 @@ func main() {
}
repository := postgres.NewJournalRepository(database)
handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil))
settlementRepository := postgres.NewSettlementRepository(database)
settlementService := applicationsettlement.NewService(settlementRepository)
var healthService *health.Service
if cfg.Settlement.Enabled {
healthService = health.NewService(database, settlementRepository)
} else {
healthService = health.NewService(database)
}
handler := grpcadapter.NewHandler(healthService, applicationledger.NewService(repository, nil), settlementService).WithSettlementAdminToken(cfg.Settlement.AdminToken)
if cfg.Settlement.Enabled {
worker := applicationsettlement.Worker{Repository: settlementRepository, Submitter: kuknos.Submitter{Endpoint: cfg.Settlement.Endpoint}, WorkerID: cfg.Settlement.WorkerID, MaxAttempts: cfg.Settlement.MaxAttempts}
go runSettlementWorker(ctx, worker, cfg.Settlement.PollInterval, cfg.Settlement.BatchSize)
go runSettlementReconciliation(ctx, settlementRepository)
slog.Info("Kuknos settlement worker enabled", "endpoint", cfg.Settlement.Endpoint, "worker_id", cfg.Settlement.WorkerID)
}
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
serverConfig := grpcadapter.ServerConfig{
Host: cfg.GRPC.Host,
@@ -52,3 +71,48 @@ func main() {
os.Exit(1)
}
}
func runSettlementReconciliation(ctx context.Context, repository *postgres.SettlementRepository) {
run := func() {
stats, err := repository.ReconcileConfirmationEvidence(ctx, 10*time.Minute)
if err != nil {
slog.ErrorContext(ctx, "settlement reconciliation failed", "error", err)
return
}
if stats.Detected > 0 || stats.Resolved > 0 || stats.Open > 0 {
slog.WarnContext(ctx, "settlement reconciliation completed", "detected", stats.Detected, "resolved", stats.Resolved, "open", stats.Open)
}
}
run()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
func runSettlementWorker(ctx context.Context, worker applicationsettlement.Worker, interval time.Duration, batchSize int) {
if interval <= 0 {
interval = 5 * time.Second
}
if batchSize <= 0 {
batchSize = 20
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := worker.RunOnce(ctx, batchSize); err != nil {
slog.ErrorContext(ctx, "settlement worker cycle failed", "error", err)
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"context"
"flag"
"os"
"time"
"gl/infrastructure/config"
"gl/infrastructure/postgres"
)
func main() {
configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cfg, err := config.Load(*configPath)
if err != nil {
os.Exit(1)
}
database, err := postgres.Open(ctx, cfg.Database)
if err != nil {
os.Exit(1)
}
defer database.Close()
if err := database.Ping(ctx); err != nil {
os.Exit(1)
}
}
+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"
+67
View File
@@ -0,0 +1,67 @@
package ledger
import (
"fmt"
"time"
)
type SettlementStatus string
const (
SettlementPending SettlementStatus = "PENDING"
SettlementSubmitted SettlementStatus = "SUBMITTED"
SettlementConfirmed SettlementStatus = "CONFIRMED"
SettlementRetryable SettlementStatus = "RETRYABLE"
SettlementManualReview SettlementStatus = "MANUAL_REVIEW"
)
type Settlement struct {
ID string
SourceService string
SourceTxID string
IdempotencyKey string
Status SettlementStatus
Network string
TransactionHash string
Attempts int
AvailableAt time.Time
LastError string
SignedTransactionXDR string
}
type SettlementStats struct {
Pending int64
Retryable int64
ManualReview int64
OldestPendingSeconds int64
}
func (s Settlement) Validate() error {
if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" {
return fmt.Errorf("settlement identity fields are required")
}
if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview {
return fmt.Errorf("invalid settlement status %q", s.Status)
}
if s.Attempts < 0 || s.AvailableAt.IsZero() {
return fmt.Errorf("invalid settlement retry state")
}
return nil
}
func (s Settlement) Retry(now time.Time, maxAttempts int, err error) Settlement {
s.LastError = err.Error()
s.Status = SettlementRetryable
if maxAttempts > 0 && s.Attempts >= maxAttempts {
s.Status = SettlementManualReview
}
delay := time.Second
for i := 1; i < s.Attempts && delay < 15*time.Minute; i++ {
delay *= 2
}
if delay > 15*time.Minute {
delay = 15 * time.Minute
}
s.AvailableAt = now.UTC().Add(delay)
return s
}
+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
+561 -31
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/msg.proto
@@ -1441,11 +1441,17 @@ func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult {
}
type HealthResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"`
DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
state protoimpl.MessageState `protogen:"open.v1"`
Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"`
DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"`
SettlementEnabled bool `protobuf:"varint,3,opt,name=settlement_enabled,json=settlementEnabled,proto3" json:"settlement_enabled,omitempty"`
SettlementReady bool `protobuf:"varint,4,opt,name=settlement_ready,json=settlementReady,proto3" json:"settlement_ready,omitempty"`
SettlementPending int64 `protobuf:"varint,5,opt,name=settlement_pending,json=settlementPending,proto3" json:"settlement_pending,omitempty"`
SettlementRetryable int64 `protobuf:"varint,6,opt,name=settlement_retryable,json=settlementRetryable,proto3" json:"settlement_retryable,omitempty"`
SettlementManualReview int64 `protobuf:"varint,7,opt,name=settlement_manual_review,json=settlementManualReview,proto3" json:"settlement_manual_review,omitempty"`
OldestPendingSeconds int64 `protobuf:"varint,8,opt,name=oldest_pending_seconds,json=oldestPendingSeconds,proto3" json:"oldest_pending_seconds,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthResponse) Reset() {
@@ -1492,6 +1498,479 @@ func (x *HealthResponse) GetDatabaseReady() bool {
return false
}
func (x *HealthResponse) GetSettlementEnabled() bool {
if x != nil {
return x.SettlementEnabled
}
return false
}
func (x *HealthResponse) GetSettlementReady() bool {
if x != nil {
return x.SettlementReady
}
return false
}
func (x *HealthResponse) GetSettlementPending() int64 {
if x != nil {
return x.SettlementPending
}
return 0
}
func (x *HealthResponse) GetSettlementRetryable() int64 {
if x != nil {
return x.SettlementRetryable
}
return 0
}
func (x *HealthResponse) GetSettlementManualReview() int64 {
if x != nil {
return x.SettlementManualReview
}
return 0
}
func (x *HealthResponse) GetOldestPendingSeconds() int64 {
if x != nil {
return x.OldestPendingSeconds
}
return 0
}
type EnqueueSettlementRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"`
SourceTransactionId string `protobuf:"bytes,2,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"`
IdempotencyKey string `protobuf:"bytes,3,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
Network string `protobuf:"bytes,4,opt,name=network,proto3" json:"network,omitempty"`
// Signed transaction XDR. GL persists and submits this payload durably.
SignedTransactionXdr string `protobuf:"bytes,5,opt,name=signed_transaction_xdr,json=signedTransactionXdr,proto3" json:"signed_transaction_xdr,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EnqueueSettlementRequest) Reset() {
*x = EnqueueSettlementRequest{}
mi := &file_ledger_v1_msg_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EnqueueSettlementRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EnqueueSettlementRequest) ProtoMessage() {}
func (x *EnqueueSettlementRequest) ProtoReflect() protoreflect.Message {
mi := &file_ledger_v1_msg_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EnqueueSettlementRequest.ProtoReflect.Descriptor instead.
func (*EnqueueSettlementRequest) Descriptor() ([]byte, []int) {
return file_ledger_v1_msg_proto_rawDescGZIP(), []int{18}
}
func (x *EnqueueSettlementRequest) GetSourceService() string {
if x != nil {
return x.SourceService
}
return ""
}
func (x *EnqueueSettlementRequest) GetSourceTransactionId() string {
if x != nil {
return x.SourceTransactionId
}
return ""
}
func (x *EnqueueSettlementRequest) GetIdempotencyKey() string {
if x != nil {
return x.IdempotencyKey
}
return ""
}
func (x *EnqueueSettlementRequest) GetNetwork() string {
if x != nil {
return x.Network
}
return ""
}
func (x *EnqueueSettlementRequest) GetSignedTransactionXdr() string {
if x != nil {
return x.SignedTransactionXdr
}
return ""
}
type EnqueueSettlementResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
AlreadyExisted bool `protobuf:"varint,3,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EnqueueSettlementResponse) Reset() {
*x = EnqueueSettlementResponse{}
mi := &file_ledger_v1_msg_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EnqueueSettlementResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EnqueueSettlementResponse) ProtoMessage() {}
func (x *EnqueueSettlementResponse) ProtoReflect() protoreflect.Message {
mi := &file_ledger_v1_msg_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EnqueueSettlementResponse.ProtoReflect.Descriptor instead.
func (*EnqueueSettlementResponse) Descriptor() ([]byte, []int) {
return file_ledger_v1_msg_proto_rawDescGZIP(), []int{19}
}
func (x *EnqueueSettlementResponse) GetSettlementId() string {
if x != nil {
return x.SettlementId
}
return ""
}
func (x *EnqueueSettlementResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *EnqueueSettlementResponse) GetAlreadyExisted() bool {
if x != nil {
return x.AlreadyExisted
}
return false
}
type GetSettlementRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Lookup:
//
// *GetSettlementRequest_SettlementId
// *GetSettlementRequest_IdempotencyKey
Lookup isGetSettlementRequest_Lookup `protobuf_oneof:"lookup"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSettlementRequest) Reset() {
*x = GetSettlementRequest{}
mi := &file_ledger_v1_msg_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSettlementRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSettlementRequest) ProtoMessage() {}
func (x *GetSettlementRequest) ProtoReflect() protoreflect.Message {
mi := &file_ledger_v1_msg_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSettlementRequest.ProtoReflect.Descriptor instead.
func (*GetSettlementRequest) Descriptor() ([]byte, []int) {
return file_ledger_v1_msg_proto_rawDescGZIP(), []int{20}
}
func (x *GetSettlementRequest) GetLookup() isGetSettlementRequest_Lookup {
if x != nil {
return x.Lookup
}
return nil
}
func (x *GetSettlementRequest) GetSettlementId() string {
if x != nil {
if x, ok := x.Lookup.(*GetSettlementRequest_SettlementId); ok {
return x.SettlementId
}
}
return ""
}
func (x *GetSettlementRequest) GetIdempotencyKey() string {
if x != nil {
if x, ok := x.Lookup.(*GetSettlementRequest_IdempotencyKey); ok {
return x.IdempotencyKey
}
}
return ""
}
type isGetSettlementRequest_Lookup interface {
isGetSettlementRequest_Lookup()
}
type GetSettlementRequest_SettlementId struct {
SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3,oneof"`
}
type GetSettlementRequest_IdempotencyKey struct {
IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3,oneof"`
}
func (*GetSettlementRequest_SettlementId) isGetSettlementRequest_Lookup() {}
func (*GetSettlementRequest_IdempotencyKey) isGetSettlementRequest_Lookup() {}
type Settlement struct {
state protoimpl.MessageState `protogen:"open.v1"`
SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"`
SourceService string `protobuf:"bytes,2,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"`
SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"`
IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"`
Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"`
Network string `protobuf:"bytes,6,opt,name=network,proto3" json:"network,omitempty"`
TransactionHash string `protobuf:"bytes,7,opt,name=transaction_hash,json=transactionHash,proto3" json:"transaction_hash,omitempty"`
Attempts uint32 `protobuf:"varint,8,opt,name=attempts,proto3" json:"attempts,omitempty"`
LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Settlement) Reset() {
*x = Settlement{}
mi := &file_ledger_v1_msg_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Settlement) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Settlement) ProtoMessage() {}
func (x *Settlement) ProtoReflect() protoreflect.Message {
mi := &file_ledger_v1_msg_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Settlement.ProtoReflect.Descriptor instead.
func (*Settlement) Descriptor() ([]byte, []int) {
return file_ledger_v1_msg_proto_rawDescGZIP(), []int{21}
}
func (x *Settlement) GetSettlementId() string {
if x != nil {
return x.SettlementId
}
return ""
}
func (x *Settlement) GetSourceService() string {
if x != nil {
return x.SourceService
}
return ""
}
func (x *Settlement) GetSourceTransactionId() string {
if x != nil {
return x.SourceTransactionId
}
return ""
}
func (x *Settlement) GetIdempotencyKey() string {
if x != nil {
return x.IdempotencyKey
}
return ""
}
func (x *Settlement) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *Settlement) GetNetwork() string {
if x != nil {
return x.Network
}
return ""
}
func (x *Settlement) GetTransactionHash() string {
if x != nil {
return x.TransactionHash
}
return ""
}
func (x *Settlement) GetAttempts() uint32 {
if x != nil {
return x.Attempts
}
return 0
}
func (x *Settlement) GetLastError() string {
if x != nil {
return x.LastError
}
return ""
}
type RetrySettlementRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"`
ActorId string `protobuf:"bytes,2,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"`
Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RetrySettlementRequest) Reset() {
*x = RetrySettlementRequest{}
mi := &file_ledger_v1_msg_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RetrySettlementRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RetrySettlementRequest) ProtoMessage() {}
func (x *RetrySettlementRequest) ProtoReflect() protoreflect.Message {
mi := &file_ledger_v1_msg_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RetrySettlementRequest.ProtoReflect.Descriptor instead.
func (*RetrySettlementRequest) Descriptor() ([]byte, []int) {
return file_ledger_v1_msg_proto_rawDescGZIP(), []int{22}
}
func (x *RetrySettlementRequest) GetSettlementId() string {
if x != nil {
return x.SettlementId
}
return ""
}
func (x *RetrySettlementRequest) GetActorId() string {
if x != nil {
return x.ActorId
}
return ""
}
func (x *RetrySettlementRequest) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
type RetrySettlementResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Settlement *Settlement `protobuf:"bytes,1,opt,name=settlement,proto3" json:"settlement,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RetrySettlementResponse) Reset() {
*x = RetrySettlementResponse{}
mi := &file_ledger_v1_msg_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RetrySettlementResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RetrySettlementResponse) ProtoMessage() {}
func (x *RetrySettlementResponse) ProtoReflect() protoreflect.Message {
mi := &file_ledger_v1_msg_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RetrySettlementResponse.ProtoReflect.Descriptor instead.
func (*RetrySettlementResponse) Descriptor() ([]byte, []int) {
return file_ledger_v1_msg_proto_rawDescGZIP(), []int{23}
}
func (x *RetrySettlementResponse) GetSettlement() *Settlement {
if x != nil {
return x.Settlement
}
return nil
}
var File_ledger_v1_msg_proto protoreflect.FileDescriptor
const file_ledger_v1_msg_proto_rawDesc = "" +
@@ -1632,10 +2111,50 @@ const file_ledger_v1_msg_proto_rawDesc = "" +
"\ajournal\x18\x02 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" +
"\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" +
"\x16ReplayJournalsResponse\x128\n" +
"\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"Q\n" +
"\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"\xfd\x02\n" +
"\x0eHealthResponse\x12\x18\n" +
"\aserving\x18\x01 \x01(\bR\aserving\x12%\n" +
"\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady*\x9a\x02\n" +
"\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady\x12-\n" +
"\x12settlement_enabled\x18\x03 \x01(\bR\x11settlementEnabled\x12)\n" +
"\x10settlement_ready\x18\x04 \x01(\bR\x0fsettlementReady\x12-\n" +
"\x12settlement_pending\x18\x05 \x01(\x03R\x11settlementPending\x121\n" +
"\x14settlement_retryable\x18\x06 \x01(\x03R\x13settlementRetryable\x128\n" +
"\x18settlement_manual_review\x18\a \x01(\x03R\x16settlementManualReview\x124\n" +
"\x16oldest_pending_seconds\x18\b \x01(\x03R\x14oldestPendingSeconds\"\xee\x01\n" +
"\x18EnqueueSettlementRequest\x12%\n" +
"\x0esource_service\x18\x01 \x01(\tR\rsourceService\x122\n" +
"\x15source_transaction_id\x18\x02 \x01(\tR\x13sourceTransactionId\x12'\n" +
"\x0fidempotency_key\x18\x03 \x01(\tR\x0eidempotencyKey\x12\x18\n" +
"\anetwork\x18\x04 \x01(\tR\anetwork\x124\n" +
"\x16signed_transaction_xdr\x18\x05 \x01(\tR\x14signedTransactionXdr\"\x81\x01\n" +
"\x19EnqueueSettlementResponse\x12#\n" +
"\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12'\n" +
"\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"r\n" +
"\x14GetSettlementRequest\x12%\n" +
"\rsettlement_id\x18\x01 \x01(\tH\x00R\fsettlementId\x12)\n" +
"\x0fidempotency_key\x18\x02 \x01(\tH\x00R\x0eidempotencyKeyB\b\n" +
"\x06lookup\"\xcd\x02\n" +
"\n" +
"Settlement\x12#\n" +
"\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12%\n" +
"\x0esource_service\x18\x02 \x01(\tR\rsourceService\x122\n" +
"\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12'\n" +
"\x0fidempotency_key\x18\x04 \x01(\tR\x0eidempotencyKey\x12\x16\n" +
"\x06status\x18\x05 \x01(\tR\x06status\x12\x18\n" +
"\anetwork\x18\x06 \x01(\tR\anetwork\x12)\n" +
"\x10transaction_hash\x18\a \x01(\tR\x0ftransactionHash\x12\x1a\n" +
"\battempts\x18\b \x01(\rR\battempts\x12\x1d\n" +
"\n" +
"last_error\x18\t \x01(\tR\tlastError\"p\n" +
"\x16RetrySettlementRequest\x12#\n" +
"\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" +
"\bactor_id\x18\x02 \x01(\tR\aactorId\x12\x16\n" +
"\x06reason\x18\x03 \x01(\tR\x06reason\"P\n" +
"\x17RetrySettlementResponse\x125\n" +
"\n" +
"settlement\x18\x01 \x01(\v2\x15.ledger.v1.SettlementR\n" +
"settlement*\x9a\x02\n" +
"\fAccountClass\x12\x1d\n" +
"\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" +
"\x1cACCOUNT_CLASS_USER_AVAILABLE\x10\x01\x12\x1d\n" +
@@ -1669,7 +2188,7 @@ func file_ledger_v1_msg_proto_rawDescGZIP() []byte {
}
var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
var file_ledger_v1_msg_proto_goTypes = []any{
(AccountClass)(0), // 0: ledger.v1.AccountClass
(TransactionState)(0), // 1: ledger.v1.TransactionState
@@ -1691,47 +2210,54 @@ var file_ledger_v1_msg_proto_goTypes = []any{
(*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult
(*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse
(*HealthResponse)(nil), // 19: ledger.v1.HealthResponse
nil, // 20: ledger.v1.Journal.MetadataEntry
nil, // 21: ledger.v1.AppendJournalRequest.MetadataEntry
nil, // 22: ledger.v1.AppendTransactionEventRequest.MetadataEntry
(*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp
(*EnqueueSettlementRequest)(nil), // 20: ledger.v1.EnqueueSettlementRequest
(*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse
(*GetSettlementRequest)(nil), // 22: ledger.v1.GetSettlementRequest
(*Settlement)(nil), // 23: ledger.v1.Settlement
(*RetrySettlementRequest)(nil), // 24: ledger.v1.RetrySettlementRequest
(*RetrySettlementResponse)(nil), // 25: ledger.v1.RetrySettlementResponse
nil, // 26: ledger.v1.Journal.MetadataEntry
nil, // 27: ledger.v1.AppendJournalRequest.MetadataEntry
nil, // 28: ledger.v1.AppendTransactionEventRequest.MetadataEntry
(*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp
}
var file_ledger_v1_msg_proto_depIdxs = []int32{
0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass
2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference
3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry
23, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp
23, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp
29, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp
29, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp
4, // 5: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference
20, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry
26, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry
3, // 7: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry
23, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp
29, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp
4, // 9: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference
21, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry
27, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry
5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal
1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState
23, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp
29, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp
4, // 14: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference
22, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry
28, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry
8, // 16: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest
23, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp
29, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp
9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent
2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference
23, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp
23, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp
29, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp
29, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp
5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal
2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference
23, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp
29, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp
2, // 25: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference
23, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp
29, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp
6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest
5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal
17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult
30, // [30:30] is the sub-list for method output_type
30, // [30:30] is the sub-list for method input_type
30, // [30:30] is the sub-list for extension type_name
30, // [30:30] is the sub-list for extension extendee
0, // [0:30] is the sub-list for field type_name
23, // 30: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement
31, // [31:31] is the sub-list for method output_type
31, // [31:31] is the sub-list for method input_type
31, // [31:31] is the sub-list for extension type_name
31, // [31:31] is the sub-list for extension extendee
0, // [0:31] is the sub-list for field type_name
}
func init() { file_ledger_v1_msg_proto_init() }
@@ -1746,13 +2272,17 @@ func file_ledger_v1_msg_proto_init() {
(*GetJournalRequest_IdempotencyKey)(nil),
}
file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{}
file_ledger_v1_msg_proto_msgTypes[20].OneofWrappers = []any{
(*GetSettlementRequest_SettlementId)(nil),
(*GetSettlementRequest_IdempotencyKey)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)),
NumEnums: 2,
NumMessages: 21,
NumMessages: 27,
NumExtensions: 0,
NumServices: 0,
},
+34 -19
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,7 +25,7 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor
const file_ledger_v1_srv_proto_rawDesc = "" +
"\n" +
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xbe\x04\n" +
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xc1\x06\n" +
"\x14GeneralLedgerService\x123\n" +
"\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" +
"\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" +
@@ -35,7 +35,10 @@ const file_ledger_v1_srv_proto_rawDesc = "" +
"\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" +
"\n" +
"GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" +
"\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponseBy\n" +
"\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" +
"\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" +
"\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" +
"\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponseBy\n" +
"\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" +
"Ledger::V1b\x06proto3"
@@ -47,13 +50,19 @@ var file_ledger_v1_srv_proto_goTypes = []any{
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest
(*HealthResponse)(nil), // 7: ledger.v1.HealthResponse
(*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse
(*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse
(*Journal)(nil), // 10: ledger.v1.Journal
(*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse
(*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse
(*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse
(*EnqueueSettlementRequest)(nil), // 7: ledger.v1.EnqueueSettlementRequest
(*GetSettlementRequest)(nil), // 8: ledger.v1.GetSettlementRequest
(*RetrySettlementRequest)(nil), // 9: ledger.v1.RetrySettlementRequest
(*HealthResponse)(nil), // 10: ledger.v1.HealthResponse
(*AppendJournalResponse)(nil), // 11: ledger.v1.AppendJournalResponse
(*AppendTransactionEventResponse)(nil), // 12: ledger.v1.AppendTransactionEventResponse
(*Journal)(nil), // 13: ledger.v1.Journal
(*ListEntriesResponse)(nil), // 14: ledger.v1.ListEntriesResponse
(*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse
(*ReplayJournalsResponse)(nil), // 16: ledger.v1.ReplayJournalsResponse
(*EnqueueSettlementResponse)(nil), // 17: ledger.v1.EnqueueSettlementResponse
(*Settlement)(nil), // 18: ledger.v1.Settlement
(*RetrySettlementResponse)(nil), // 19: ledger.v1.RetrySettlementResponse
}
var file_ledger_v1_srv_proto_depIdxs = []int32{
0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
@@ -63,15 +72,21 @@ var file_ledger_v1_srv_proto_depIdxs = []int32{
4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
7, // [7:14] is the sub-list for method output_type
0, // [0:7] is the sub-list for method input_type
7, // 7: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
8, // 8: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
9, // 9: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
10, // 10: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
11, // 11: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
12, // 12: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
13, // 13: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
14, // 14: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
15, // 15: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
16, // 16: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
17, // 17: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse
18, // 18: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement
19, // 19: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse
10, // [10:20] is the sub-list for method output_type
0, // [0:10] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
+125 -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
@@ -27,6 +27,9 @@ const (
GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries"
GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance"
GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals"
GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement"
GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement"
GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement"
)
// GeneralLedgerServiceClient is the client API for GeneralLedgerService service.
@@ -40,6 +43,10 @@ type GeneralLedgerServiceClient interface {
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error)
ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error)
EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error)
GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error)
// Restricted operator command. Requires the configured x-admin-token metadata.
RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error)
}
type generalLedgerServiceClient struct {
@@ -120,6 +127,36 @@ func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *Rep
return out, nil
}
func (c *generalLedgerServiceClient) EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EnqueueSettlementResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_EnqueueSettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Settlement)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetSettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RetrySettlementResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_RetrySettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GeneralLedgerServiceServer is the server API for GeneralLedgerService service.
// All implementations should embed UnimplementedGeneralLedgerServiceServer
// for forward compatibility.
@@ -131,6 +168,10 @@ type GeneralLedgerServiceServer interface {
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error)
ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error)
EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error)
GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error)
// Restricted operator command. Requires the configured x-admin-token metadata.
RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error)
}
// UnimplementedGeneralLedgerServiceServer should be embedded to have
@@ -141,25 +182,34 @@ type GeneralLedgerServiceServer interface {
type UnimplementedGeneralLedgerServiceServer struct{}
func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AppendJournal not implemented")
return nil, status.Errorf(codes.Unimplemented, "method AppendJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AppendTransactionEvent not implemented")
return nil, status.Errorf(codes.Unimplemented, "method AppendTransactionEvent not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) {
return nil, status.Error(codes.Unimplemented, "method GetJournal not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GetJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListEntries not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetBalance not implemented")
return nil, status.Errorf(codes.Unimplemented, "method GetBalance not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ReplayJournals not implemented")
return nil, status.Errorf(codes.Unimplemented, "method ReplayJournals not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method EnqueueSettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetSettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {}
@@ -171,7 +221,7 @@ type UnsafeGeneralLedgerServiceServer interface {
}
func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) {
// If the following call panics, it indicates UnimplementedGeneralLedgerServiceServer was
// If the following call pancis, it indicates UnimplementedGeneralLedgerServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
@@ -307,6 +357,60 @@ func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_EnqueueSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EnqueueSettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_EnqueueSettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, req.(*EnqueueSettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetSettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, req.(*GetSettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RetrySettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_RetrySettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, req.(*RetrySettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
// GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -342,6 +446,18 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ReplayJournals",
Handler: _GeneralLedgerService_ReplayJournals_Handler,
},
{
MethodName: "EnqueueSettlement",
Handler: _GeneralLedgerService_EnqueueSettlement_Handler,
},
{
MethodName: "GetSettlement",
Handler: _GeneralLedgerService_GetSettlement_Handler,
},
{
MethodName: "RetrySettlement",
Handler: _GeneralLedgerService_RetrySettlement_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "ledger/v1/srv.proto",
+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 = ""
+4 -1
View File
@@ -40,4 +40,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
)
tool github.com/a-h/templ
tool (
github.com/a-h/templ
github.com/a-h/templ/cmd/templ
)
+47 -12
View File
@@ -3,6 +3,8 @@ package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/knadh/koanf/parsers/toml"
@@ -11,21 +13,22 @@ import (
)
type Config struct {
Environment string `koanf:"environment"`
GRPC GRPCConfig `koanf:"grpc"`
Database DatabaseConfig `koanf:"database"`
Environment string `koanf:"environment"`
GRPC GRPCConfig `koanf:"grpc"`
Database DatabaseConfig `koanf:"db"`
Settlement SettlementConfig `koanf:"settlement"`
}
type DashboardConfig struct {
Environment string `koanf:"environment"`
HTTP HTTPConfig `koanf:"http"`
Database DatabaseConfig `koanf:"database"`
Database DatabaseConfig `koanf:"db"`
}
type GRPCConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
ShutdownTimeout time.Duration `koanf:"timeout"`
}
type HTTPConfig struct {
@@ -36,12 +39,23 @@ type HTTPConfig struct {
}
type DatabaseConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
Name string `koanf:"name"`
User string `koanf:"user"`
Password string `koanf:"password"`
SSLMode string `koanf:"ssl-mode"`
Host string `koanf:"host"`
Port int `koanf:"port"`
Name string `koanf:"name"`
User string `koanf:"user"`
Password string `koanf:"password"`
GormLogLevel int `koanf:"gorm-log-level"`
SSLMode string `koanf:"-"`
}
type SettlementConfig struct {
Enabled bool `koanf:"enabled"`
Endpoint string `koanf:"endpoint"`
WorkerID string `koanf:"worker-id"`
PollInterval time.Duration `koanf:"poll-interval"`
BatchSize int `koanf:"batch-size"`
MaxAttempts int `koanf:"max-attempts"`
AdminToken string `koanf:"admin-token"`
}
func Load(path string) (*Config, error) {
@@ -52,12 +66,14 @@ func Load(path string) (*Config, error) {
Port: 8600,
ShutdownTimeout: 10 * time.Second,
},
Database: defaultDatabaseConfig(),
Database: defaultDatabaseConfig(),
Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8},
}
if err := load(path, cfg); err != nil {
return nil, err
}
applyEnvironment(&cfg.Database, &cfg.Settlement)
if err := cfg.Validate(); err != nil {
return nil, err
}
@@ -78,12 +94,28 @@ func LoadDashboard(path string) (*DashboardConfig, error) {
if err := load(path, cfg); err != nil {
return nil, err
}
applyEnvironment(&cfg.Database, nil)
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) {
if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok {
database.Password = value
}
if settlement == nil {
return
}
if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok {
settlement.Endpoint = strings.TrimRight(value, "/")
}
if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok {
settlement.AdminToken = value
}
}
func load(path string, target any) error {
k := koanf.New(".")
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
@@ -115,6 +147,9 @@ func (c *Config) Validate() error {
if c.GRPC.ShutdownTimeout <= 0 {
return fmt.Errorf("grpc shutdown timeout must be positive")
}
if c.Environment == "production" && c.Settlement.AdminToken == "" {
return fmt.Errorf("settlement admin token is required in production")
}
return validateDatabase(c.Database)
}
+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")
}
}
+55
View File
@@ -0,0 +1,55 @@
package kuknos
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"gl/domain/ledger"
)
// Submitter sends a pre-signed Stellar/Kuknos transaction to Horizon. Signing
// remains outside GL; GL owns durable admission, retry, and reconciliation.
type Submitter struct {
Endpoint string
Client *http.Client
}
func (s Submitter) Submit(ctx context.Context, record ledger.Settlement) (string, error) {
if s.Endpoint == "" || record.SignedTransactionXDR == "" {
return "", fmt.Errorf("kuknos submission endpoint and signed xdr are required")
}
form := url.Values{"tx": {record.SignedTransactionXDR}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.Endpoint, "/")+"/transactions", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := s.Client
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var body struct {
Hash string `json:"hash"`
Extras struct {
ResultCodes struct {
Transaction string `json:"transaction"`
} `json:"result_codes"`
} `json:"extras"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return "", fmt.Errorf("decode kuknos response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.Hash == "" {
return "", fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, body.Extras.ResultCodes.Transaction)
}
return body.Hash, nil
}
+30
View File
@@ -0,0 +1,30 @@
package observability
import (
"log/slog"
"os"
)
// Configure installs the process-wide JSON logger used by every GL adapter.
func Configure(service string, level slog.Leveler) *slog.Logger {
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case slog.TimeKey:
attr.Key = "timestamp"
case slog.LevelKey:
attr.Key = "severity"
case slog.MessageKey:
attr.Key = "message"
case slog.SourceKey:
attr.Key = "source"
}
return attr
},
})
logger := slog.New(handler).With("service", service)
slog.SetDefault(logger)
return logger
}
+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...)
}
@@ -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,204 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gl/domain/ledger"
)
type SettlementRepository struct{ database Database }
type ReconciliationStats struct {
Open, Detected, Resolved int64
}
type txQueryer interface {
Query(context.Context, string, ...any) (Rows, error)
}
func NewSettlementRepository(database Database) *SettlementRepository {
return &SettlementRepository{database: database}
}
func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) {
var stats ledger.SettlementStats
err := r.database.QueryRow(ctx, `SELECT
COUNT(*) FILTER (WHERE status='PENDING'),
COUNT(*) FILTER (WHERE status='RETRYABLE'),
COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'),
COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0)
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds)
if err != nil {
return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err)
}
return stats, nil
}
func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) {
if err := s.Validate(); err != nil {
return ledger.Settlement{}, false, err
}
var inserted string
err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements
(id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`,
s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted)
if err == nil {
return s, false, nil
}
if err == pgx.ErrNoRows {
var existingXDR string
if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil {
return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err)
}
if existingXDR != s.SignedTransactionXDR {
return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict
}
existing, err := r.Get(ctx, inserted, "")
return existing, true, err
}
return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err)
}
func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) {
var s ledger.Settlement
err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err)
}
return s, nil
}
func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil {
return nil, fmt.Errorf("release stale settlement locks: %w", err)
}
queryer, ok := tx.(txQueryer)
if !ok {
return nil, fmt.Errorf("settlement transaction does not support row queries")
}
rows, err := queryer.Query(ctx, `WITH due AS (
SELECT id FROM kuknos_settlements
WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by=''
ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp()
FROM due WHERE s.id=due.id
RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ledger.Settlement
for rows.Next() {
var s ledger.Settlement
if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil {
return nil, err
}
result = append(result, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error {
_, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID)
return err
}
func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err)
}
defer tx.Rollback(ctx)
var previous ledger.SettlementStatus
if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err)
}
if previous == ledger.SettlementConfirmed {
return ledger.Settlement{}, fmt.Errorf("confirmed settlement cannot be retried")
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil {
return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err)
}
return r.Get(ctx, id, "")
}
// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements
// whose latest GL lifecycle evidence is missing or has a different hash.
func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) {
if grace <= 0 {
grace = 10 * time.Minute
}
tx, err := r.database.Begin(ctx)
if err != nil {
return ReconciliationStats{}, err
}
defer tx.Rollback(ctx)
var detected int64
err = tx.QueryRow(ctx, `WITH inconsistent AS (
SELECT s.id,
CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type,
s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash
FROM kuknos_settlements s
LEFT JOIN LATERAL (
SELECT id, blockchain_transaction_hash FROM transaction_events
WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id
ORDER BY event_version DESC, recorded_at DESC LIMIT 1
) e ON true
WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval
AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash)
), upserted AS (
INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash)
SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent
ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type,
expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash,
status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL
WHERE kuknos_reconciliation_issues.status<>'OPEN'
OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type
OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash
OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash
RETURNING 1
) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected)
if err != nil {
return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err)
}
result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp()
WHERE status='OPEN' AND EXISTS (
SELECT 1 FROM kuknos_settlements s JOIN transaction_events e
ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id
WHERE s.id=i.settlement_id AND s.status='CONFIRMED'
AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>''
)`)
if err != nil {
return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err)
}
var open int64
if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil {
return ReconciliationStats{}, err
}
if err := tx.Commit(ctx); err != nil {
return ReconciliationStats{}, err
}
return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil
}
@@ -0,0 +1,41 @@
package postgres
import (
"context"
"testing"
"time"
)
func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) {
tx := &fakeTx{rows: []Row{
fakeRow{values: []any{int64(1)}},
fakeRow{values: []any{int64(2)}},
}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
stats, err := repository.ReconcileConfirmationEvidence(context.Background(), 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if stats.Detected != 1 || stats.Resolved != 1 || stats.Open != 2 {
t.Fatalf("unexpected reconciliation stats: %+v", stats)
}
if !tx.committed || tx.execCount != 1 {
t.Fatalf("unexpected transaction state: %+v", tx)
}
}
func TestReconcileConfirmationEvidenceRollsBackResolutionFailure(t *testing.T) {
tx := &fakeTx{
rows: []Row{fakeRow{values: []any{int64(1)}}},
execErrAt: 1,
}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.ReconcileConfirmationEvidence(context.Background(), time.Minute); err == nil {
t.Fatal("expected resolution error")
}
if !tx.rolledBack || tx.committed {
t.Fatalf("failed reconciliation was not rolled back: %+v", tx)
}
}
+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
}
+1 -1
View File
@@ -13,7 +13,7 @@ func TestHealth(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !response.Serving || response.DatabaseReady {
if response.Serving || response.DatabaseReady {
t.Fatalf("unexpected response: %+v", response)
}
}
+41 -1
View File
@@ -4,13 +4,17 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"runtime/debug"
"time"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
)
type ServerConfig struct {
@@ -30,7 +34,7 @@ func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerSe
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
defer listener.Close()
server := grpc.NewServer()
server := grpc.NewServer(grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
reflection.Register(server)
@@ -67,3 +71,39 @@ func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listene
}
return nil
}
func structuredUnaryLogger() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
started := time.Now()
response, err := handler(ctx, req)
code := status.Code(err)
level := slog.LevelInfo
if code != codes.OK {
level = slog.LevelError
}
slog.Log(ctx, level, "grpc call finished",
"component", "grpc_server",
"grpc_method", info.FullMethod,
"grpc_code", code.String(),
"duration_ms", float64(time.Since(started).Microseconds())/1000,
)
return response, err
}
}
func panicRecovery() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
defer func() {
if recovered := recover(); recovered != nil {
slog.ErrorContext(ctx, "grpc panic recovered",
"component", "grpc_server",
"grpc_method", info.FullMethod,
"panic", recovered,
"stack", string(debug.Stack()),
)
err = status.Error(codes.Internal, "internal server error")
}
}()
return handler(ctx, req)
}
}
+60
View File
@@ -0,0 +1,60 @@
package grpcadapter
import (
"context"
"crypto/subtle"
"gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.EnqueueSettlementRequest) (*ledgerv1.EnqueueSettlementResponse, error) {
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil
}
func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySettlementRequest) (*ledgerv1.RetrySettlementResponse, error) {
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token")
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 {
return nil, status.Error(codes.PermissionDenied, "invalid administrative token")
}
record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
if err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil
}
func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement {
return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError}
}
func (h *Handler) GetSettlement(ctx context.Context, request *ledgerv1.GetSettlementRequest) (*ledgerv1.Settlement, error) {
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
var id, key string
switch lookup := request.GetLookup().(type) {
case *ledgerv1.GetSettlementRequest_SettlementId:
id = lookup.SettlementId
case *ledgerv1.GetSettlementRequest_IdempotencyKey:
key = lookup.IdempotencyKey
}
record, err := h.settlement.Get(ctx, id, key)
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
return settlementMessage(record), nil
}
-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"