Compare commits
7 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| 12c478cf3a | |||
| d5c9b338a4 | |||
| b14c5e2696 | |||
| 3f5fd860bf | |||
| 9b6c438cc7 | |||
| dedb9281eb | |||
| 83720ba9d1 |
@@ -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
|
||||||
@@ -28,6 +28,9 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# macOS metadata
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
# ---> JupyterNotebooks
|
# ---> JupyterNotebooks
|
||||||
# gitignore template for Jupyter Notebooks
|
# gitignore template for Jupyter Notebooks
|
||||||
# website: http://jupyter.org/
|
# 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
|
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
|
.tmp-go-cache
|
||||||
@@ -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
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
# General Ledger service design
|
# 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
|
## Purpose and ownership
|
||||||
|
|
||||||
`GL` is Darano's durable financial journal. It records every committed wallet
|
`GL` is Darano's primary financial source of truth. It records every wallet
|
||||||
value movement independently of Stellar so balances and transaction history can
|
value movement as an append-only journal. Kuknos, through its Stellar-compatible
|
||||||
be reconstructed during a blockchain or provider outage.
|
interface, is the secondary settlement and verification source of truth.
|
||||||
|
|
||||||
GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does
|
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
|
not write GL tables directly, and GL does not write wallet tables. In normal
|
||||||
operation Wallet remains the transaction orchestrator and Stellar remains the
|
operation a transaction requires both GL and Kuknos. If GL is unhealthy, all
|
||||||
external settlement network. Promoting GL from a mirror to an operational
|
value-changing transaction admission and processing halt until it recovers. If
|
||||||
fallback is an explicit, audited mode change; an outage must never make a failed
|
Kuknos is unavailable, an authorized operator may explicitly enable GL-only
|
||||||
blockchain operation appear successful automatically.
|
operation through an AdminPanel toggle or configuration. Kuknos must never be
|
||||||
|
disabled automatically.
|
||||||
|
|
||||||
## Non-negotiable invariants
|
## Non-negotiable invariants
|
||||||
|
|
||||||
@@ -114,13 +115,20 @@ does not hold a wallet database transaction open.
|
|||||||
|
|
||||||
## Operating modes and failure semantics
|
## Operating modes and failure semantics
|
||||||
|
|
||||||
- `MIRROR`: normal mode. Wallet follows its existing settlement policy and GL
|
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
|
||||||
asynchronously records every committed effect.
|
only after its required GL journal and Kuknos settlement evidence exist.
|
||||||
- `DEGRADED_LEDGER`: explicitly enabled by an authorized operator. Eligible
|
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
|
||||||
internal operations may settle against GL while blockchain-bound operations
|
through AdminPanel or configuration. GL remains mandatory and authoritative;
|
||||||
remain pending. The initial implementation does not activate this mode.
|
eligible transactions may proceed without Kuknos. Every mode transition must
|
||||||
- `RECONCILE`: outbound posting is paused or restricted while tooling compares
|
be immutable, attributable, time-bounded where configured, and emitted via
|
||||||
Wallet, GL, and blockchain state and appends approved reversals/corrections.
|
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
|
GL rejects unbalanced journals, invalid precision, unknown account/asset
|
||||||
combinations, duplicate line numbers, missing source identity, conflicting
|
combinations, duplicate line numbers, missing source identity, conflicting
|
||||||
@@ -155,7 +163,7 @@ are adapter concerns.
|
|||||||
|
|
||||||
## Initial non-goals
|
## Initial non-goals
|
||||||
|
|
||||||
- Replacing Stellar automatically on health-check failure.
|
- Disabling Kuknos automatically on health-check failure.
|
||||||
- Editing or deleting posted journals.
|
- Editing or deleting posted journals.
|
||||||
- Storing binary floats or using Wallet's mutable transaction table as GL.
|
- Storing binary floats or using Wallet's mutable transaction table as GL.
|
||||||
- Sharing a database schema between Wallet and GL.
|
- Sharing a database schema between Wallet and GL.
|
||||||
|
|||||||
@@ -1,29 +1,56 @@
|
|||||||
// Package health provides the service readiness use case.
|
// Package health provides the service readiness use case.
|
||||||
package health
|
package health
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
type Database interface {
|
type Database interface {
|
||||||
Ping(context.Context) error
|
Ping(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettlementMonitor interface {
|
||||||
|
Stats(context.Context) (ledger.SettlementStats, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Serving bool
|
Serving bool
|
||||||
DatabaseReady bool
|
DatabaseReady bool
|
||||||
|
SettlementEnabled bool
|
||||||
|
SettlementReady bool
|
||||||
|
SettlementStats ledger.SettlementStats
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
database Database
|
database Database
|
||||||
|
settlement SettlementMonitor
|
||||||
|
settlementEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(database Database) *Service {
|
func NewService(database Database, settlement ...SettlementMonitor) *Service {
|
||||||
return &Service{database: database}
|
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 {
|
func (s *Service) Check(ctx context.Context) Status {
|
||||||
status := Status{Serving: true}
|
status := Status{}
|
||||||
if s.database != nil {
|
if s.database != nil {
|
||||||
status.DatabaseReady = s.database.Ping(ctx) == 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
|
return status
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,23 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type databaseStub struct{ err error }
|
type databaseStub struct{ err error }
|
||||||
|
|
||||||
func (s databaseStub) Ping(context.Context) error { return s.err }
|
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) {
|
func TestCheckReportsDatabaseReadiness(t *testing.T) {
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -22,9 +33,31 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
got := NewService(tc.db).Check(context.Background())
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -15,7 +15,9 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||||
--mount=type=cache,target=/root/.cache/go-build,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
|
FROM alpine:3.24.1
|
||||||
|
|
||||||
@@ -26,11 +28,14 @@ RUN apk add --no-cache ca-certificates \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /out/gl /app/gl
|
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 gl.cfg.toml /app/gl.cfg.toml
|
||||||
|
COPY dashboard.cfg.toml /app/dashboard.cfg.toml
|
||||||
|
|
||||||
USER darano
|
USER darano
|
||||||
|
|
||||||
EXPOSE 8600
|
EXPOSE 8600 8601
|
||||||
|
|
||||||
ENTRYPOINT ["/app/gl"]
|
ENTRYPOINT ["/app/gl"]
|
||||||
CMD ["-conf", "/app/gl.cfg.toml"]
|
CMD ["-conf", "/app/gl.cfg.toml"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -10,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) {
|
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.WriteHeader(http.StatusOK)
|
||||||
_, _ = response.Write([]byte("ok"))
|
_, _ = response.Write([]byte("ok"))
|
||||||
}))
|
}))
|
||||||
@@ -49,7 +50,7 @@ func TestPercentileUsesNearestRank(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunDrainsInflightRequestAfterDuration(t *testing.T) {
|
func TestRunDrainsInflightRequestAfterDuration(t *testing.T) {
|
||||||
var canceled atomic.Bool
|
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)
|
time.Sleep(20 * time.Millisecond)
|
||||||
if request.Context().Err() != nil {
|
if request.Context().Err() != nil {
|
||||||
canceled.Store(true)
|
canceled.Store(true)
|
||||||
@@ -66,3 +67,14 @@ func TestRunDrainsInflightRequestAfterDuration(t *testing.T) {
|
|||||||
t.Fatalf("in-flight request was not drained cleanly: %+v", result)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
|
|
||||||
"gl/application/explorer"
|
"gl/application/explorer"
|
||||||
"gl/infrastructure/config"
|
"gl/infrastructure/config"
|
||||||
|
"gl/infrastructure/observability"
|
||||||
"gl/infrastructure/postgres"
|
"gl/infrastructure/postgres"
|
||||||
webadapter "gl/interface/web"
|
webadapter "gl/interface/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
observability.Configure("gl-dashboard", slog.LevelInfo)
|
||||||
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
|
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
|||||||
+65
-1
@@ -7,15 +7,20 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gl/application/health"
|
"gl/application/health"
|
||||||
applicationledger "gl/application/ledger"
|
applicationledger "gl/application/ledger"
|
||||||
|
applicationsettlement "gl/application/settlement"
|
||||||
"gl/infrastructure/config"
|
"gl/infrastructure/config"
|
||||||
|
"gl/infrastructure/kuknos"
|
||||||
|
"gl/infrastructure/observability"
|
||||||
"gl/infrastructure/postgres"
|
"gl/infrastructure/postgres"
|
||||||
grpcadapter "gl/interface/grpc"
|
grpcadapter "gl/interface/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
observability.Configure("gl", slog.LevelInfo)
|
||||||
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
|
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -40,7 +45,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repository := postgres.NewJournalRepository(database)
|
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)
|
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
|
||||||
serverConfig := grpcadapter.ServerConfig{
|
serverConfig := grpcadapter.ServerConfig{
|
||||||
Host: cfg.GRPC.Host,
|
Host: cfg.GRPC.Host,
|
||||||
@@ -52,3 +71,48 @@ func main() {
|
|||||||
os.Exit(1)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -2,14 +2,14 @@ environment = "local"
|
|||||||
|
|
||||||
[http]
|
[http]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 8080
|
port = 8601
|
||||||
read-header-timeout = "5s"
|
read-header-timeout = "5s"
|
||||||
shutdown-timeout = "10s"
|
shutdown-timeout = "10s"
|
||||||
|
|
||||||
[database]
|
[db]
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 5432
|
port = 5432
|
||||||
name = "gl_db"
|
name = "gl_db"
|
||||||
user = "postgres"
|
user = "postgres"
|
||||||
password = "postgres"
|
password = ""
|
||||||
ssl-mode = "disable"
|
ssl-mode = "disable"
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.6
|
||||||
// protoc (unknown)
|
// protoc (unknown)
|
||||||
// source: base/v1/msg.proto
|
// source: base/v1/msg.proto
|
||||||
|
|
||||||
|
|||||||
+561
-31
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.6
|
||||||
// protoc (unknown)
|
// protoc (unknown)
|
||||||
// source: ledger/v1/msg.proto
|
// source: ledger/v1/msg.proto
|
||||||
|
|
||||||
@@ -1441,11 +1441,17 @@ func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HealthResponse struct {
|
type HealthResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"`
|
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"`
|
DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
SettlementEnabled bool `protobuf:"varint,3,opt,name=settlement_enabled,json=settlementEnabled,proto3" json:"settlement_enabled,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
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() {
|
func (x *HealthResponse) Reset() {
|
||||||
@@ -1492,6 +1498,479 @@ func (x *HealthResponse) GetDatabaseReady() bool {
|
|||||||
return false
|
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
|
var File_ledger_v1_msg_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_ledger_v1_msg_proto_rawDesc = "" +
|
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" +
|
"\ajournal\x18\x02 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" +
|
||||||
"\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" +
|
"\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" +
|
||||||
"\x16ReplayJournalsResponse\x128\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" +
|
"\x0eHealthResponse\x12\x18\n" +
|
||||||
"\aserving\x18\x01 \x01(\bR\aserving\x12%\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" +
|
"\fAccountClass\x12\x1d\n" +
|
||||||
"\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" +
|
"\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" +
|
||||||
"\x1cACCOUNT_CLASS_USER_AVAILABLE\x10\x01\x12\x1d\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_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{
|
var file_ledger_v1_msg_proto_goTypes = []any{
|
||||||
(AccountClass)(0), // 0: ledger.v1.AccountClass
|
(AccountClass)(0), // 0: ledger.v1.AccountClass
|
||||||
(TransactionState)(0), // 1: ledger.v1.TransactionState
|
(TransactionState)(0), // 1: ledger.v1.TransactionState
|
||||||
@@ -1691,47 +2210,54 @@ var file_ledger_v1_msg_proto_goTypes = []any{
|
|||||||
(*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult
|
(*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult
|
||||||
(*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse
|
(*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse
|
||||||
(*HealthResponse)(nil), // 19: ledger.v1.HealthResponse
|
(*HealthResponse)(nil), // 19: ledger.v1.HealthResponse
|
||||||
nil, // 20: ledger.v1.Journal.MetadataEntry
|
(*EnqueueSettlementRequest)(nil), // 20: ledger.v1.EnqueueSettlementRequest
|
||||||
nil, // 21: ledger.v1.AppendJournalRequest.MetadataEntry
|
(*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse
|
||||||
nil, // 22: ledger.v1.AppendTransactionEventRequest.MetadataEntry
|
(*GetSettlementRequest)(nil), // 22: ledger.v1.GetSettlementRequest
|
||||||
(*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp
|
(*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{
|
var file_ledger_v1_msg_proto_depIdxs = []int32{
|
||||||
0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass
|
0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass
|
||||||
2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference
|
2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference
|
||||||
3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry
|
3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry
|
||||||
23, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp
|
29, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp
|
||||||
23, // 4: ledger.v1.Journal.recorded_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
|
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
|
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
|
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
|
5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal
|
||||||
1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState
|
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
|
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
|
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
|
9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent
|
||||||
2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference
|
2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference
|
||||||
23, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp
|
29, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp
|
||||||
23, // 21: ledger.v1.ListEntriesRequest.recorded_to: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
|
5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal
|
||||||
2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference
|
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
|
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
|
6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest
|
||||||
5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal
|
5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal
|
||||||
17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult
|
17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult
|
||||||
30, // [30:30] is the sub-list for method output_type
|
23, // 30: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement
|
||||||
30, // [30:30] is the sub-list for method input_type
|
31, // [31:31] is the sub-list for method output_type
|
||||||
30, // [30:30] is the sub-list for extension type_name
|
31, // [31:31] is the sub-list for method input_type
|
||||||
30, // [30:30] is the sub-list for extension extendee
|
31, // [31:31] is the sub-list for extension type_name
|
||||||
0, // [0:30] is the sub-list for field 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() }
|
func init() { file_ledger_v1_msg_proto_init() }
|
||||||
@@ -1746,13 +2272,17 @@ func file_ledger_v1_msg_proto_init() {
|
|||||||
(*GetJournalRequest_IdempotencyKey)(nil),
|
(*GetJournalRequest_IdempotencyKey)(nil),
|
||||||
}
|
}
|
||||||
file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{}
|
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{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)),
|
||||||
NumEnums: 2,
|
NumEnums: 2,
|
||||||
NumMessages: 21,
|
NumMessages: 27,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
+34
-19
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.6
|
||||||
// protoc (unknown)
|
// protoc (unknown)
|
||||||
// source: ledger/v1/srv.proto
|
// source: ledger/v1/srv.proto
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
const file_ledger_v1_srv_proto_rawDesc = "" +
|
const file_ledger_v1_srv_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\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" +
|
"\x14GeneralLedgerService\x123\n" +
|
||||||
"\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\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" +
|
"\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" +
|
"\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\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" +
|
"\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"
|
"Ledger::V1b\x06proto3"
|
||||||
|
|
||||||
@@ -47,13 +50,19 @@ var file_ledger_v1_srv_proto_goTypes = []any{
|
|||||||
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest
|
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest
|
||||||
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest
|
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest
|
||||||
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest
|
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest
|
||||||
(*HealthResponse)(nil), // 7: ledger.v1.HealthResponse
|
(*EnqueueSettlementRequest)(nil), // 7: ledger.v1.EnqueueSettlementRequest
|
||||||
(*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse
|
(*GetSettlementRequest)(nil), // 8: ledger.v1.GetSettlementRequest
|
||||||
(*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse
|
(*RetrySettlementRequest)(nil), // 9: ledger.v1.RetrySettlementRequest
|
||||||
(*Journal)(nil), // 10: ledger.v1.Journal
|
(*HealthResponse)(nil), // 10: ledger.v1.HealthResponse
|
||||||
(*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse
|
(*AppendJournalResponse)(nil), // 11: ledger.v1.AppendJournalResponse
|
||||||
(*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse
|
(*AppendTransactionEventResponse)(nil), // 12: ledger.v1.AppendTransactionEventResponse
|
||||||
(*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse
|
(*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{
|
var file_ledger_v1_srv_proto_depIdxs = []int32{
|
||||||
0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
|
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
|
4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
|
||||||
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
||||||
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
||||||
7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
|
7, // 7: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
|
||||||
8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
|
8, // 8: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
|
||||||
9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
|
9, // 9: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
|
||||||
10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
|
10, // 10: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
|
||||||
11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
|
11, // 11: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||||
12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
|
12, // 12: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
|
||||||
13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
|
13, // 13: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
|
||||||
7, // [7:14] is the sub-list for method output_type
|
14, // 14: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
|
||||||
0, // [0:7] is the sub-list for method input_type
|
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 type_name
|
||||||
0, // [0:0] is the sub-list for extension extendee
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
0, // [0:0] is the sub-list for field type_name
|
0, // [0:0] is the sub-list for field type_name
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ledger/v1/srv.proto
|
// source: ledger/v1/srv.proto
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ const (
|
|||||||
GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries"
|
GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries"
|
||||||
GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance"
|
GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance"
|
||||||
GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals"
|
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.
|
// 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)
|
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
||||||
GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error)
|
GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error)
|
||||||
ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, 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 {
|
type generalLedgerServiceClient struct {
|
||||||
@@ -120,6 +127,36 @@ func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *Rep
|
|||||||
return out, nil
|
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.
|
// GeneralLedgerServiceServer is the server API for GeneralLedgerService service.
|
||||||
// All implementations should embed UnimplementedGeneralLedgerServiceServer
|
// All implementations should embed UnimplementedGeneralLedgerServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -131,6 +168,10 @@ type GeneralLedgerServiceServer interface {
|
|||||||
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
||||||
GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error)
|
GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error)
|
||||||
ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, 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
|
// UnimplementedGeneralLedgerServiceServer should be embedded to have
|
||||||
@@ -141,25 +182,34 @@ type GeneralLedgerServiceServer interface {
|
|||||||
type UnimplementedGeneralLedgerServiceServer struct{}
|
type UnimplementedGeneralLedgerServiceServer struct{}
|
||||||
|
|
||||||
func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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) {
|
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() {}
|
func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -171,7 +221,7 @@ type UnsafeGeneralLedgerServiceServer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) {
|
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
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
// unimplemented method is ever invoked, so we test this at initialization
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
// time to prevent it from happening at runtime later due to I/O.
|
// 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)
|
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.
|
// GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -342,6 +446,18 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ReplayJournals",
|
MethodName: "ReplayJournals",
|
||||||
Handler: _GeneralLedgerService_ReplayJournals_Handler,
|
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{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "ledger/v1/srv.proto",
|
Metadata: "ledger/v1/srv.proto",
|
||||||
|
|||||||
+12
-3
@@ -3,12 +3,21 @@ environment = "local"
|
|||||||
[grpc]
|
[grpc]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 8600
|
port = 8600
|
||||||
shutdown-timeout = "10s"
|
timeout = "10s"
|
||||||
|
|
||||||
[database]
|
[db]
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
port = 5432
|
port = 5432
|
||||||
name = "gl_db"
|
name = "gl_db"
|
||||||
user = "postgres"
|
user = "postgres"
|
||||||
password = "postgres"
|
password = ""
|
||||||
ssl-mode = "disable"
|
ssl-mode = "disable"
|
||||||
|
|
||||||
|
[settlement]
|
||||||
|
enabled = false
|
||||||
|
endpoint = ""
|
||||||
|
worker-id = "gl-local"
|
||||||
|
poll-interval = "5s"
|
||||||
|
batch-size = 20
|
||||||
|
max-attempts = 8
|
||||||
|
admin-token = ""
|
||||||
|
|||||||
@@ -40,4 +40,7 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
tool github.com/a-h/templ
|
tool (
|
||||||
|
github.com/a-h/templ
|
||||||
|
github.com/a-h/templ/cmd/templ
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/knadh/koanf/parsers/toml"
|
"github.com/knadh/koanf/parsers/toml"
|
||||||
@@ -11,21 +13,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Environment string `koanf:"environment"`
|
Environment string `koanf:"environment"`
|
||||||
GRPC GRPCConfig `koanf:"grpc"`
|
GRPC GRPCConfig `koanf:"grpc"`
|
||||||
Database DatabaseConfig `koanf:"database"`
|
Database DatabaseConfig `koanf:"db"`
|
||||||
|
Settlement SettlementConfig `koanf:"settlement"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardConfig struct {
|
type DashboardConfig struct {
|
||||||
Environment string `koanf:"environment"`
|
Environment string `koanf:"environment"`
|
||||||
HTTP HTTPConfig `koanf:"http"`
|
HTTP HTTPConfig `koanf:"http"`
|
||||||
Database DatabaseConfig `koanf:"database"`
|
Database DatabaseConfig `koanf:"db"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GRPCConfig struct {
|
type GRPCConfig struct {
|
||||||
Host string `koanf:"host"`
|
Host string `koanf:"host"`
|
||||||
Port int `koanf:"port"`
|
Port int `koanf:"port"`
|
||||||
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
|
ShutdownTimeout time.Duration `koanf:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HTTPConfig struct {
|
type HTTPConfig struct {
|
||||||
@@ -36,12 +39,23 @@ type HTTPConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Host string `koanf:"host"`
|
Host string `koanf:"host"`
|
||||||
Port int `koanf:"port"`
|
Port int `koanf:"port"`
|
||||||
Name string `koanf:"name"`
|
Name string `koanf:"name"`
|
||||||
User string `koanf:"user"`
|
User string `koanf:"user"`
|
||||||
Password string `koanf:"password"`
|
Password string `koanf:"password"`
|
||||||
SSLMode string `koanf:"ssl-mode"`
|
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) {
|
func Load(path string) (*Config, error) {
|
||||||
@@ -52,12 +66,14 @@ func Load(path string) (*Config, error) {
|
|||||||
Port: 8600,
|
Port: 8600,
|
||||||
ShutdownTimeout: 10 * time.Second,
|
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 {
|
if err := load(path, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
applyEnvironment(&cfg.Database, &cfg.Settlement)
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -78,12 +94,28 @@ func LoadDashboard(path string) (*DashboardConfig, error) {
|
|||||||
if err := load(path, cfg); err != nil {
|
if err := load(path, cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
applyEnvironment(&cfg.Database, nil)
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
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 {
|
func load(path string, target any) error {
|
||||||
k := koanf.New(".")
|
k := koanf.New(".")
|
||||||
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
|
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 {
|
if c.GRPC.ShutdownTimeout <= 0 {
|
||||||
return fmt.Errorf("grpc shutdown timeout must be positive")
|
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)
|
return validateDatabase(c.Database)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "gl.toml")
|
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 {
|
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
|||||||
func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "dashboard.toml")
|
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 {
|
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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)
|
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)
|
t.Fatalf("unexpected database defaults: %+v", cfg.Database)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
|||||||
func TestLoadRejectsInvalidConfiguration(t *testing.T) {
|
func TestLoadRejectsInvalidConfiguration(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "gl.toml")
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,3 +64,46 @@ func TestLoadReturnsMissingFileError(t *testing.T) {
|
|||||||
t.Fatal("expected missing file error")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -98,3 +98,7 @@ type txAdapter struct {
|
|||||||
func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row {
|
func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row {
|
||||||
return t.Tx.QueryRow(ctx, sql, args...)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,18 +5,27 @@ import (
|
|||||||
|
|
||||||
"gl/application/health"
|
"gl/application/health"
|
||||||
applicationledger "gl/application/ledger"
|
applicationledger "gl/application/ledger"
|
||||||
|
applicationsettlement "gl/application/settlement"
|
||||||
basev1 "gl/gen/base/v1"
|
basev1 "gl/gen/base/v1"
|
||||||
ledgerv1 "gl/gen/ledger/v1"
|
ledgerv1 "gl/gen/ledger/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
ledgerv1.UnimplementedGeneralLedgerServiceServer
|
ledgerv1.UnimplementedGeneralLedgerServiceServer
|
||||||
health *health.Service
|
health *health.Service
|
||||||
ledger *applicationledger.Service
|
ledger *applicationledger.Service
|
||||||
|
settlement *applicationsettlement.Service
|
||||||
|
adminToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service) *Handler {
|
func (h *Handler) WithSettlementAdminToken(token string) *Handler { h.adminToken = token; return h }
|
||||||
return &Handler{health: healthService, ledger: ledgerService}
|
|
||||||
|
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 {
|
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) {
|
func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) {
|
||||||
result := h.health.Check(ctx)
|
result := h.health.Check(ctx)
|
||||||
return &ledgerv1.HealthResponse{
|
return &ledgerv1.HealthResponse{
|
||||||
Serving: result.Serving,
|
Serving: result.Serving,
|
||||||
DatabaseReady: result.DatabaseReady,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func TestHealth(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !response.Serving || response.DatabaseReady {
|
if response.Serving || response.DatabaseReady {
|
||||||
t.Fatalf("unexpected response: %+v", response)
|
t.Fatalf("unexpected response: %+v", response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime/debug"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ledgerv1 "gl/gen/ledger/v1"
|
ledgerv1 "gl/gen/ledger/v1"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/reflection"
|
"google.golang.org/grpc/reflection"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerConfig struct {
|
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 {
|
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
|
||||||
defer listener.Close()
|
defer listener.Close()
|
||||||
|
|
||||||
server := grpc.NewServer()
|
server := grpc.NewServer(grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
|
||||||
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
|
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
|
||||||
reflection.Register(server)
|
reflection.Register(server)
|
||||||
|
|
||||||
@@ -67,3 +71,39 @@ func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listene
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user