Compare commits
14 Commits
dedb9281eb
...
stage
| Author | SHA256 | Date | |
|---|---|---|---|
| d33ec51201 | |||
| 88e227f80c | |||
| 01d13dbbf2 | |||
| 4ae5bb615b | |||
| 837fe5bc5f | |||
| 495a5884df | |||
| 810448b6e7 | |||
| f7366a1985 | |||
| 3445ead897 | |||
| 12c478cf3a | |||
| d5c9b338a4 | |||
| b14c5e2696 | |||
| 3f5fd860bf | |||
| 9b6c438cc7 |
@@ -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
|
||||
@@ -23,7 +23,7 @@ runs:
|
||||
set -euo pipefail
|
||||
|
||||
docker buildx build \
|
||||
-f build/Dockerfile \
|
||||
-f deployment/Dockerfile \
|
||||
--build-arg "GO_PROXY=https://go.reg.darano.ir" \
|
||||
--load \
|
||||
-t "$IMAGE:$BRANCH" \
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Git
|
||||
uses: https://git.darano.ir/actions/checkout@v5
|
||||
uses: https://git.darano.ir/actions/checkout@v6.0.3
|
||||
with:
|
||||
token: ${{ gitea.token }}
|
||||
path: ./
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Git
|
||||
uses: https://git.darano.ir/actions/checkout@v5
|
||||
uses: https://git.darano.ir/actions/checkout@v6.0.3
|
||||
with:
|
||||
token: ${{ gitea.token }}
|
||||
path: ./
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Git
|
||||
uses: https://git.darano.ir/actions/checkout@v5
|
||||
uses: https://git.darano.ir/actions/checkout@v6.0.3
|
||||
with:
|
||||
token: ${{ gitea.token }}
|
||||
path: ./
|
||||
|
||||
@@ -246,3 +246,4 @@ cython_debug/
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
.tmp-go-cache
|
||||
@@ -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,6 +1,6 @@
|
||||
# General Ledger service design
|
||||
|
||||
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28.
|
||||
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28; standalone GL authority API implemented on 2026-10-05.
|
||||
|
||||
## Purpose and ownership
|
||||
|
||||
@@ -30,9 +30,10 @@ disabled automatically.
|
||||
5. Account, asset, source transaction, correlation, actor, occurrence time, and
|
||||
recording time are retained on every applicable record.
|
||||
6. A journal commits atomically with all its entries or not at all.
|
||||
7. A financial effect is journaled only when Wallet has committed that effect.
|
||||
7. In normal Wallet ingestion, a financial effect is journaled only when Wallet has committed that effect.
|
||||
Pending and failed lifecycle events may be retained as audit events but do
|
||||
not create monetary entries.
|
||||
not create monetary entries. For standalone GL-only execution, the GL journal
|
||||
commit itself is the financial effect; any Wallet copy is a projection.
|
||||
8. Derived balances are rebuildable solely by ordering and summing immutable
|
||||
entries. Cached balances are disposable projections, never source data.
|
||||
|
||||
@@ -115,6 +116,37 @@ does not hold a wallet database transaction open.
|
||||
|
||||
## Operating modes and failure semantics
|
||||
|
||||
GL now exposes its own persisted `NORMAL` / `GL_ONLY` operating state through
|
||||
`GetOperatingMode` and the authenticated `SetOperatingMode` RPC. This is
|
||||
distinct from Wallet's existing `KUKNOS_DISABLED` configuration, which still
|
||||
makes Wallet read-only until its callers are integrated with this API.
|
||||
|
||||
In `GL_ONLY`, `PostInternalJournal` executes balanced internal effects
|
||||
synchronously. PostgreSQL locks user accounts and checks available/frozen
|
||||
balances in the same commit; conflicting spends cannot both succeed. All
|
||||
journal append paths enforce the GL-only funds rules; ordinary ingestion rejects
|
||||
new effects in this mode, so internal execution cannot be bypassed. Blockchain
|
||||
references
|
||||
and external blockchain account movements are forbidden, new settlement
|
||||
requests are rejected, and the worker stops claiming existing settlements.
|
||||
The mode survives restarts and changes have immutable actor/reason/revision
|
||||
audits and OpenTelemetry spans. Switching drains posting transactions and
|
||||
rejects in-flight settlement claims or negative user balances. Returning to
|
||||
`NORMAL` requires explicit operator reconciliation confirmation.
|
||||
|
||||
The ledger must be complete before switching: restore/import opening balances
|
||||
and drain committed Wallet outbox effects and in-flight Wallet processing.
|
||||
GL-only mode keeps ledger reads/writes available while reporting settlement as
|
||||
paused; existing normal-mode Wallet clients therefore fail closed rather than
|
||||
continuing blockchain-dependent processing. Internal executions must not also
|
||||
be delivered through that outbox as new monetary effects. User authorization,
|
||||
asset eligibility, fees, and business counterparty mapping remain admission
|
||||
responsibilities. Actual external payment settlement cannot occur internally.
|
||||
See [the authority API](deployment/AUTHORITY_API.md) and
|
||||
[standalone operation](deployment/STANDALONE.md).
|
||||
|
||||
The broader Wallet availability policy remains:
|
||||
|
||||
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
|
||||
only after its required GL journal and Kuknos settlement evidence exist.
|
||||
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
|
||||
@@ -145,6 +177,9 @@ The first `ledger.v1.GeneralLedgerService` contract provides:
|
||||
- `AppendTransactionEvent` for non-monetary lifecycle history;
|
||||
- `GetJournal` and `ListEntries` for audit and replay inspection;
|
||||
- `GetBalance` for a rebuildable account/asset projection;
|
||||
- `GetUserBalances` for available, frozen, and total balances per user asset;
|
||||
- `GetOperatingMode` / `SetOperatingMode` for attributable operator cutover;
|
||||
- `PostInternalJournal` for atomic internal execution in `GL_ONLY`;
|
||||
- `Health` for readiness and database health.
|
||||
|
||||
Wallet depends on its own `Ledger` port. Its gRPC implementation translates
|
||||
|
||||
@@ -6,6 +6,11 @@ blockchain or provider is unavailable.
|
||||
|
||||
The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md).
|
||||
|
||||
For an independent PostgreSQL + ledger API + dashboard deployment with no
|
||||
blockchain network access, see [Standalone General Ledger](deployment/STANDALONE.md).
|
||||
The [authority API](deployment/AUTHORITY_API.md) provides an authenticated,
|
||||
persisted GL-only toggle, internal journal execution, and per-user balances.
|
||||
|
||||
Generate protobufs, test, and build with:
|
||||
|
||||
```bash
|
||||
@@ -39,6 +44,21 @@ theme toggle that initially follows the operating-system preference.
|
||||
The gRPC service owns schema migrations. The dashboard is read-only, uses its
|
||||
own `dashboard.cfg.toml`, and does not run migrations.
|
||||
|
||||
AdminPanel settlement review supports `IgnoreSettlement` through the authenticated
|
||||
GL admin connection. Operators with `ledgerops.retry_kuknossettlement` can review an
|
||||
unlocked `MANUAL_REVIEW` or `RETRYABLE` settlement and record a reason to close it.
|
||||
GL atomically sets `IGNORED`, records the actor/reason/status transition in
|
||||
`kuknos_settlement_operator_audits`, and resolves linked open reconciliation issues.
|
||||
The original error and signed payload remain available as evidence. Ignored
|
||||
settlements are terminal, cannot be retried, and leave the attention queue.
|
||||
Dismissal does not confirm a payment or reverse funds; IRT withdrawal reservations
|
||||
still require financial reconciliation.
|
||||
|
||||
Deploy the updated GL service first so its startup applies migration
|
||||
`000007_ignore_settlement.up.sql` and exposes the RPC, then deploy Wallet and
|
||||
AdminPanel. An older GL service rejects the command; AdminPanel preserves the
|
||||
review form and shows the error rather than reporting a successful dismissal.
|
||||
|
||||
Run the dashboard load test against a started dashboard with:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -13,10 +13,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
recentJournalLimit = 12
|
||||
transactionPageSize = 20
|
||||
topAssetLimit = 5
|
||||
topHoldersPerAsset = 5
|
||||
recentJournalLimit = 12
|
||||
recentSettlementLimit = 12
|
||||
transactionPageSize = 20
|
||||
topAssetLimit = 5
|
||||
topHoldersPerAsset = 5
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
@@ -26,6 +27,25 @@ type Stats struct {
|
||||
LastRecordedAt time.Time
|
||||
}
|
||||
|
||||
type SettlementStats struct {
|
||||
Pending int64
|
||||
Retryable int64
|
||||
Confirmed int64
|
||||
ManualReview int64
|
||||
}
|
||||
|
||||
type Settlement struct {
|
||||
ID string
|
||||
SourceService string
|
||||
SourceTransactionID string
|
||||
Status ledger.SettlementStatus
|
||||
Network string
|
||||
TransactionHash string
|
||||
Attempts int
|
||||
LastError string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Holder struct {
|
||||
Rank int64
|
||||
OwnerType string
|
||||
@@ -47,6 +67,7 @@ type Repository interface {
|
||||
GetByID(context.Context, string) (ledger.Journal, error)
|
||||
Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error)
|
||||
TopHolders(context.Context, int, int) ([]Holder, error)
|
||||
SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -54,8 +75,10 @@ type Service struct {
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Stats Stats
|
||||
Journals []ledger.Journal
|
||||
Stats Stats
|
||||
Journals []ledger.Journal
|
||||
SettlementStats SettlementStats
|
||||
Settlements []Settlement
|
||||
}
|
||||
|
||||
type Assets struct {
|
||||
@@ -100,7 +123,11 @@ func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) {
|
||||
if err != nil {
|
||||
return Dashboard{}, fmt.Errorf("read recent journals: %w", err)
|
||||
}
|
||||
return Dashboard{Stats: stats, Journals: journals}, nil
|
||||
settlementStats, settlements, err := s.repository.SettlementDashboard(ctx, recentSettlementLimit)
|
||||
if err != nil {
|
||||
return Dashboard{}, fmt.Errorf("read settlement dashboard: %w", err)
|
||||
}
|
||||
return Dashboard{Stats: stats, Journals: journals, SettlementStats: settlementStats, Settlements: settlements}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Assets(ctx context.Context) (Assets, error) {
|
||||
|
||||
@@ -10,14 +10,16 @@ import (
|
||||
)
|
||||
|
||||
type repositoryStub struct {
|
||||
stats Stats
|
||||
journals []ledger.Journal
|
||||
transactions []ledger.Journal
|
||||
journal ledger.Journal
|
||||
journalErr error
|
||||
holders []Holder
|
||||
balance ledger.Amount
|
||||
lastFilter *ledger.JournalFilter
|
||||
stats Stats
|
||||
journals []ledger.Journal
|
||||
transactions []ledger.Journal
|
||||
journal ledger.Journal
|
||||
journalErr error
|
||||
holders []Holder
|
||||
balance ledger.Amount
|
||||
settlementStats SettlementStats
|
||||
settlements []Settlement
|
||||
lastFilter *ledger.JournalFilter
|
||||
}
|
||||
|
||||
func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil }
|
||||
@@ -39,6 +41,23 @@ func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.T
|
||||
func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) {
|
||||
return r.holders, nil
|
||||
}
|
||||
func (r repositoryStub) SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) {
|
||||
return r.settlementStats, r.settlements, nil
|
||||
}
|
||||
|
||||
func TestDashboardIncludesBlockchainSettlements(t *testing.T) {
|
||||
service := NewService(repositoryStub{
|
||||
settlementStats: SettlementStats{Confirmed: 7, Pending: 2},
|
||||
settlements: []Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, TransactionHash: "chain-hash"}},
|
||||
})
|
||||
result, err := service.Dashboard(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.SettlementStats.Confirmed != 7 || len(result.Settlements) != 1 || result.Settlements[0].TransactionHash != "chain-hash" {
|
||||
t.Fatalf("unexpected settlement dashboard: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsIncludesTopHolders(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("125.5")
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
// Package health provides the service readiness use case.
|
||||
package health
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type Database interface {
|
||||
Ping(context.Context) error
|
||||
}
|
||||
|
||||
type SettlementMonitor interface {
|
||||
Stats(context.Context) (ledger.SettlementStats, error)
|
||||
}
|
||||
|
||||
type SettlementAuthority interface {
|
||||
SettlementAllowed(context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Serving bool
|
||||
DatabaseReady bool
|
||||
Serving bool
|
||||
DatabaseReady bool
|
||||
SettlementEnabled bool
|
||||
SettlementReady bool
|
||||
SettlementStats ledger.SettlementStats
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
database Database
|
||||
database Database
|
||||
settlement SettlementMonitor
|
||||
settlementEnabled bool
|
||||
}
|
||||
|
||||
func NewService(database Database) *Service {
|
||||
return &Service{database: database}
|
||||
func NewService(database Database, settlement ...SettlementMonitor) *Service {
|
||||
service := &Service{database: database}
|
||||
if len(settlement) > 0 && settlement[0] != nil {
|
||||
service.settlement = settlement[0]
|
||||
service.settlementEnabled = true
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *Service) Check(ctx context.Context) Status {
|
||||
@@ -26,5 +48,17 @@ func (s *Service) Check(ctx context.Context) Status {
|
||||
status.DatabaseReady = s.database.Ping(ctx) == nil
|
||||
}
|
||||
status.Serving = status.DatabaseReady
|
||||
status.SettlementEnabled = s.settlementEnabled
|
||||
if status.DatabaseReady && s.settlementEnabled {
|
||||
stats, err := s.settlement.Stats(ctx)
|
||||
if err == nil {
|
||||
status.SettlementStats = stats
|
||||
status.SettlementReady = true
|
||||
if authority, ok := s.settlement.(SettlementAuthority); ok {
|
||||
allowed, authorityErr := authority.SettlementAllowed(ctx)
|
||||
status.SettlementReady = authorityErr == nil && allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -4,12 +4,23 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type databaseStub struct{ err error }
|
||||
|
||||
func (s databaseStub) Ping(context.Context) error { return s.err }
|
||||
|
||||
type settlementStub struct {
|
||||
stats ledger.SettlementStats
|
||||
err error
|
||||
}
|
||||
|
||||
func (s settlementStub) Stats(context.Context) (ledger.SettlementStats, error) {
|
||||
return s.stats, s.err
|
||||
}
|
||||
|
||||
func TestCheckReportsDatabaseReadiness(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
@@ -28,3 +39,37 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsSettlementBacklog(t *testing.T) {
|
||||
monitor := settlementStub{stats: ledger.SettlementStats{Pending: 2, Retryable: 3, OldestPendingSeconds: 60}}
|
||||
got := NewService(databaseStub{}, monitor).Check(context.Background())
|
||||
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || !got.SettlementReady || got.SettlementStats != monitor.stats {
|
||||
t.Fatalf("unexpected status: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckExposesManualReviewWithoutStoppingSettlementQueue(t *testing.T) {
|
||||
got := NewService(databaseStub{}, settlementStub{stats: ledger.SettlementStats{ManualReview: 1}}).Check(context.Background())
|
||||
if !got.Serving || !got.SettlementReady || got.SettlementStats.ManualReview != 1 {
|
||||
t.Fatalf("unexpected status: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsSettlementQueryFailure(t *testing.T) {
|
||||
got := NewService(databaseStub{}, settlementStub{err: errors.New("query failed")}).Check(context.Background())
|
||||
if !got.Serving || got.SettlementReady {
|
||||
t.Fatalf("unexpected status: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type pausedSettlementStub struct{ settlementStub }
|
||||
|
||||
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
|
||||
|
||||
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
|
||||
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
|
||||
got := NewService(databaseStub{}, monitor).Check(context.Background())
|
||||
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
|
||||
t.Fatalf("unexpected GL-only readiness: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
domain "gl/domain/ledger"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
type AuthorityRepository interface {
|
||||
OperatingMode(context.Context) (domain.OperatingState, error)
|
||||
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
|
||||
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
|
||||
}
|
||||
|
||||
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return domain.OperatingState{}, domain.ErrModeRequired
|
||||
}
|
||||
return repository.OperatingMode(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
|
||||
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
|
||||
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
|
||||
}
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return domain.OperatingState{}, domain.ErrModeRequired
|
||||
}
|
||||
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
|
||||
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, "mode change failed")
|
||||
return domain.OperatingState{}, err
|
||||
}
|
||||
if state.Revision != revision {
|
||||
span.AddEvent("ledger.operating_mode_changed")
|
||||
}
|
||||
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
|
||||
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
|
||||
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
|
||||
}
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
|
||||
}
|
||||
|
||||
// PostInternalJournal executes exact balanced entries without network settlement.
|
||||
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
|
||||
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
if strings.TrimSpace(command.ActorID) == "" {
|
||||
return AppendJournalResult{}, invalid("actor id is required for internal execution")
|
||||
}
|
||||
if command.Blockchain != (domain.BlockchainReference{}) {
|
||||
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
|
||||
}
|
||||
for _, entry := range command.Entries {
|
||||
if entry.Account.Class == domain.AccountClassExternalBlockchain {
|
||||
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
|
||||
}
|
||||
}
|
||||
command.Metadata = cloneMetadata(command.Metadata)
|
||||
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
|
||||
return s.appendJournal(ctx, command)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
domain "gl/domain/ledger"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*AppendJournalCommand)
|
||||
}{
|
||||
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
|
||||
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
|
||||
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repository := newRepositoryStub()
|
||||
command := validAppendCommand()
|
||||
command.ActorID = "operator"
|
||||
tc.change(&command)
|
||||
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
|
||||
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
|
||||
t.Fatalf("invalid internal execution reached repository: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
|
||||
repository := newRepositoryStub()
|
||||
service := NewService(repository, fixedID("test"))
|
||||
command := validAppendCommand()
|
||||
command.ActorID = "operator"
|
||||
command.Metadata = map[string]string{"correlation": "value"}
|
||||
result, err := service.PostInternalJournal(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
|
||||
t.Fatal("internal provenance missing")
|
||||
}
|
||||
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
|
||||
t.Fatal("caller metadata mutated")
|
||||
}
|
||||
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
|
||||
_, err = service.AppendJournal(context.Background(), command)
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("ordinary API accepted internal marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorityValidatesInputs(t *testing.T) {
|
||||
service := NewService(newRepositoryStub(), nil)
|
||||
for _, owner := range []string{"", " "} {
|
||||
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid owner: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid mode: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ type AppendJournalResult struct {
|
||||
}
|
||||
|
||||
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
|
||||
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
|
||||
}
|
||||
return s.appendJournal(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) appendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
journalID, err := s.newID()
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
|
||||
|
||||
@@ -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,72 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type EnqueueRepository interface {
|
||||
Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error)
|
||||
Get(context.Context, string, string) (ledger.Settlement, error)
|
||||
RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error)
|
||||
IgnoreByOperator(context.Context, string, string, string) (ledger.Settlement, error)
|
||||
}
|
||||
|
||||
func (s *Service) IgnoreByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
|
||||
settlementID, actorID, reason = strings.TrimSpace(settlementID), strings.TrimSpace(actorID), strings.TrimSpace(reason)
|
||||
if settlementID == "" || actorID == "" || reason == "" || len([]rune(reason)) > 500 {
|
||||
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and a reason of at most 500 characters are required")
|
||||
}
|
||||
return s.repository.IgnoreByOperator(ctx, settlementID, actorID, reason)
|
||||
}
|
||||
|
||||
func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
|
||||
if settlementID == "" || actorID == "" || reason == "" {
|
||||
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.now != nil {
|
||||
now = s.now().UTC()
|
||||
}
|
||||
return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository EnqueueRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} }
|
||||
|
||||
func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) {
|
||||
if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" {
|
||||
return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required")
|
||||
}
|
||||
id := make([]byte, 16)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.now != nil {
|
||||
now = s.now().UTC()
|
||||
}
|
||||
encoded := hex.EncodeToString(id)
|
||||
record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now}
|
||||
if err := record.Validate(); err != nil {
|
||||
return ledger.Settlement{}, false, err
|
||||
}
|
||||
return s.repository.Enqueue(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) {
|
||||
if settlementID == "" && idempotencyKey == "" {
|
||||
return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required")
|
||||
}
|
||||
return s.repository.Get(ctx, settlementID, idempotencyKey)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type serviceRepository struct {
|
||||
retriedID, actor, reason string
|
||||
}
|
||||
|
||||
func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) {
|
||||
return ledger.Settlement{}, false, nil
|
||||
}
|
||||
func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) {
|
||||
return ledger.Settlement{}, nil
|
||||
}
|
||||
func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) {
|
||||
r.retriedID, r.actor, r.reason = id, actor, reason
|
||||
return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil
|
||||
}
|
||||
|
||||
func (r *serviceRepository) IgnoreByOperator(_ context.Context, id, actor, reason string) (ledger.Settlement, error) {
|
||||
r.retriedID, r.actor, r.reason = id, actor, reason
|
||||
return ledger.Settlement{ID: id, Status: ledger.SettlementIgnored}, nil
|
||||
}
|
||||
|
||||
func TestIgnoreByOperatorRequiresReviewedAuditIdentity(t *testing.T) {
|
||||
repository := &serviceRepository{}
|
||||
service := NewService(repository)
|
||||
for _, reason := range []string{"", " "} {
|
||||
if _, err := service.IgnoreByOperator(context.Background(), "id", "actor", reason); err == nil {
|
||||
t.Fatal("expected blank review reason to be rejected")
|
||||
}
|
||||
}
|
||||
if repository.retriedID != "" {
|
||||
t.Fatal("invalid dismissal reached repository")
|
||||
}
|
||||
got, err := service.IgnoreByOperator(context.Background(), "id", "actor-7", " cannot complete ")
|
||||
if err != nil || got.Status != ledger.SettlementIgnored || repository.actor != "actor-7" || repository.reason != "cannot complete" {
|
||||
t.Fatalf("unexpected dismissal: %+v repo=%+v err=%v", got, repository, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) {
|
||||
service := NewService(&serviceRepository{})
|
||||
if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil {
|
||||
t.Fatal("expected missing reason to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) {
|
||||
repository := &serviceRepository{}
|
||||
service := NewService(repository)
|
||||
got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" {
|
||||
t.Fatalf("unexpected retry: %+v repo=%+v", got, repository)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package settlement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error)
|
||||
Save(context.Context, ledger.Settlement, string) error
|
||||
}
|
||||
|
||||
type Submitter interface {
|
||||
Submit(context.Context, ledger.Settlement) (string, error)
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
Repository Repository
|
||||
Submitter Submitter
|
||||
WorkerID string
|
||||
MaxAttempts int
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (w Worker) RunOnce(ctx context.Context, limit int) error {
|
||||
if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 {
|
||||
return fmt.Errorf("settlement worker is not configured")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if w.Now != nil {
|
||||
now = w.Now().UTC()
|
||||
}
|
||||
records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
record.Attempts++
|
||||
hash, submitErr := w.Submitter.Submit(ctx, record)
|
||||
if submitErr != nil {
|
||||
var permanent ledger.PermanentSubmissionError
|
||||
if errors.As(submitErr, &permanent) {
|
||||
record.Status = ledger.SettlementManualReview
|
||||
record.LastError = submitErr.Error()
|
||||
} else {
|
||||
record = record.Retry(now, w.MaxAttempts, submitErr)
|
||||
}
|
||||
} else {
|
||||
// Horizon returns success only after the transaction is accepted into
|
||||
// a ledger, so a successful response is already confirmation.
|
||||
record.Status = ledger.SettlementConfirmed
|
||||
record.TransactionHash = hash
|
||||
record.LastError = ""
|
||||
}
|
||||
if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
|
||||
response.WriteHeader(http.StatusOK)
|
||||
_, _ = response.Write([]byte("ok"))
|
||||
}))
|
||||
@@ -49,7 +50,7 @@ func TestPercentileUsesNearestRank(t *testing.T) {
|
||||
|
||||
func TestRunDrainsInflightRequestAfterDuration(t *testing.T) {
|
||||
var canceled atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if request.Context().Err() != nil {
|
||||
canceled.Store(true)
|
||||
@@ -66,3 +67,14 @@ func TestRunDrainsInflightRequestAfterDuration(t *testing.T) {
|
||||
t.Fatalf("in-flight request was not drained cleanly: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func newIPv4TestServer(handler http.Handler) *httptest.Server {
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
server := httptest.NewUnstartedServer(handler)
|
||||
server.Listener = listener
|
||||
server.Start()
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ import (
|
||||
|
||||
"gl/application/explorer"
|
||||
"gl/infrastructure/config"
|
||||
"gl/infrastructure/observability"
|
||||
"gl/infrastructure/postgres"
|
||||
webadapter "gl/interface/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
observability.Configure("gl-dashboard", slog.LevelInfo)
|
||||
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
|
||||
flag.Parse()
|
||||
|
||||
@@ -23,6 +25,8 @@ func main() {
|
||||
slog.Error("load dashboard configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl-dashboard", cfg.Environment)
|
||||
defer shutdownTelemetry(context.Background())
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
+67
-1
@@ -7,15 +7,20 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gl/application/health"
|
||||
applicationledger "gl/application/ledger"
|
||||
applicationsettlement "gl/application/settlement"
|
||||
"gl/infrastructure/config"
|
||||
"gl/infrastructure/kuknos"
|
||||
"gl/infrastructure/observability"
|
||||
"gl/infrastructure/postgres"
|
||||
grpcadapter "gl/interface/grpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
observability.Configure("gl", slog.LevelInfo)
|
||||
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
|
||||
flag.Parse()
|
||||
|
||||
@@ -24,6 +29,8 @@ func main() {
|
||||
slog.Error("load configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl", cfg.Environment)
|
||||
defer shutdownTelemetry(context.Background())
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -40,7 +47,21 @@ func main() {
|
||||
}
|
||||
|
||||
repository := postgres.NewJournalRepository(database)
|
||||
handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil))
|
||||
settlementRepository := postgres.NewSettlementRepository(database)
|
||||
settlementService := applicationsettlement.NewService(settlementRepository)
|
||||
var healthService *health.Service
|
||||
if cfg.Settlement.Enabled {
|
||||
healthService = health.NewService(database, settlementRepository)
|
||||
} else {
|
||||
healthService = health.NewService(database)
|
||||
}
|
||||
handler := grpcadapter.NewHandler(healthService, applicationledger.NewService(repository, nil), settlementService).WithSettlementAdminToken(cfg.Settlement.AdminToken)
|
||||
if cfg.Settlement.Enabled {
|
||||
worker := applicationsettlement.Worker{Repository: settlementRepository, Submitter: kuknos.Submitter{Endpoint: cfg.Settlement.Endpoint}, WorkerID: cfg.Settlement.WorkerID, MaxAttempts: cfg.Settlement.MaxAttempts}
|
||||
go runSettlementWorker(ctx, worker, cfg.Settlement.PollInterval, cfg.Settlement.BatchSize)
|
||||
go runSettlementReconciliation(ctx, settlementRepository)
|
||||
slog.Info("Kuknos settlement worker enabled", "endpoint", cfg.Settlement.Endpoint, "worker_id", cfg.Settlement.WorkerID)
|
||||
}
|
||||
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
|
||||
serverConfig := grpcadapter.ServerConfig{
|
||||
Host: cfg.GRPC.Host,
|
||||
@@ -52,3 +73,48 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runSettlementReconciliation(ctx context.Context, repository *postgres.SettlementRepository) {
|
||||
run := func() {
|
||||
stats, err := repository.ReconcileConfirmationEvidence(ctx, 10*time.Minute)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "settlement reconciliation failed", "error", err)
|
||||
return
|
||||
}
|
||||
if stats.Detected > 0 || stats.Resolved > 0 || stats.Open > 0 {
|
||||
slog.WarnContext(ctx, "settlement reconciliation completed", "detected", stats.Detected, "resolved", stats.Resolved, "open", stats.Open)
|
||||
}
|
||||
}
|
||||
run()
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runSettlementWorker(ctx context.Context, worker applicationsettlement.Worker, interval time.Duration, batchSize int) {
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 20
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := worker.RunOnce(ctx, batchSize); err != nil {
|
||||
slog.ErrorContext(ctx, "settlement worker cycle failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
basev1 "gl/gen/base/v1"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"gl/infrastructure/config"
|
||||
"gl/infrastructure/postgres"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file")
|
||||
grpcProbe := flag.Bool("grpc", false, "check the serving GL API instead of only its database")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *grpcProbe {
|
||||
host := cfg.GRPC.Host
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
connection, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(cfg.GRPC.Port)), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer connection.Close()
|
||||
health, err := ledgerv1.NewGeneralLedgerServiceClient(connection).Health(ctx, &basev1.Empty{})
|
||||
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
database, err := postgres.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
if err := database.Ping(ctx); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -2,14 +2,14 @@ environment = "local"
|
||||
|
||||
[http]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
port = 8601
|
||||
read-header-timeout = "5s"
|
||||
shutdown-timeout = "10s"
|
||||
|
||||
[database]
|
||||
[db]
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
password = ""
|
||||
ssl-mode = "disable"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# GL authority API
|
||||
|
||||
The `ledger.v1.GeneralLedgerService` gRPC service exposes four additional RPCs.
|
||||
The contract is in `../proto/ledger/v1/{msg,srv}.proto` from the GL directory;
|
||||
the generated Go client is in `gen/ledger/v1`.
|
||||
|
||||
## Operator toggle
|
||||
|
||||
In the existing AdminPanel, open **GL journals → GL operating mode**.
|
||||
The checkbox, required reason, and recovery confirmation call GL directly.
|
||||
The operator's ID comes from the authenticated session. Permission
|
||||
`ledgerops.set_operating_mode` is required; superusers already have it.
|
||||
Operators also need `ledgerops.view_generalledgerjournal` for journal navigation.
|
||||
Register the permission using AdminPanel's usual Django `migrate` command
|
||||
and grant it only to designated operators. Configure the existing
|
||||
`GL_ADMIN_GRPC_ADDRESS` and `GL_ADMIN_GRPC_TOKEN` settings, with the token
|
||||
matching GL's `DARANO_GL_ADMIN_TOKEN`. These are server-side settings.
|
||||
|
||||
The AdminPanel control changes GL's authority state. Existing Wallet flows
|
||||
still need to call the internal posting API; changing Wallet's old
|
||||
`kuknos-enabled` configuration alone continues to select read-only behavior.
|
||||
|
||||
## Future Wallet routing contract
|
||||
|
||||
Wallet remains the entry point in both modes. The public Wallet RPCs, request
|
||||
and response schemas, authentication, and client UI stay unchanged; clients
|
||||
must not call GL directly or choose the execution mode.
|
||||
|
||||
| GL mode | Transaction execution | User balance response |
|
||||
| --- | --- | --- |
|
||||
| `NORMAL` (default) | Existing Wallet admission → GL settlement queue → Stellar/Kuknos | Existing Wallet behavior |
|
||||
| `GL_ONLY` | Wallet admission → GL `PostInternalJournal`, with no blockchain call | Wallet returns GL `GetUserBalances` values in its existing response format |
|
||||
|
||||
This routing is reserved for future activation. The GL backend and AdminPanel
|
||||
control exist, but the Wallet routing above is not implemented yet. Keep
|
||||
`NORMAL` selected for the current Wallet deployment. Enabling the control now
|
||||
pauses settlement and causes existing Wallet financial writes to fail closed;
|
||||
it does not automatically turn those endpoints into internal execution.
|
||||
|
||||
The future implementation must retain Wallet's authorization and business
|
||||
validation, persist the execution choice and exact command for recovery, and
|
||||
use the same transaction identity for retries. A GL commit establishes
|
||||
financial success; subsequent Wallet history updates are recoverable
|
||||
projections and must not produce a second financial journal through the normal
|
||||
outbox. Reconciliation must preserve the original execution choice across
|
||||
mode changes rather than reinterpret an older blockchain transaction as an
|
||||
internal transfer. Cutover requires draining normal-mode work and checking
|
||||
opening balances. Returning to normal requires reconciliation before external
|
||||
settlement resumes.
|
||||
|
||||
Internal transfers, freezes, releases, trades, and their fees can execute
|
||||
inside GL. An actual external blockchain payment cannot be reported as
|
||||
externally settled while Stellar/Kuknos is unavailable.
|
||||
|
||||
`GetOperatingMode(base.v1.Empty)` returns `mode`, `revision`, `actor_id`,
|
||||
`reason`, and `changed_at`.
|
||||
|
||||
`SetOperatingMode` requires exactly one `x-admin-token` metadata value matching
|
||||
`DARANO_GL_ADMIN_TOKEN`. To enable internal authority, send:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "GL_ONLY",
|
||||
"expectedRevision": "0",
|
||||
"actorId": "operator-17",
|
||||
"reason": "Kuknos outage; opening balances and outbox checked"
|
||||
}
|
||||
```
|
||||
|
||||
Use the revision from the latest read, rather than always sending zero.
|
||||
Mode persists across service restarts. A concurrent toggle returns `ABORTED`;
|
||||
read the state before deciding whether another change is needed. A toggle
|
||||
with in-flight settlements or negative user balances returns
|
||||
`FAILED_PRECONDITION`. Returning to `NORMAL` requires
|
||||
`reconciliationConfirmed: true` after operator reconciliation.
|
||||
|
||||
## Internal execution
|
||||
|
||||
`PostInternalJournal` accepts the existing `AppendJournalRequest` and requires
|
||||
the administrative token, an actor ID, and `GL_ONLY` mode for new postings.
|
||||
For a transfer, debit the sender's `USER_AVAILABLE` account and credit the
|
||||
recipient's account by the same exact decimal amount. For a freeze, debit
|
||||
available and credit frozen; for a release, reverse those legs. Multi-asset
|
||||
trade and fee legs must balance independently per asset.
|
||||
|
||||
The API accepts exact balanced entries so it can represent all internal
|
||||
business effects without depending on Stellar XDR, account sequences, or a
|
||||
Horizon endpoint. It does not determine business fees, asset eligibility, or
|
||||
whether the caller's user is allowed to move those funds; trusted application
|
||||
admission must supply and authorize those legs. It forbids blockchain
|
||||
references and `EXTERNAL_BLOCKCHAIN` accounts. The ordinary journal API
|
||||
remains the normal-mode ingestion interface and rejects new effects in
|
||||
`GL_ONLY`, while allowing identical retries of previously ingested journals.
|
||||
|
||||
Success means the journal and every entry committed atomically. Insufficient
|
||||
available or frozen funds returns `FAILED_PRECONDITION`. Retry uncertain
|
||||
outcomes with the exact same idempotency key and payload. Identical retries
|
||||
return `already_existed=true`; conflicting payloads return `ALREADY_EXISTS`.
|
||||
A concurrency abort returns `ABORTED`; retry that same request rather than
|
||||
allocating a new transaction identity. Database errors never mean success.
|
||||
|
||||
The server reserves journal metadata key `gl_execution` and stores
|
||||
`GL_ONLY` as immutable execution provenance. Callers do not set it.
|
||||
|
||||
## User balances
|
||||
|
||||
`GetUserBalances` reads GL entries without any blockchain call:
|
||||
|
||||
```json
|
||||
{"ownerType": "user", "ownerId": "17", "assetId": "7"}
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ownerType": "user",
|
||||
"ownerId": "17",
|
||||
"balances": [
|
||||
{"assetId": "7", "available": "34", "frozen": "6", "total": "40"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Omit `assetId` to return all journaled assets for that owner. An unknown owner
|
||||
has an empty list; a requested asset with no entries returns zero amounts.
|
||||
Amounts are decimal strings. Available and frozen are read in one database
|
||||
snapshot. The endpoint is available in either operating mode.
|
||||
|
||||
The API is an internal service: expose it through an authenticated application
|
||||
gateway that binds the requested owner to the logged-in user. Do not expose
|
||||
arbitrary owner lookups or the operator token directly to a browser.
|
||||
|
||||
## Verification
|
||||
|
||||
The opt-in PostgreSQL test creates and removes its own isolated schema:
|
||||
|
||||
```bash
|
||||
GL_TEST_DATABASE_URL='<disposable PostgreSQL connection URL>' \
|
||||
go test -race ./infrastructure/postgres -run TestAuthorityWithPostgres -v
|
||||
```
|
||||
|
||||
It exercises mode authentication, revision conflicts, immutable audit records,
|
||||
settlement suspension, concurrent overspending prevention, idempotent replay,
|
||||
freezes/releases, internal RPC posting, and user balance reconstruction.
|
||||
@@ -15,7 +15,9 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl
|
||||
go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl && \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard && \
|
||||
go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck
|
||||
|
||||
FROM alpine:3.24.1
|
||||
|
||||
@@ -26,11 +28,14 @@ RUN apk add --no-cache ca-certificates \
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/gl /app/gl
|
||||
COPY --from=builder /out/dashboard /app/dashboard
|
||||
COPY --from=builder /out/healthcheck /app/healthcheck
|
||||
COPY gl.cfg.toml /app/gl.cfg.toml
|
||||
COPY dashboard.cfg.toml /app/dashboard.cfg.toml
|
||||
|
||||
USER darano
|
||||
|
||||
EXPOSE 8600
|
||||
EXPOSE 8600 8601
|
||||
|
||||
ENTRYPOINT ["/app/gl"]
|
||||
CMD ["-conf", "/app/gl.cfg.toml"]
|
||||
@@ -0,0 +1,133 @@
|
||||
# Standalone General Ledger
|
||||
|
||||
GL's journal API and explorer require only GL's own PostgreSQL database.
|
||||
Stellar, Kuknos, Wallet, and an OpenTelemetry collector are not startup or
|
||||
posting dependencies. Blockchain references on journals are optional.
|
||||
|
||||
## Run independently
|
||||
|
||||
From the GL directory, set two runtime secrets and launch:
|
||||
|
||||
```bash
|
||||
export DARANO_GL_DB_PASSWORD='<database password>'
|
||||
export DARANO_GL_ADMIN_TOKEN='<administrative retry token>'
|
||||
docker compose -f deployment/compose.standalone.yaml up --build -d --wait
|
||||
```
|
||||
|
||||
The gRPC API is on `127.0.0.1:8600`; the read-only dashboard is on
|
||||
`http://127.0.0.1:8601`. Images must be built/downloaded before an outage.
|
||||
The runtime network is internal and has no external network access.
|
||||
Settlement submission and telemetry export are explicitly disabled. The
|
||||
settlement queue remains persisted and queryable; disabling its worker does
|
||||
not confirm queued blockchain transactions.
|
||||
|
||||
The named `ledger-data` volume retains journals across container restarts and
|
||||
ordinary `docker compose down`. Do not use `down -v` on a ledger containing
|
||||
financial records.
|
||||
|
||||
This launch creates a **new, empty ledger**. Before treating it as the source
|
||||
of truth, restore the existing authoritative GL database, or import approved,
|
||||
balanced opening journals and replay all subsequent committed movements.
|
||||
Running a second empty GL does not provide failover for the first.
|
||||
|
||||
## What remains available during a blockchain outage
|
||||
|
||||
- Atomic, balanced, append-only journal posting with idempotent retries.
|
||||
- Journal lookup, history, lifecycle events, and balances rebuilt from entries.
|
||||
- Read-only exploration of the same database.
|
||||
- Durable storage of settlement requests already carrying signed XDR.
|
||||
|
||||
GL readiness reflects its database. Settlement backlog or submission failures
|
||||
do not prevent ledger reads or writes. Database loss or failed journal commits
|
||||
must prevent a caller from declaring an unrecorded financial effect successful.
|
||||
|
||||
## Financial authority versus settlement
|
||||
|
||||
The committed journal is the source of book balances. A blockchain hash is
|
||||
settlement evidence, not the journal identity. Do not modify a journal to add
|
||||
later confirmation; retain it as separate settlement/lifecycle evidence.
|
||||
|
||||
The persisted `GL_ONLY` operating mode makes GL the execution authority.
|
||||
`PostInternalJournal` commits balanced internal effects synchronously, checks
|
||||
that affected available/frozen accounts do not become negative, and serializes
|
||||
concurrent spending inside PostgreSQL. An identical retry returns the original
|
||||
journal even after the mode changes. In `GL_ONLY`, new effects must use the
|
||||
authenticated internal posting API; ordinary ingestion rejects new journals
|
||||
while still accepting identical retries. PostgreSQL enforces the funds checks
|
||||
and rejects external blockchain movements at sealing. Business admission still
|
||||
owns user authorization, asset eligibility, fees, and exact counterparty legs.
|
||||
|
||||
GL starts in `NORMAL`; mode is stored in its database and survives restarts.
|
||||
Disabling the settlement worker in TOML is separate from choosing `GL_ONLY`.
|
||||
Read the current revision, then use the authenticated `SetOperatingMode` API
|
||||
to change mode. Each change retains actor, reason, revision, and time in an
|
||||
immutable audit table. Stale revisions fail. Enabling `GL_ONLY` requires no
|
||||
in-flight settlement claims and no negative user account balances. Drain the
|
||||
Wallet outbox, pause and drain in-flight Wallet processing, and confirm the
|
||||
ledger includes every committed effect before switching.
|
||||
|
||||
The existing AdminPanel controls GL mode through its GL journals →
|
||||
GL operating mode page. Configure `GL_ADMIN_GRPC_ADDRESS` and
|
||||
`GL_ADMIN_GRPC_TOKEN` (matching GL's `DARANO_GL_ADMIN_TOKEN`), and grant
|
||||
`ledgerops.set_operating_mode` to designated operators. AdminPanel never
|
||||
updates GL tables directly; mode changes go through GL's API.
|
||||
|
||||
Wallet remains the public entry point in both modes: the future GL-only route
|
||||
must preserve the existing Wallet endpoints, response formats, and UI. In
|
||||
normal operation, admitted requests continue through GL settlement to
|
||||
Stellar/Kuknos. With GL-only routing implemented and enabled, Wallet will
|
||||
instead submit internal effects directly to GL and serve GL balances through
|
||||
those same Wallet endpoints. See [the future Wallet routing contract](AUTHORITY_API.md#future-wallet-routing-contract).
|
||||
|
||||
That Wallet routing is not implemented yet; keep `NORMAL` selected for the
|
||||
current deployment. The existing Wallet configuration toggle still selects read-only behavior.
|
||||
Wallet consumers must route internal execution to `PostInternalJournal`;
|
||||
the AdminPanel change does not replace Wallet's blockchain-dependent workflows.
|
||||
GL keeps its own readiness healthy but reports settlement as paused, so existing
|
||||
normal-mode Wallet clients fail closed until their internal routes are integrated.
|
||||
A committed GL journal is the financial
|
||||
success result; Wallet state becomes an idempotent projection of it. Do not
|
||||
also send the same effect through the normal Wallet outbox under a different
|
||||
key: that would duplicate the movement. Existing normal-mode committed Wallet
|
||||
effects continue using their durable outbox.
|
||||
|
||||
Internal transfers, freezes, releases, trades, and clearing movements are
|
||||
represented by balanced entries with no blockchain reference. An operation
|
||||
that actually sends money outside the internal books cannot become externally
|
||||
settled during an outage. External blockchain account entries and settlement
|
||||
submission are rejected in `GL_ONLY`; store any future external intent
|
||||
separately and keep its settlement pending.
|
||||
|
||||
After blockchain recovery, reconcile journals against external evidence before
|
||||
resuming normal settlement. Returning to `NORMAL` requires an operator to set
|
||||
`reconciliation_confirmed=true`; this is an explicit assertion, not an automated
|
||||
proof. While `GL_ONLY` is enabled, the worker leaves the existing settlement
|
||||
queue untouched. Mode changes reject in-flight claims so a toggle cannot race
|
||||
an already claimed submission. The existing queue requires signed XDR; it does not
|
||||
turn arbitrary journals into blockchain transactions. Sequence numbers,
|
||||
transaction expiry, and exhausted retries may require operator review and
|
||||
reconstruction. Never blindly resubmit all journals or mark them settled.
|
||||
|
||||
## Verify and preserve the ledger
|
||||
|
||||
The existing write/read load test can exercise a **disposable** standalone
|
||||
database without any blockchain endpoint:
|
||||
|
||||
```bash
|
||||
go run ./cmd/gl-loadtest -duration 5s -concurrency 1 -wallets 10
|
||||
```
|
||||
|
||||
This writes synthetic journals and must not run against an authoritative
|
||||
financial database. Restart GL and confirm the same journal IDs and balances
|
||||
remain queryable.
|
||||
|
||||
Keep PostgreSQL backups and WAL recovery independent of the blockchain
|
||||
services, and periodically verify a restore. For example, create a logical
|
||||
backup without deleting or changing ledger records:
|
||||
|
||||
```bash
|
||||
docker compose -f deployment/compose.standalone.yaml exec -T postgres \
|
||||
pg_dump -U gl -d gl_db -Fc > gl.dump
|
||||
```
|
||||
|
||||
A named volume provides persistence on one host, not disaster recovery.
|
||||
@@ -0,0 +1,65 @@
|
||||
name: darano-gl-standalone
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_DB: gl_db
|
||||
POSTGRES_USER: gl
|
||||
POSTGRES_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
|
||||
volumes:
|
||||
- ledger-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gl -d gl_db"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
networks: [ledger]
|
||||
|
||||
gl:
|
||||
image: darano-gl:standalone
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deployment/Dockerfile
|
||||
args:
|
||||
GO_PROXY: ${GO_PROXY:-https://proxy.golang.org}
|
||||
command: ["-conf", "/app/standalone.toml"]
|
||||
environment:
|
||||
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
|
||||
DARANO_GL_ADMIN_TOKEN: ${DARANO_GL_ADMIN_TOKEN:?Set DARANO_GL_ADMIN_TOKEN}
|
||||
volumes:
|
||||
- ./gl.standalone.toml:/app/standalone.toml:ro
|
||||
ports: ["127.0.0.1:8600:8600"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "/app/healthcheck", "-conf", "/app/standalone.toml", "-grpc"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
networks: [ledger]
|
||||
|
||||
dashboard:
|
||||
image: darano-gl:standalone
|
||||
entrypoint: ["/app/dashboard"]
|
||||
command: ["-conf", "/app/standalone.toml"]
|
||||
environment:
|
||||
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
|
||||
volumes:
|
||||
- ./dashboard.standalone.toml:/app/standalone.toml:ro
|
||||
ports: ["127.0.0.1:8601:8601"]
|
||||
depends_on:
|
||||
gl:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks: [ledger]
|
||||
|
||||
volumes:
|
||||
ledger-data:
|
||||
|
||||
networks:
|
||||
ledger:
|
||||
internal: true
|
||||
@@ -0,0 +1,16 @@
|
||||
environment = "production"
|
||||
|
||||
[http]
|
||||
host = "0.0.0.0"
|
||||
port = 8601
|
||||
read-header-timeout = "5s"
|
||||
shutdown-timeout = "10s"
|
||||
|
||||
[db]
|
||||
host = "postgres"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "gl"
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
@@ -0,0 +1,18 @@
|
||||
environment = "production"
|
||||
|
||||
[grpc]
|
||||
host = "0.0.0.0"
|
||||
port = 8600
|
||||
timeout = "10s"
|
||||
|
||||
[db]
|
||||
host = "postgres"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "gl"
|
||||
|
||||
[settlement]
|
||||
enabled = false
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
@@ -0,0 +1,36 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OperatingMode string
|
||||
|
||||
const (
|
||||
ModeNormal OperatingMode = "NORMAL"
|
||||
ModeGLOnly OperatingMode = "GL_ONLY"
|
||||
InternalExecutionMetadata = "gl_execution"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrModeConflict = errors.New("operating mode revision changed")
|
||||
ErrModeRequired = errors.New("operation is not permitted in the current ledger mode")
|
||||
ErrInsufficientFunds = errors.New("insufficient ledger funds")
|
||||
ErrSettlementInFlight = errors.New("settlement is in flight; drain the worker before switching")
|
||||
ErrConcurrentPosting = errors.New("concurrent posting conflicted; retry with the same idempotency key")
|
||||
)
|
||||
|
||||
type OperatingState struct {
|
||||
Mode OperatingMode
|
||||
Revision int64
|
||||
ActorID string
|
||||
Reason string
|
||||
ChangedAt time.Time
|
||||
}
|
||||
|
||||
type UserAssetBalance struct {
|
||||
AssetID int64
|
||||
Available Amount
|
||||
Frozen Amount
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PermanentSubmissionError struct{ Err error }
|
||||
|
||||
func (e PermanentSubmissionError) Error() string { return e.Err.Error() }
|
||||
func (e PermanentSubmissionError) Unwrap() error { return e.Err }
|
||||
|
||||
type SettlementStatus string
|
||||
|
||||
const (
|
||||
SettlementPending SettlementStatus = "PENDING"
|
||||
SettlementSubmitted SettlementStatus = "SUBMITTED"
|
||||
SettlementConfirmed SettlementStatus = "CONFIRMED"
|
||||
SettlementRetryable SettlementStatus = "RETRYABLE"
|
||||
SettlementManualReview SettlementStatus = "MANUAL_REVIEW"
|
||||
SettlementIgnored SettlementStatus = "IGNORED"
|
||||
)
|
||||
|
||||
type Settlement struct {
|
||||
ID string
|
||||
SourceService string
|
||||
SourceTxID string
|
||||
IdempotencyKey string
|
||||
Status SettlementStatus
|
||||
Network string
|
||||
TransactionHash string
|
||||
Attempts int
|
||||
AvailableAt time.Time
|
||||
LastError string
|
||||
SignedTransactionXDR string
|
||||
}
|
||||
|
||||
type SettlementStats struct {
|
||||
Pending int64
|
||||
Retryable int64
|
||||
ManualReview int64
|
||||
OldestPendingSeconds int64
|
||||
}
|
||||
|
||||
func (s Settlement) Validate() error {
|
||||
if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" {
|
||||
return fmt.Errorf("settlement identity fields are required")
|
||||
}
|
||||
if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview && s.Status != SettlementIgnored {
|
||||
return fmt.Errorf("invalid settlement status %q", s.Status)
|
||||
}
|
||||
if s.Attempts < 0 || s.AvailableAt.IsZero() {
|
||||
return fmt.Errorf("invalid settlement retry state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Settlement) Retry(now time.Time, maxAttempts int, err error) Settlement {
|
||||
s.LastError = err.Error()
|
||||
s.Status = SettlementRetryable
|
||||
if maxAttempts > 0 && s.Attempts >= maxAttempts {
|
||||
s.Status = SettlementManualReview
|
||||
}
|
||||
delay := time.Second
|
||||
for i := 1; i < s.Attempts && delay < 15*time.Minute; i++ {
|
||||
delay *= 2
|
||||
}
|
||||
if delay > 15*time.Minute {
|
||||
delay = 15 * time.Minute
|
||||
}
|
||||
s.AvailableAt = now.UTC().Add(delay)
|
||||
return s
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc (unknown)
|
||||
// source: base/v1/msg.proto
|
||||
|
||||
|
||||
+1150
-126
File diff suppressed because it is too large
Load Diff
+69
-33
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc (unknown)
|
||||
// source: ledger/v1/srv.proto
|
||||
|
||||
@@ -25,8 +25,12 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_ledger_v1_srv_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xbe\x04\n" +
|
||||
"\x14GeneralLedgerService\x123\n" +
|
||||
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xf2\t\n" +
|
||||
"\x14GeneralLedgerService\x12D\n" +
|
||||
"\x10GetOperatingMode\x12\x0e.base.v1.Empty\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
|
||||
"\x10SetOperatingMode\x12\".ledger.v1.SetOperatingModeRequest\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
|
||||
"\x13PostInternalJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12X\n" +
|
||||
"\x0fGetUserBalances\x12!.ledger.v1.GetUserBalancesRequest\x1a\".ledger.v1.GetUserBalancesResponse\x123\n" +
|
||||
"\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" +
|
||||
"\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" +
|
||||
"\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" +
|
||||
@@ -35,43 +39,75 @@ const file_ledger_v1_srv_proto_rawDesc = "" +
|
||||
"\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" +
|
||||
"\n" +
|
||||
"GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" +
|
||||
"\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponseBy\n" +
|
||||
"\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" +
|
||||
"\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" +
|
||||
"\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" +
|
||||
"\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponse\x12[\n" +
|
||||
"\x10IgnoreSettlement\x12\".ledger.v1.IgnoreSettlementRequest\x1a#.ledger.v1.IgnoreSettlementResponseBy\n" +
|
||||
"\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" +
|
||||
"Ledger::V1b\x06proto3"
|
||||
|
||||
var file_ledger_v1_srv_proto_goTypes = []any{
|
||||
(*v1.Empty)(nil), // 0: base.v1.Empty
|
||||
(*AppendJournalRequest)(nil), // 1: ledger.v1.AppendJournalRequest
|
||||
(*AppendTransactionEventRequest)(nil), // 2: ledger.v1.AppendTransactionEventRequest
|
||||
(*GetJournalRequest)(nil), // 3: ledger.v1.GetJournalRequest
|
||||
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest
|
||||
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest
|
||||
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest
|
||||
(*HealthResponse)(nil), // 7: ledger.v1.HealthResponse
|
||||
(*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse
|
||||
(*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse
|
||||
(*Journal)(nil), // 10: ledger.v1.Journal
|
||||
(*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse
|
||||
(*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse
|
||||
(*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse
|
||||
(*SetOperatingModeRequest)(nil), // 1: ledger.v1.SetOperatingModeRequest
|
||||
(*AppendJournalRequest)(nil), // 2: ledger.v1.AppendJournalRequest
|
||||
(*GetUserBalancesRequest)(nil), // 3: ledger.v1.GetUserBalancesRequest
|
||||
(*AppendTransactionEventRequest)(nil), // 4: ledger.v1.AppendTransactionEventRequest
|
||||
(*GetJournalRequest)(nil), // 5: ledger.v1.GetJournalRequest
|
||||
(*ListEntriesRequest)(nil), // 6: ledger.v1.ListEntriesRequest
|
||||
(*GetBalanceRequest)(nil), // 7: ledger.v1.GetBalanceRequest
|
||||
(*ReplayJournalsRequest)(nil), // 8: ledger.v1.ReplayJournalsRequest
|
||||
(*EnqueueSettlementRequest)(nil), // 9: ledger.v1.EnqueueSettlementRequest
|
||||
(*GetSettlementRequest)(nil), // 10: ledger.v1.GetSettlementRequest
|
||||
(*RetrySettlementRequest)(nil), // 11: ledger.v1.RetrySettlementRequest
|
||||
(*IgnoreSettlementRequest)(nil), // 12: ledger.v1.IgnoreSettlementRequest
|
||||
(*OperatingModeResponse)(nil), // 13: ledger.v1.OperatingModeResponse
|
||||
(*AppendJournalResponse)(nil), // 14: ledger.v1.AppendJournalResponse
|
||||
(*GetUserBalancesResponse)(nil), // 15: ledger.v1.GetUserBalancesResponse
|
||||
(*HealthResponse)(nil), // 16: ledger.v1.HealthResponse
|
||||
(*AppendTransactionEventResponse)(nil), // 17: ledger.v1.AppendTransactionEventResponse
|
||||
(*Journal)(nil), // 18: ledger.v1.Journal
|
||||
(*ListEntriesResponse)(nil), // 19: ledger.v1.ListEntriesResponse
|
||||
(*GetBalanceResponse)(nil), // 20: ledger.v1.GetBalanceResponse
|
||||
(*ReplayJournalsResponse)(nil), // 21: ledger.v1.ReplayJournalsResponse
|
||||
(*EnqueueSettlementResponse)(nil), // 22: ledger.v1.EnqueueSettlementResponse
|
||||
(*Settlement)(nil), // 23: ledger.v1.Settlement
|
||||
(*RetrySettlementResponse)(nil), // 24: ledger.v1.RetrySettlementResponse
|
||||
(*IgnoreSettlementResponse)(nil), // 25: ledger.v1.IgnoreSettlementResponse
|
||||
}
|
||||
var file_ledger_v1_srv_proto_depIdxs = []int32{
|
||||
0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
|
||||
1, // 1: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
2, // 2: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
|
||||
3, // 3: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
|
||||
4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
|
||||
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
||||
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
||||
7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
|
||||
8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||
9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
|
||||
10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
|
||||
11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
|
||||
12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
|
||||
13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
|
||||
7, // [7:14] is the sub-list for method output_type
|
||||
0, // [0:7] is the sub-list for method input_type
|
||||
0, // 0: ledger.v1.GeneralLedgerService.GetOperatingMode:input_type -> base.v1.Empty
|
||||
1, // 1: ledger.v1.GeneralLedgerService.SetOperatingMode:input_type -> ledger.v1.SetOperatingModeRequest
|
||||
2, // 2: ledger.v1.GeneralLedgerService.PostInternalJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
3, // 3: ledger.v1.GeneralLedgerService.GetUserBalances:input_type -> ledger.v1.GetUserBalancesRequest
|
||||
0, // 4: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
|
||||
2, // 5: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
4, // 6: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
|
||||
5, // 7: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
|
||||
6, // 8: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
|
||||
7, // 9: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
||||
8, // 10: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
||||
9, // 11: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
|
||||
10, // 12: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
|
||||
11, // 13: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
|
||||
12, // 14: ledger.v1.GeneralLedgerService.IgnoreSettlement:input_type -> ledger.v1.IgnoreSettlementRequest
|
||||
13, // 15: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
|
||||
13, // 16: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
|
||||
14, // 17: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||
15, // 18: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse
|
||||
16, // 19: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
|
||||
14, // 20: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||
17, // 21: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
|
||||
18, // 22: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
|
||||
19, // 23: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
|
||||
20, // 24: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
|
||||
21, // 25: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
|
||||
22, // 26: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse
|
||||
23, // 27: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement
|
||||
24, // 28: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse
|
||||
25, // 29: ledger.v1.GeneralLedgerService.IgnoreSettlement:output_type -> ledger.v1.IgnoreSettlementResponse
|
||||
15, // [15:30] is the sub-list for method output_type
|
||||
0, // [0:15] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc (unknown)
|
||||
// source: ledger/v1/srv.proto
|
||||
|
||||
@@ -20,6 +20,10 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
GeneralLedgerService_GetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/GetOperatingMode"
|
||||
GeneralLedgerService_SetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/SetOperatingMode"
|
||||
GeneralLedgerService_PostInternalJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/PostInternalJournal"
|
||||
GeneralLedgerService_GetUserBalances_FullMethodName = "/ledger.v1.GeneralLedgerService/GetUserBalances"
|
||||
GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health"
|
||||
GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal"
|
||||
GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent"
|
||||
@@ -27,12 +31,22 @@ const (
|
||||
GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries"
|
||||
GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance"
|
||||
GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals"
|
||||
GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement"
|
||||
GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement"
|
||||
GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement"
|
||||
GeneralLedgerService_IgnoreSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/IgnoreSettlement"
|
||||
)
|
||||
|
||||
// GeneralLedgerServiceClient is the client API for GeneralLedgerService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type GeneralLedgerServiceClient interface {
|
||||
GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error)
|
||||
// Operator-only; requires x-admin-token and an optimistic revision.
|
||||
SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error)
|
||||
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
|
||||
PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
|
||||
GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error)
|
||||
Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error)
|
||||
AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
|
||||
AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error)
|
||||
@@ -40,6 +54,13 @@ type GeneralLedgerServiceClient interface {
|
||||
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
||||
GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error)
|
||||
ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error)
|
||||
EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error)
|
||||
GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error)
|
||||
// Restricted operator command. Requires the configured x-admin-token metadata.
|
||||
RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error)
|
||||
// Operator-reviewed, terminal dismissal. Does not confirm or reverse payment.
|
||||
// Requires x-admin-token and a recorded reason; only unlocked failed settlements.
|
||||
IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error)
|
||||
}
|
||||
|
||||
type generalLedgerServiceClient struct {
|
||||
@@ -50,6 +71,46 @@ func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerSer
|
||||
return &generalLedgerServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(OperatingModeResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_GetOperatingMode_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(OperatingModeResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_SetOperatingMode_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AppendJournalResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_PostInternalJournal_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetUserBalancesResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_GetUserBalances_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HealthResponse)
|
||||
@@ -120,10 +181,56 @@ func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *Rep
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(EnqueueSettlementResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_EnqueueSettlement_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(Settlement)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_GetSettlement_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(RetrySettlementResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_RetrySettlement_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(IgnoreSettlementResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_IgnoreSettlement_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GeneralLedgerServiceServer is the server API for GeneralLedgerService service.
|
||||
// All implementations should embed UnimplementedGeneralLedgerServiceServer
|
||||
// for forward compatibility.
|
||||
type GeneralLedgerServiceServer interface {
|
||||
GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error)
|
||||
// Operator-only; requires x-admin-token and an optimistic revision.
|
||||
SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error)
|
||||
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
|
||||
PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
|
||||
GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error)
|
||||
Health(context.Context, *v1.Empty) (*HealthResponse, error)
|
||||
AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
|
||||
AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error)
|
||||
@@ -131,6 +238,13 @@ type GeneralLedgerServiceServer interface {
|
||||
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
||||
GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error)
|
||||
ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error)
|
||||
EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error)
|
||||
GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error)
|
||||
// Restricted operator command. Requires the configured x-admin-token metadata.
|
||||
RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error)
|
||||
// Operator-reviewed, terminal dismissal. Does not confirm or reverse payment.
|
||||
// Requires x-admin-token and a recorded reason; only unlocked failed settlements.
|
||||
IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error)
|
||||
}
|
||||
|
||||
// UnimplementedGeneralLedgerServiceServer should be embedded to have
|
||||
@@ -140,26 +254,50 @@ type GeneralLedgerServiceServer interface {
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedGeneralLedgerServiceServer struct{}
|
||||
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetOperatingMode not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetOperatingMode not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method PostInternalJournal not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUserBalances not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AppendJournal not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AppendJournal not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AppendTransactionEvent not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AppendTransactionEvent not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetJournal not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetJournal not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListEntries not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetBalance not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetBalance not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ReplayJournals not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ReplayJournals not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method EnqueueSettlement not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetSettlement not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method IgnoreSettlement not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@@ -171,7 +309,7 @@ type UnsafeGeneralLedgerServiceServer interface {
|
||||
}
|
||||
|
||||
func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedGeneralLedgerServiceServer was
|
||||
// If the following call pancis, it indicates UnimplementedGeneralLedgerServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
@@ -181,6 +319,78 @@ func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedg
|
||||
s.RegisterService(&GeneralLedgerService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_GetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(v1.Empty)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_GetOperatingMode_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, req.(*v1.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_SetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetOperatingModeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_SetOperatingMode_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, req.(*SetOperatingModeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_PostInternalJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AppendJournalRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_PostInternalJournal_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, req.(*AppendJournalRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_GetUserBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserBalancesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_GetUserBalances_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, req.(*GetUserBalancesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(v1.Empty)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -307,6 +517,78 @@ func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.C
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_EnqueueSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(EnqueueSettlementRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_EnqueueSettlement_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, req.(*EnqueueSettlementRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_GetSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetSettlementRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_GetSettlement_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, req.(*GetSettlementRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RetrySettlementRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_RetrySettlement_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, req.(*RetrySettlementRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_IgnoreSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(IgnoreSettlementRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_IgnoreSettlement_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, req.(*IgnoreSettlementRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -314,6 +596,22 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "ledger.v1.GeneralLedgerService",
|
||||
HandlerType: (*GeneralLedgerServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetOperatingMode",
|
||||
Handler: _GeneralLedgerService_GetOperatingMode_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetOperatingMode",
|
||||
Handler: _GeneralLedgerService_SetOperatingMode_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PostInternalJournal",
|
||||
Handler: _GeneralLedgerService_PostInternalJournal_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUserBalances",
|
||||
Handler: _GeneralLedgerService_GetUserBalances_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Health",
|
||||
Handler: _GeneralLedgerService_Health_Handler,
|
||||
@@ -342,6 +640,22 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ReplayJournals",
|
||||
Handler: _GeneralLedgerService_ReplayJournals_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "EnqueueSettlement",
|
||||
Handler: _GeneralLedgerService_EnqueueSettlement_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetSettlement",
|
||||
Handler: _GeneralLedgerService_GetSettlement_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RetrySettlement",
|
||||
Handler: _GeneralLedgerService_RetrySettlement_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "IgnoreSettlement",
|
||||
Handler: _GeneralLedgerService_IgnoreSettlement_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "ledger/v1/srv.proto",
|
||||
|
||||
+12
-3
@@ -3,12 +3,21 @@ environment = "local"
|
||||
[grpc]
|
||||
host = "0.0.0.0"
|
||||
port = 8600
|
||||
shutdown-timeout = "10s"
|
||||
timeout = "10s"
|
||||
|
||||
[database]
|
||||
[db]
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
password = ""
|
||||
ssl-mode = "disable"
|
||||
|
||||
[settlement]
|
||||
enabled = false
|
||||
endpoint = ""
|
||||
worker-id = "gl-local"
|
||||
poll-interval = "5s"
|
||||
batch-size = 20
|
||||
max-attempts = 8
|
||||
admin-token = ""
|
||||
|
||||
@@ -8,18 +8,32 @@ require (
|
||||
github.com/knadh/koanf/parsers/toml v0.1.0
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
github.com/knadh/koanf/v2 v2.3.4
|
||||
google.golang.org/grpc v1.67.1
|
||||
google.golang.org/protobuf v1.36.6
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
@@ -30,14 +44,22 @@ require (
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/natefinch/atomic v1.0.1 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
)
|
||||
|
||||
tool github.com/a-h/templ
|
||||
tool (
|
||||
github.com/a-h/templ
|
||||
github.com/a-h/templ/cmd/templ
|
||||
)
|
||||
|
||||
@@ -6,6 +6,10 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -13,12 +17,25 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
@@ -53,30 +70,60 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/koanf/parsers/toml"
|
||||
@@ -11,21 +13,37 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Environment string `koanf:"environment"`
|
||||
GRPC GRPCConfig `koanf:"grpc"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
Environment string `koanf:"environment"`
|
||||
GRPC GRPCConfig `koanf:"grpc"`
|
||||
Database DatabaseConfig `koanf:"db"`
|
||||
Settlement SettlementConfig `koanf:"settlement"`
|
||||
Telemetry TelemetryConfig `koanf:"telemetry"`
|
||||
}
|
||||
|
||||
type DashboardConfig struct {
|
||||
Environment string `koanf:"environment"`
|
||||
HTTP HTTPConfig `koanf:"http"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
Environment string `koanf:"environment"`
|
||||
HTTP HTTPConfig `koanf:"http"`
|
||||
Database DatabaseConfig `koanf:"db"`
|
||||
Telemetry TelemetryConfig `koanf:"telemetry"`
|
||||
}
|
||||
|
||||
type TelemetryConfig struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
Protocol string `koanf:"protocol"`
|
||||
Endpoint string `koanf:"endpoint"`
|
||||
Insecure bool `koanf:"insecure"`
|
||||
Headers map[string]string `koanf:"headers"`
|
||||
Sampler string `koanf:"sampler"`
|
||||
SamplerArg string `koanf:"sampler-arg"`
|
||||
ExportTimeout time.Duration `koanf:"export-timeout"`
|
||||
BatchTimeout time.Duration `koanf:"batch-timeout"`
|
||||
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
|
||||
}
|
||||
|
||||
type GRPCConfig struct {
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
|
||||
ShutdownTimeout time.Duration `koanf:"timeout"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
@@ -36,12 +54,23 @@ type HTTPConfig struct {
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
Name string `koanf:"name"`
|
||||
User string `koanf:"user"`
|
||||
Password string `koanf:"password"`
|
||||
SSLMode string `koanf:"ssl-mode"`
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
Name string `koanf:"name"`
|
||||
User string `koanf:"user"`
|
||||
Password string `koanf:"password"`
|
||||
GormLogLevel int `koanf:"gorm-log-level"`
|
||||
SSLMode string `koanf:"-"`
|
||||
}
|
||||
|
||||
type SettlementConfig struct {
|
||||
Enabled bool `koanf:"enabled"`
|
||||
Endpoint string `koanf:"endpoint"`
|
||||
WorkerID string `koanf:"worker-id"`
|
||||
PollInterval time.Duration `koanf:"poll-interval"`
|
||||
BatchSize int `koanf:"batch-size"`
|
||||
MaxAttempts int `koanf:"max-attempts"`
|
||||
AdminToken string `koanf:"admin-token"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -52,12 +81,14 @@ func Load(path string) (*Config, error) {
|
||||
Port: 8600,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
},
|
||||
Database: defaultDatabaseConfig(),
|
||||
Database: defaultDatabaseConfig(),
|
||||
Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8},
|
||||
}
|
||||
|
||||
if err := load(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyEnvironment(&cfg.Database, &cfg.Settlement)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -78,12 +109,28 @@ func LoadDashboard(path string) (*DashboardConfig, error) {
|
||||
if err := load(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyEnvironment(&cfg.Database, nil)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) {
|
||||
if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok {
|
||||
database.Password = value
|
||||
}
|
||||
if settlement == nil {
|
||||
return
|
||||
}
|
||||
if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok {
|
||||
settlement.Endpoint = strings.TrimRight(value, "/")
|
||||
}
|
||||
if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok {
|
||||
settlement.AdminToken = value
|
||||
}
|
||||
}
|
||||
|
||||
func load(path string, target any) error {
|
||||
k := koanf.New(".")
|
||||
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
|
||||
@@ -115,6 +162,9 @@ func (c *Config) Validate() error {
|
||||
if c.GRPC.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("grpc shutdown timeout must be positive")
|
||||
}
|
||||
if c.Environment == "production" && c.Settlement.AdminToken == "" {
|
||||
return fmt.Errorf("settlement admin token is required in production")
|
||||
}
|
||||
return validateDatabase(c.Database)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gl.toml")
|
||||
contents := []byte("[grpc]\nport = 0\nshutdown-timeout = \"3s\"\n")
|
||||
contents := []byte("[grpc]\nport = 0\ntimeout = \"3s\"\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
|
||||
func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dashboard.toml")
|
||||
contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n")
|
||||
contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n[db]\nname = \"dashboard_db\"\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||
if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second {
|
||||
t.Fatalf("unexpected http config: %+v", cfg.HTTP)
|
||||
}
|
||||
if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 {
|
||||
if cfg.Database.Name != "dashboard_db" || cfg.Database.Port != 5432 {
|
||||
t.Fatalf("unexpected database defaults: %+v", cfg.Database)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
|
||||
func TestLoadRejectsInvalidConfiguration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gl.toml")
|
||||
if err := os.WriteFile(path, []byte("[grpc]\nshutdown-timeout = \"0s\"\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(path, []byte("[grpc]\ntimeout = \"0s\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -64,3 +64,46 @@ func TestLoadReturnsMissingFileError(t *testing.T) {
|
||||
t.Fatal("expected missing file error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesSecretEnvironmentOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "gl.toml")
|
||||
contents := []byte("environment = \"production\"\n[grpc]\ntimeout = \"3s\"\n[settlement]\nenabled = true\nendpoint = \"https://invalid.example\"\n")
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret")
|
||||
t.Setenv("DARANO_GL_ADMIN_TOKEN", "runtime-admin-secret")
|
||||
t.Setenv("DARANO_GL_SETTLEMENT_ENDPOINT", "https://horizon.example/")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.Password != "runtime-db-secret" {
|
||||
t.Fatal("database password was not loaded from the environment")
|
||||
}
|
||||
if cfg.Settlement.AdminToken != "runtime-admin-secret" {
|
||||
t.Fatal("admin token was not loaded from the environment")
|
||||
}
|
||||
if cfg.Settlement.Endpoint != "https://horizon.example" {
|
||||
t.Fatalf("unexpected settlement endpoint: %q", cfg.Settlement.Endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDashboardAppliesDatabasePasswordEnvironmentOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "dashboard.toml")
|
||||
if err := os.WriteFile(path, []byte("[http]\nport = 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret")
|
||||
|
||||
cfg, err := LoadDashboard(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.Password != "runtime-db-secret" {
|
||||
t.Fatal("database password was not loaded from the environment")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package kuknos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer = otel.Tracer("gl/infrastructure/kuknos")
|
||||
|
||||
// Submitter sends a pre-signed Stellar/Kuknos transaction to Horizon. Signing
|
||||
// remains outside GL; GL owns durable admission, retry, and reconciliation.
|
||||
type Submitter struct {
|
||||
Endpoint string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func (s Submitter) Submit(ctx context.Context, record ledger.Settlement) (string, error) {
|
||||
ctx, span := tracer.Start(ctx, "kuknos.submit_transaction", trace.WithSpanKind(trace.SpanKindClient))
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("kuknos.network", record.Network),
|
||||
attribute.String("settlement.id", record.ID),
|
||||
attribute.String("settlement.source_service", record.SourceService),
|
||||
attribute.String("settlement.source_transaction_id", record.SourceTxID),
|
||||
attribute.String("settlement.idempotency_key", record.IdempotencyKey),
|
||||
attribute.Int("settlement.attempt", record.Attempts),
|
||||
)
|
||||
|
||||
if s.Endpoint == "" || record.SignedTransactionXDR == "" {
|
||||
return s.fail(ctx, span, record, fmt.Errorf("kuknos submission endpoint and signed xdr are required"))
|
||||
}
|
||||
endpoint := strings.TrimRight(s.Endpoint, "/") + "/transactions"
|
||||
form := url.Values{"tx": {record.SignedTransactionXDR}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return s.fail(ctx, span, record, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
client := s.Client
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
startedAt := time.Now()
|
||||
slog.InfoContext(ctx, "kuknos transaction submission started",
|
||||
"settlement_id", record.ID,
|
||||
"source_transaction_id", record.SourceTxID,
|
||||
"idempotency_key", record.IdempotencyKey,
|
||||
"network", record.Network,
|
||||
"attempt", record.Attempts,
|
||||
"endpoint", endpoint,
|
||||
)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return s.fail(ctx, span, record, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return s.fail(ctx, span, record, fmt.Errorf("read kuknos response: %w", err))
|
||||
}
|
||||
rawResponse := string(responseBody)
|
||||
var response struct {
|
||||
Hash string `json:"hash"`
|
||||
Extras struct {
|
||||
ResultCodes struct {
|
||||
Transaction string `json:"transaction"`
|
||||
} `json:"result_codes"`
|
||||
} `json:"extras"`
|
||||
}
|
||||
if err := json.Unmarshal(responseBody, &response); err != nil {
|
||||
slog.ErrorContext(ctx, "kuknos transaction submission returned an invalid response",
|
||||
"settlement_id", record.ID,
|
||||
"source_transaction_id", record.SourceTxID,
|
||||
"idempotency_key", record.IdempotencyKey,
|
||||
"network", record.Network,
|
||||
"attempt", record.Attempts,
|
||||
"status_code", resp.StatusCode,
|
||||
"stellar_response", rawResponse,
|
||||
"error", err,
|
||||
)
|
||||
return s.fail(ctx, span, record, fmt.Errorf("decode kuknos response: %w", err))
|
||||
}
|
||||
latency := time.Since(startedAt)
|
||||
span.SetAttributes(
|
||||
attribute.Int("http.response.status_code", resp.StatusCode),
|
||||
attribute.Int64("http.response.body.size", int64(len(responseBody))),
|
||||
attribute.Int64("kuknos.submission.duration_ms", latency.Milliseconds()),
|
||||
)
|
||||
span.AddEvent("kuknos.response", trace.WithAttributes(
|
||||
attribute.String("stellar_response", rawResponse),
|
||||
))
|
||||
span.SetStatus(codes.Ok, "submission response received")
|
||||
slog.InfoContext(ctx, "kuknos transaction submission response",
|
||||
"settlement_id", record.ID,
|
||||
"source_transaction_id", record.SourceTxID,
|
||||
"idempotency_key", record.IdempotencyKey,
|
||||
"network", record.Network,
|
||||
"attempt", record.Attempts,
|
||||
"status_code", resp.StatusCode,
|
||||
"transaction_hash", response.Hash,
|
||||
"transaction_result_code", response.Extras.ResultCodes.Transaction,
|
||||
"stellar_response", rawResponse,
|
||||
"duration_ms", latency.Milliseconds(),
|
||||
)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || response.Hash == "" {
|
||||
err := fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, response.Extras.ResultCodes.Transaction)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
if response.Extras.ResultCodes.Transaction == "tx_bad_seq" {
|
||||
return "", ledger.PermanentSubmissionError{Err: err}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return response.Hash, nil
|
||||
}
|
||||
|
||||
func (s Submitter) fail(ctx context.Context, span trace.Span, record ledger.Settlement, err error) (string, error) {
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
slog.ErrorContext(ctx, "kuknos transaction submission failed",
|
||||
"settlement_id", record.ID,
|
||||
"source_transaction_id", record.SourceTxID,
|
||||
"idempotency_key", record.IdempotencyKey,
|
||||
"network", record.Network,
|
||||
"attempt", record.Attempts,
|
||||
"error", err,
|
||||
)
|
||||
return "", err
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Configure installs the process-wide JSON logger used by every GL adapter.
|
||||
func Configure(service string, level slog.Leveler) *slog.Logger {
|
||||
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: level,
|
||||
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
|
||||
switch attr.Key {
|
||||
case slog.TimeKey:
|
||||
attr.Key = "timestamp"
|
||||
case slog.LevelKey:
|
||||
attr.Key = "severity"
|
||||
attr.Value = slog.StringValue(levelName(attr.Value))
|
||||
case slog.MessageKey:
|
||||
attr.Key = "message"
|
||||
case slog.SourceKey:
|
||||
attr.Key = "source"
|
||||
}
|
||||
return attr
|
||||
},
|
||||
})
|
||||
logger := slog.New(&traceHandler{Handler: handler}).With("service", service)
|
||||
slog.SetDefault(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
func levelName(value slog.Value) string {
|
||||
if value.Kind() == slog.KindInt64 {
|
||||
return slog.Level(value.Int64()).String()
|
||||
}
|
||||
if level, ok := value.Any().(slog.Level); ok {
|
||||
return level.String()
|
||||
}
|
||||
return value.String()
|
||||
}
|
||||
|
||||
type traceHandler struct{ slog.Handler }
|
||||
|
||||
func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error {
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if spanContext.IsValid() {
|
||||
record.AddAttrs(slog.String("trace_id", spanContext.TraceID().String()), slog.String("span_id", spanContext.SpanID().String()))
|
||||
}
|
||||
return h.Handler.Handle(ctx, record)
|
||||
}
|
||||
|
||||
func (h *traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return &traceHandler{Handler: h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
func (h *traceHandler) WithGroup(name string) slog.Handler {
|
||||
return &traceHandler{Handler: h.Handler.WithGroup(name)}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gl/infrastructure/config"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// Setup configures tracing without making telemetry availability a startup dependency.
|
||||
func Setup(ctx context.Context, cfg config.TelemetryConfig, service, environment string) func(context.Context) {
|
||||
if value := os.Getenv("OTEL_SERVICE_NAME"); value != "" { service = value }
|
||||
if value := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"); value != "" { cfg.Endpoint = value } else if value := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); value != "" { cfg.Endpoint = value }
|
||||
if value := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"); value != "" { cfg.Protocol = value }
|
||||
if value := os.Getenv("OTEL_TRACES_SAMPLER"); value != "" { cfg.Sampler = value }; if value := os.Getenv("OTEL_TRACES_SAMPLER_ARG"); value != "" { cfg.SamplerArg = value }
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
|
||||
if !cfg.Enabled || cfg.Endpoint == "" || (cfg.Protocol != "grpc" && cfg.Protocol != "http/protobuf") { if cfg.Enabled { slog.Warn("OpenTelemetry disabled due to invalid configuration") }; return func(context.Context) {} }
|
||||
var exp sdktrace.SpanExporter; var err error
|
||||
if cfg.Protocol == "grpc" { options := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint), otlptracegrpc.WithTimeout(timeout(cfg.ExportTimeout)), otlptracegrpc.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracegrpc.WithInsecure()) }; exp, err = otlptracegrpc.New(ctx, options...) } else { options := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.Endpoint), otlptracehttp.WithTimeout(timeout(cfg.ExportTimeout)), otlptracehttp.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracehttp.WithInsecure()) }; exp, err = otlptracehttp.New(ctx, options...) }
|
||||
if err != nil { slog.Warn("OpenTelemetry exporter unavailable; tracing disabled", "error", err); return func(context.Context) {} }
|
||||
host, _ := os.Hostname(); res, err := resource.New(ctx, resource.WithAttributes(attribute.String("service.name", service), attribute.String("service.namespace", "darano"), attribute.String("deployment.environment.name", environment), attribute.String("host.name", host))); if err != nil { slog.Warn("OpenTelemetry resource setup failed", "error", err); return func(context.Context) {} }
|
||||
ratio, parseErr := strconv.ParseFloat(cfg.SamplerArg, 64); if cfg.Sampler == "parentbased_traceidratio" && (parseErr != nil || ratio < 0 || ratio > 1) { slog.Warn("OpenTelemetry disabled due to invalid sampler", "error", fmt.Errorf("invalid sampler-arg")); return func(context.Context) {} }
|
||||
provider := sdktrace.NewTracerProvider(sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), sdktrace.WithBatcher(exp, sdktrace.WithMaxQueueSize(2048), sdktrace.WithBatchTimeout(timeout(cfg.BatchTimeout)), sdktrace.WithExportTimeout(timeout(cfg.ExportTimeout))))
|
||||
otel.SetTracerProvider(provider); return func(stop context.Context) { stop, cancel := context.WithTimeout(stop, timeout(cfg.ShutdownTimeout)); defer cancel(); if err := provider.Shutdown(stop); err != nil { slog.Warn("OpenTelemetry shutdown failed", "error", err) } }
|
||||
}
|
||||
func timeout(value time.Duration) time.Duration { if value > 0 { return value }; return 5*time.Second }
|
||||
var _ = strings.TrimSpace
|
||||
@@ -0,0 +1,243 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
applicationledger "gl/application/ledger"
|
||||
applicationsettlement "gl/application/settlement"
|
||||
domain "gl/domain/ledger"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
grpcadapter "gl/interface/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// Opt-in real PostgreSQL proof. Each run owns a new schema and removes only it.
|
||||
func TestAuthorityWithPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("GL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set GL_TEST_DATABASE_URL to a disposable PostgreSQL database")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
admin, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
schema := fmt.Sprintf("gl_authority_test_%d", time.Now().UnixNano())
|
||||
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer stop()
|
||||
if _, err := admin.Exec(cleanup, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.ConnConfig.RuntimeParams["search_path"] = schema
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
database := &Pool{pool: pool}
|
||||
if err := Migrate(ctx, database); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository := NewJournalRepository(database)
|
||||
service := applicationledger.NewService(repository, nil)
|
||||
settlementRepo := NewSettlementRepository(database)
|
||||
settlements := applicationsettlement.NewService(settlementRepo)
|
||||
handler := grpcadapter.NewHandler(nil, service, settlements).WithSettlementAdminToken("test-token")
|
||||
authorized := metadata.NewIncomingContext(ctx, metadata.Pairs("x-admin-token", "test-token"))
|
||||
|
||||
account := func(class domain.AccountClass, owner string) domain.AccountReference {
|
||||
return domain.AccountReference{Class: class, OwnerType: "user", OwnerID: owner, AssetID: 7}
|
||||
}
|
||||
command := func(key string, from, to domain.AccountReference, amount string) applicationledger.AppendJournalCommand {
|
||||
credit, err := domain.ParseAmount(amount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return applicationledger.AppendJournalCommand{
|
||||
SourceService: "internal", SourceTransactionID: key, IdempotencyKey: key, EffectKind: "transfer", EventVersion: 1,
|
||||
ActorID: "operator", OccurredAt: time.Unix(100, 0).UTC(),
|
||||
Entries: []applicationledger.EntryCommand{
|
||||
{LineNumber: 1, Account: from, Amount: credit.Negate().String()},
|
||||
{LineNumber: 2, Account: to, Amount: credit.String()},
|
||||
},
|
||||
}
|
||||
}
|
||||
alice := account(domain.AccountClassUserAvailable, "alice")
|
||||
bob := account(domain.AccountClassUserAvailable, "bob")
|
||||
opening := command("opening", domain.AccountReference{Class: domain.AccountClassTreasury, AssetID: 7}, alice, "100")
|
||||
if _, err := service.AppendJournal(ctx, opening); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transfer := command("transfer-1", alice, bob, "60")
|
||||
if _, err := service.PostInternalJournal(ctx, transfer); !errors.Is(err, domain.ErrModeRequired) {
|
||||
t.Fatalf("NORMAL admitted internal execution: %v", err)
|
||||
}
|
||||
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("unauthorized toggle: %v", err)
|
||||
}
|
||||
if _, _, err := settlements.Enqueue(ctx, "wallet", "t1", "s1", "kuknos", "signed-xdr"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by='in-flight'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 0, "operator", "outage", false); !errors.Is(err, domain.ErrSettlementInFlight) {
|
||||
t.Fatalf("in-flight toggle: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by=''"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := handler.SetOperatingMode(authorized, &ledgerv1.SetOperatingModeRequest{Mode: "GL_ONLY", ActorId: "operator", Reason: "outage"})
|
||||
if err != nil || state.GetRevision() != 1 {
|
||||
t.Fatalf("enable GL_ONLY: %+v %v", state, err)
|
||||
}
|
||||
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 0, "operator", "stale", true); !errors.Is(err, domain.ErrModeConflict) {
|
||||
t.Fatalf("stale revision accepted: %v", err)
|
||||
}
|
||||
if claimed, err := settlementRepo.ClaimDue(ctx, "worker", 10, time.Now()); err != nil || len(claimed) != 0 {
|
||||
t.Fatalf("settlements claimed in GL_ONLY: %v %v", claimed, err)
|
||||
}
|
||||
if _, _, err := settlements.Enqueue(ctx, "wallet", "t2", "s2", "kuknos", "signed-xdr"); !errors.Is(err, domain.ErrModeRequired) {
|
||||
t.Fatalf("settlement admitted: %v", err)
|
||||
}
|
||||
|
||||
// Two simultaneous spends cannot consume the same opening balance.
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, key := range []string{"transfer-1", "transfer-2"} {
|
||||
next := command(key, alice, bob, "60")
|
||||
wg.Add(1)
|
||||
go func(next applicationledger.AppendJournalCommand) {
|
||||
defer wg.Done()
|
||||
_, err := service.PostInternalJournal(ctx, next)
|
||||
results <- err
|
||||
}(next)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
successes, insufficient := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
successes++
|
||||
} else if errors.Is(err, domain.ErrInsufficientFunds) {
|
||||
insufficient++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || insufficient != 1 {
|
||||
t.Fatalf("concurrent spends: success=%d insufficient=%d", successes, insufficient)
|
||||
}
|
||||
var successfulKey string
|
||||
if err := database.QueryRow(ctx, "SELECT idempotency_key FROM journals WHERE metadata->>'gl_execution'='GL_ONLY'").Scan(&successfulKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transfer = command(successfulKey, alice, bob, "60")
|
||||
repeated, err := service.PostInternalJournal(ctx, transfer)
|
||||
if err != nil || !repeated.AlreadyExisted {
|
||||
t.Fatalf("idempotent internal retry: %+v %v", repeated, err)
|
||||
}
|
||||
conflicting := command(successfulKey, alice, bob, "1")
|
||||
if _, err := service.PostInternalJournal(ctx, conflicting); !errors.Is(err, domain.ErrIdempotencyConflict) {
|
||||
t.Fatalf("conflicting retry: %v", err)
|
||||
}
|
||||
frozen := account(domain.AccountClassUserFrozen, "alice")
|
||||
if _, err := service.PostInternalJournal(ctx, command("freeze", alice, frozen, "10")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.PostInternalJournal(ctx, command("release", frozen, alice, "4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The legacy ingestion API cannot execute new effects in GL_ONLY.
|
||||
if _, err := service.AppendJournal(ctx, command("legacy-overspend", alice, bob, "100")); !errors.Is(err, domain.ErrModeRequired) {
|
||||
t.Fatalf("legacy bypass: %v", err)
|
||||
}
|
||||
response, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "alice"})
|
||||
if err != nil || len(response.GetBalances()) != 1 {
|
||||
t.Fatalf("user balance: %+v %v", response, err)
|
||||
}
|
||||
balance := response.Balances[0]
|
||||
if balance.Available != "34" || balance.Frozen != "6" || balance.Total != "40" {
|
||||
t.Fatalf("incorrect balance: %+v", balance)
|
||||
}
|
||||
var sum string
|
||||
if err := database.QueryRow(ctx, `SELECT sum(e.amount)::text FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')`).Scan(&sum); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total, err := domain.ParseAmount(sum)
|
||||
if err != nil || total.String() != "100" {
|
||||
t.Fatalf("conservation failed: %s %v", sum, err)
|
||||
}
|
||||
|
||||
// Exercise the internal RPC mapping with a real commit.
|
||||
posted, err := handler.PostInternalJournal(authorized, &ledgerv1.AppendJournalRequest{
|
||||
SourceService: "internal", SourceTransactionId: "rpc", IdempotencyKey: "rpc", EffectKind: "transfer", EventVersion: 1,
|
||||
ActorId: "operator", OccurredAt: timestamppb.New(time.Unix(100, 0)),
|
||||
Entries: []*ledgerv1.JournalEntry{
|
||||
{LineNumber: 1, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "alice", AssetId: 7}, Amount: "-1"},
|
||||
{LineNumber: 2, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "bob", AssetId: 7}, Amount: "1"},
|
||||
},
|
||||
})
|
||||
if err != nil || posted.GetJournal().GetJournalId() == "" {
|
||||
t.Fatalf("internal RPC: %+v %v", posted, err)
|
||||
}
|
||||
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", false); !errors.Is(err, domain.ErrModeConflict) {
|
||||
t.Fatalf("unreconciled recovery: %v", err)
|
||||
}
|
||||
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repeated, err = service.PostInternalJournal(ctx, transfer)
|
||||
if err != nil || !repeated.AlreadyExisted {
|
||||
t.Fatalf("retry after mode change: %+v %v", repeated, err)
|
||||
}
|
||||
if _, err := service.PostInternalJournal(ctx, command("after-recovery", alice, bob, "1")); !errors.Is(err, domain.ErrModeRequired) {
|
||||
t.Fatalf("internal post after recovery: %v", err)
|
||||
}
|
||||
unknownAsset := int64(99)
|
||||
zero, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "unknown", AssetId: &unknownAsset})
|
||||
if err != nil || len(zero.GetBalances()) != 1 || zero.Balances[0].Total != "0" {
|
||||
t.Fatalf("unknown user balance: %+v %v", zero, err)
|
||||
}
|
||||
// New instances read the same persisted authority state.
|
||||
if persisted, err := NewJournalRepository(database).OperatingMode(ctx); err != nil || persisted.Mode != domain.ModeNormal || persisted.Revision != 2 {
|
||||
t.Fatalf("persisted mode: %+v %v", persisted, err)
|
||||
}
|
||||
if _, err := service.AppendJournal(ctx, command("negative-baseline", alice, bob, "1000")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 2, "operator", "uncorrected books", false); !errors.Is(err, domain.ErrInsufficientFunds) {
|
||||
t.Fatalf("negative opening balances accepted: %v", err)
|
||||
}
|
||||
var audits int64
|
||||
if err := database.QueryRow(ctx, "SELECT count(*) FROM ledger_mode_changes").Scan(&audits); err != nil || audits != 2 {
|
||||
t.Fatalf("mode audit: %d %v", audits, err)
|
||||
}
|
||||
if _, err := database.Exec(ctx, "DELETE FROM ledger_mode_changes"); err == nil {
|
||||
t.Fatal("mode audit is mutable")
|
||||
}
|
||||
if _, err := database.Exec(ctx, "UPDATE journals SET actor_id='changed'"); err == nil {
|
||||
t.Fatal("journals are mutable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
func authorityError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "PGL01":
|
||||
return ledger.ErrModeRequired
|
||||
case "PGL02":
|
||||
return ledger.ErrInsufficientFunds
|
||||
case "40P01", "40001":
|
||||
return ledger.ErrConcurrentPosting
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *JournalRepository) OperatingMode(ctx context.Context) (ledger.OperatingState, error) {
|
||||
return scanOperatingState(r.database.QueryRow(ctx, operatingStateSQL))
|
||||
}
|
||||
|
||||
func scanOperatingState(row Row) (ledger.OperatingState, error) {
|
||||
var state ledger.OperatingState
|
||||
var mode string
|
||||
err := row.Scan(&mode, &state.Revision, &state.ActorID, &state.Reason, &state.ChangedAt)
|
||||
state.Mode = ledger.OperatingMode(mode)
|
||||
return state, err
|
||||
}
|
||||
|
||||
const operatingStateSQL = "SELECT mode, revision, actor_id, reason, changed_at FROM ledger_operating_state WHERE singleton"
|
||||
|
||||
func (r *JournalRepository) SetOperatingMode(ctx context.Context, mode ledger.OperatingMode, revision int64, actor, reason string, reconciled bool) (ledger.OperatingState, error) {
|
||||
tx, err := r.database.Begin(ctx)
|
||||
if err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
current, err := scanOperatingState(tx.QueryRow(ctx, operatingStateSQL+" FOR UPDATE"))
|
||||
if err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
if current.Revision != revision {
|
||||
return ledger.OperatingState{}, ledger.ErrModeConflict
|
||||
}
|
||||
if current.Mode == mode {
|
||||
return current, nil
|
||||
}
|
||||
if mode == ledger.ModeNormal && !reconciled {
|
||||
return ledger.OperatingState{}, fmt.Errorf("%w: reconciliation must be confirmed before returning to NORMAL", ledger.ErrModeConflict)
|
||||
}
|
||||
if mode == ledger.ModeGLOnly {
|
||||
var inFlight bool
|
||||
if err := tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM kuknos_settlements WHERE locked_by <> '')").Scan(&inFlight); err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
if inFlight {
|
||||
return ledger.OperatingState{}, ledger.ErrSettlementInFlight
|
||||
}
|
||||
var negativeBalance bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS (
|
||||
SELECT a.id FROM ledger_accounts a JOIN journal_entries e ON e.account_id=a.id
|
||||
JOIN journals j ON j.id=e.journal_id AND j.sealed_at IS NOT NULL
|
||||
WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')
|
||||
GROUP BY a.id HAVING sum(e.amount)<0
|
||||
)`).Scan(&negativeBalance); err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
if negativeBalance {
|
||||
return ledger.OperatingState{}, ledger.ErrInsufficientFunds
|
||||
}
|
||||
}
|
||||
state, err := scanOperatingState(tx.QueryRow(ctx, `UPDATE ledger_operating_state
|
||||
SET mode=$1, revision=revision+1, actor_id=$2, reason=$3, changed_at=clock_timestamp()
|
||||
WHERE singleton RETURNING mode, revision, actor_id, reason, changed_at`, string(mode), actor, reason))
|
||||
if err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ledger_mode_changes
|
||||
(revision, previous_mode, mode, actor_id, reason, reconciliation_confirmed, changed_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`, state.Revision, string(current.Mode), string(mode), actor, reason, reconciled, state.ChangedAt); err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ledger.OperatingState{}, err
|
||||
}
|
||||
slog.WarnContext(ctx, "ledger operating mode changed", "previous_mode", current.Mode, "mode", mode, "revision", state.Revision, "actor_id", actor, "reason", reason)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// One statement provides a consistent snapshot across available and frozen funds.
|
||||
func (r *JournalRepository) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]ledger.UserAssetBalance, error) {
|
||||
rows, err := r.database.Query(ctx, `SELECT a.asset_id,
|
||||
COALESCE(sum(e.amount) FILTER (WHERE a.class='USER_AVAILABLE'),0)::text,
|
||||
COALESCE(sum(e.amount) FILTER (WHERE a.class='USER_FROZEN'),0)::text
|
||||
FROM ledger_accounts a
|
||||
LEFT JOIN journal_entries e ON e.account_id=a.id
|
||||
LEFT JOIN journals j ON j.id=e.journal_id AND j.sealed_at IS NOT NULL
|
||||
WHERE a.owner_type=$1 AND a.owner_id=$2
|
||||
AND a.class IN ('USER_AVAILABLE','USER_FROZEN')
|
||||
AND ($3::bigint IS NULL OR a.asset_id=$3)
|
||||
AND (e.journal_id IS NULL OR j.id IS NOT NULL)
|
||||
GROUP BY a.asset_id ORDER BY a.asset_id`, ownerType, ownerID, assetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []ledger.UserAssetBalance{}
|
||||
for rows.Next() {
|
||||
var balance ledger.UserAssetBalance
|
||||
var available, frozen string
|
||||
if err := rows.Scan(&balance.AssetID, &available, &frozen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balance.Available, err = ledger.ParseAmount(available)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balance.Frozen, err = ledger.ParseAmount(frozen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, balance)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assetID != nil && len(result) == 0 {
|
||||
result = append(result, ledger.UserAssetBalance{AssetID: *assetID})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -98,3 +98,7 @@ type txAdapter struct {
|
||||
func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row {
|
||||
return t.Tx.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
func (t txAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) {
|
||||
return t.Tx.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gl/domain/ledger"
|
||||
|
||||
@@ -47,6 +48,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
if classified := authorityError(err); classified != nil {
|
||||
err = classified
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -75,13 +79,34 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
}
|
||||
if err != nil {
|
||||
var postgresError *pgconn.PgError
|
||||
if classified := authorityError(err); classified != nil {
|
||||
return AppendResult{}, classified
|
||||
}
|
||||
if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
|
||||
return AppendResult{}, ledger.ErrAlreadyReversed
|
||||
}
|
||||
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range journal.Entries {
|
||||
// Resolve and lock accounts in stable order across every writer.
|
||||
entries := append([]ledger.Entry(nil), journal.Entries...)
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
left, right := entries[i].Account, entries[j].Account
|
||||
if left.Class != right.Class {
|
||||
return left.Class < right.Class
|
||||
}
|
||||
if left.OwnerType != right.OwnerType {
|
||||
return left.OwnerType < right.OwnerType
|
||||
}
|
||||
if left.OwnerID != right.OwnerID {
|
||||
return left.OwnerID < right.OwnerID
|
||||
}
|
||||
if left.AssetID != right.AssetID {
|
||||
return left.AssetID < right.AssetID
|
||||
}
|
||||
return entries[i].LineNumber < entries[j].LineNumber
|
||||
})
|
||||
for _, entry := range entries {
|
||||
var accountID int64
|
||||
err = tx.QueryRow(ctx, insertAccountSQL,
|
||||
entry.Account.Class,
|
||||
@@ -114,6 +139,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
|
||||
if classified := authorityError(err); classified != nil {
|
||||
return AppendResult{}, classified
|
||||
}
|
||||
return AppendResult{}, fmt.Errorf("seal journal: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
|
||||
@@ -31,6 +31,10 @@ func (r fakeRow) Scan(dest ...any) error {
|
||||
*target = value.(string)
|
||||
case *int64:
|
||||
*target = value.(int64)
|
||||
case *int:
|
||||
*target = value.(int)
|
||||
case *ledger.SettlementStatus:
|
||||
*target = value.(ledger.SettlementStatus)
|
||||
case *bool:
|
||||
*target = value.(bool)
|
||||
case *uint32:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE kuknos_settlements (
|
||||
id uuid PRIMARY KEY,
|
||||
source_service text NOT NULL CHECK (source_service <> ''),
|
||||
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
|
||||
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
|
||||
status text NOT NULL CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW')),
|
||||
network text NOT NULL DEFAULT 'kuknos',
|
||||
signed_transaction_xdr text NOT NULL DEFAULT '',
|
||||
transaction_hash text NOT NULL DEFAULT '',
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
available_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
locked_at timestamptz,
|
||||
locked_by text NOT NULL DEFAULT '',
|
||||
last_error text NOT NULL DEFAULT '',
|
||||
submitted_at timestamptz,
|
||||
confirmed_at timestamptz,
|
||||
manual_review_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE INDEX kuknos_settlements_due_idx ON kuknos_settlements (status, available_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE kuknos_settlement_operator_audits (
|
||||
id bigserial PRIMARY KEY,
|
||||
settlement_id uuid NOT NULL REFERENCES kuknos_settlements(id),
|
||||
actor_id text NOT NULL CHECK (actor_id <> ''),
|
||||
action text NOT NULL CHECK (action IN ('RETRY')),
|
||||
reason text NOT NULL CHECK (reason <> ''),
|
||||
previous_status text NOT NULL,
|
||||
resulting_status text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
CREATE INDEX kuknos_settlement_operator_audits_settlement_idx
|
||||
ON kuknos_settlement_operator_audits (settlement_id, created_at DESC);
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE kuknos_reconciliation_issues (
|
||||
settlement_id uuid PRIMARY KEY REFERENCES kuknos_settlements(id),
|
||||
issue_type text NOT NULL CHECK (issue_type IN ('MISSING_LEDGER_CONFIRMATION','TRANSACTION_HASH_MISMATCH')),
|
||||
expected_hash text NOT NULL DEFAULT '',
|
||||
actual_hash text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','RESOLVED')),
|
||||
first_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
last_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
resolved_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX kuknos_reconciliation_issues_open_idx
|
||||
ON kuknos_reconciliation_issues (status, last_detected_at DESC);
|
||||
@@ -0,0 +1,95 @@
|
||||
CREATE TABLE ledger_operating_state (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
mode text NOT NULL CHECK (mode IN ('NORMAL','GL_ONLY')),
|
||||
revision bigint NOT NULL CHECK (revision >= 0),
|
||||
actor_id text NOT NULL, reason text NOT NULL,
|
||||
changed_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
INSERT INTO ledger_operating_state (mode,revision,actor_id,reason) VALUES ('NORMAL',0,'system','initial mode');
|
||||
CREATE TABLE ledger_mode_changes (
|
||||
revision bigint PRIMARY KEY,
|
||||
previous_mode text NOT NULL CHECK (previous_mode IN ('NORMAL','GL_ONLY')),
|
||||
mode text NOT NULL CHECK (mode IN ('NORMAL','GL_ONLY')),
|
||||
actor_id text NOT NULL CHECK (btrim(actor_id) <> ''),
|
||||
reason text NOT NULL CHECK (btrim(reason) <> ''),
|
||||
reconciliation_confirmed boolean NOT NULL, changed_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE TRIGGER mode_changes_reject_mutation BEFORE UPDATE OR DELETE ON ledger_mode_changes
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
|
||||
CREATE TRIGGER operating_state_reject_delete BEFORE DELETE ON ledger_operating_state
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
|
||||
|
||||
-- A shared lock is held through each posting; toggles drain writers.
|
||||
CREATE FUNCTION guard_journal_authority() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE current_mode text;
|
||||
BEGIN
|
||||
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton FOR SHARE;
|
||||
IF NEW.metadata->>'gl_execution'='GL_ONLY' AND current_mode<>'GL_ONLY'
|
||||
AND NOT EXISTS (SELECT 1 FROM journals WHERE idempotency_key=NEW.idempotency_key) THEN
|
||||
RAISE EXCEPTION 'GL_ONLY mode is required' USING ERRCODE='PGL01';
|
||||
END IF;
|
||||
IF current_mode='GL_ONLY' AND COALESCE(NEW.metadata->>'gl_execution','')<>'GL_ONLY'
|
||||
AND NOT EXISTS (SELECT 1 FROM journals WHERE idempotency_key=NEW.idempotency_key) THEN
|
||||
RAISE EXCEPTION 'new GL_ONLY effects must use internal execution' USING ERRCODE='PGL01';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER journals_guard_authority BEFORE INSERT ON journals
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_journal_authority();
|
||||
|
||||
-- Both credits and debits lock accounts, including normal recorders.
|
||||
CREATE OR REPLACE FUNCTION guard_entry_insert() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id=NEW.journal_id) THEN
|
||||
RAISE EXCEPTION 'cannot append to a sealed journal' USING ERRCODE='55000';
|
||||
END IF;
|
||||
PERFORM id FROM ledger_accounts WHERE id=NEW.account_id
|
||||
AND class IN ('USER_AVAILABLE','USER_FROZEN') FOR UPDATE;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_journal_seal() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE current_mode text;
|
||||
BEGIN
|
||||
IF OLD.sealed_at IS NOT NULL OR NEW.sealed_at IS NULL
|
||||
OR (to_jsonb(NEW)-'sealed_at') IS DISTINCT FROM (to_jsonb(OLD)-'sealed_at') THEN
|
||||
RAISE EXCEPTION 'journals are immutable except for initial sealing' USING ERRCODE='55000';
|
||||
END IF;
|
||||
IF (SELECT count(*) FROM journal_entries WHERE journal_id=NEW.id)<2 THEN
|
||||
RAISE EXCEPTION 'journal requires at least two entries' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF EXISTS (SELECT asset_id FROM journal_entries WHERE journal_id=NEW.id GROUP BY asset_id HAVING sum(amount)<>0) THEN
|
||||
RAISE EXCEPTION 'journal is not balanced per asset' USING ERRCODE='23514';
|
||||
END IF;
|
||||
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton;
|
||||
IF current_mode='GL_ONLY' THEN
|
||||
IF NEW.blockchain_network<>'' OR NEW.blockchain_transaction_hash<>'' OR NEW.blockchain_ledger_sequence<>''
|
||||
OR EXISTS (SELECT 1 FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id
|
||||
WHERE e.journal_id=NEW.id AND a.class='EXTERNAL_BLOCKCHAIN') THEN
|
||||
RAISE EXCEPTION 'external blockchain movements are unavailable in GL_ONLY' USING ERRCODE='PGL01';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT a.id FROM ledger_accounts a JOIN journal_entries e ON e.account_id=a.id JOIN journals j ON j.id=e.journal_id
|
||||
WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')
|
||||
AND a.id IN (SELECT account_id FROM journal_entries WHERE journal_id=NEW.id)
|
||||
AND (j.sealed_at IS NOT NULL OR j.id=NEW.id)
|
||||
GROUP BY a.id HAVING sum(e.amount)<0
|
||||
) THEN
|
||||
RAISE EXCEPTION 'insufficient ledger funds' USING ERRCODE='PGL02';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
|
||||
CREATE FUNCTION guard_settlement_authority() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE current_mode text;
|
||||
BEGIN
|
||||
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton FOR SHARE;
|
||||
IF current_mode='GL_ONLY'
|
||||
AND NOT EXISTS (SELECT 1 FROM kuknos_settlements WHERE idempotency_key=NEW.idempotency_key) THEN
|
||||
RAISE EXCEPTION 'blockchain settlement is disabled in GL_ONLY' USING ERRCODE='PGL01';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END; $$;
|
||||
CREATE TRIGGER settlements_guard_authority BEFORE INSERT ON kuknos_settlements
|
||||
FOR EACH ROW EXECUTE FUNCTION guard_settlement_authority();
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE kuknos_settlements DROP CONSTRAINT kuknos_settlements_status_check;
|
||||
ALTER TABLE kuknos_settlements ADD CONSTRAINT kuknos_settlements_status_check
|
||||
CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW','IGNORED'));
|
||||
|
||||
ALTER TABLE kuknos_settlement_operator_audits DROP CONSTRAINT kuknos_settlement_operator_audits_action_check;
|
||||
ALTER TABLE kuknos_settlement_operator_audits ADD CONSTRAINT kuknos_settlement_operator_audits_action_check
|
||||
CHECK (action IN ('RETRY','IGNORE'));
|
||||
@@ -86,6 +86,42 @@ func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *JournalRepository) SettlementDashboard(ctx context.Context, limit int) (explorer.SettlementStats, []explorer.Settlement, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 12
|
||||
}
|
||||
var stats explorer.SettlementStats
|
||||
if err := r.database.QueryRow(ctx, `SELECT
|
||||
count(*) FILTER (WHERE status='PENDING'),
|
||||
count(*) FILTER (WHERE status='RETRYABLE'),
|
||||
count(*) FILTER (WHERE status='CONFIRMED'),
|
||||
count(*) FILTER (WHERE status='MANUAL_REVIEW')
|
||||
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.Confirmed, &stats.ManualReview); err != nil {
|
||||
return explorer.SettlementStats{}, nil, fmt.Errorf("read settlement stats: %w", err)
|
||||
}
|
||||
rows, err := r.database.Query(ctx, `SELECT id, source_service, source_transaction_id, status,
|
||||
network, transaction_hash, attempts, last_error, updated_at
|
||||
FROM kuknos_settlements ORDER BY updated_at DESC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return explorer.SettlementStats{}, nil, fmt.Errorf("list recent settlements: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
settlements := make([]explorer.Settlement, 0, limit)
|
||||
for rows.Next() {
|
||||
var settlement explorer.Settlement
|
||||
if err := rows.Scan(&settlement.ID, &settlement.SourceService, &settlement.SourceTransactionID,
|
||||
&settlement.Status, &settlement.Network, &settlement.TransactionHash, &settlement.Attempts,
|
||||
&settlement.LastError, &settlement.UpdatedAt); err != nil {
|
||||
return explorer.SettlementStats{}, nil, fmt.Errorf("scan recent settlement: %w", err)
|
||||
}
|
||||
settlements = append(settlements, settlement)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return explorer.SettlementStats{}, nil, fmt.Errorf("iterate recent settlements: %w", err)
|
||||
}
|
||||
return stats, settlements, nil
|
||||
}
|
||||
|
||||
func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) {
|
||||
if assetLimit <= 0 || assetLimit > 20 {
|
||||
assetLimit = 5
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type SettlementRepository struct{ database Database }
|
||||
|
||||
type ReconciliationStats struct {
|
||||
Open, Detected, Resolved int64
|
||||
}
|
||||
|
||||
type txQueryer interface {
|
||||
Query(context.Context, string, ...any) (Rows, error)
|
||||
}
|
||||
|
||||
func NewSettlementRepository(database Database) *SettlementRepository {
|
||||
return &SettlementRepository{database: database}
|
||||
}
|
||||
|
||||
// Existing normal-mode Wallet clients require SettlementReady; a GL-only
|
||||
// cutover must not let them continue admitting blockchain-dependent effects.
|
||||
func (r *SettlementRepository) SettlementAllowed(ctx context.Context) (bool, error) {
|
||||
var mode string
|
||||
err := r.database.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton").Scan(&mode)
|
||||
return mode == string(ledger.ModeNormal), err
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) {
|
||||
var stats ledger.SettlementStats
|
||||
err := r.database.QueryRow(ctx, `SELECT
|
||||
COUNT(*) FILTER (WHERE status='PENDING'),
|
||||
COUNT(*) FILTER (WHERE status='RETRYABLE'),
|
||||
COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'),
|
||||
COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0)
|
||||
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds)
|
||||
if err != nil {
|
||||
return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) {
|
||||
if err := s.Validate(); err != nil {
|
||||
return ledger.Settlement{}, false, err
|
||||
}
|
||||
var inserted string
|
||||
err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements
|
||||
(id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`,
|
||||
s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted)
|
||||
if err == nil {
|
||||
return s, false, nil
|
||||
}
|
||||
if err == pgx.ErrNoRows {
|
||||
var existingXDR string
|
||||
if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil {
|
||||
return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err)
|
||||
}
|
||||
if existingXDR != s.SignedTransactionXDR {
|
||||
return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict
|
||||
}
|
||||
existing, err := r.Get(ctx, inserted, "")
|
||||
return existing, true, err
|
||||
}
|
||||
if classified := authorityError(err); classified != nil {
|
||||
return ledger.Settlement{}, false, classified
|
||||
}
|
||||
return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err)
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) {
|
||||
var s ledger.Settlement
|
||||
err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR)
|
||||
if err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) {
|
||||
tx, err := r.database.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var mode string
|
||||
if err := tx.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton FOR SHARE").Scan(&mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mode == string(ledger.ModeGLOnly) {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil {
|
||||
return nil, fmt.Errorf("release stale settlement locks: %w", err)
|
||||
}
|
||||
queryer, ok := tx.(txQueryer)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("settlement transaction does not support row queries")
|
||||
}
|
||||
rows, err := queryer.Query(ctx, `WITH due AS (
|
||||
SELECT id FROM kuknos_settlements
|
||||
WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by=''
|
||||
ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2
|
||||
)
|
||||
UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp()
|
||||
FROM due WHERE s.id=due.id
|
||||
RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []ledger.Settlement
|
||||
for rows.Next() {
|
||||
var s ledger.Settlement
|
||||
if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error {
|
||||
_, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) {
|
||||
tx, err := r.database.Begin(ctx)
|
||||
if err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var previous ledger.SettlementStatus
|
||||
if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err)
|
||||
}
|
||||
if previous == ledger.SettlementConfirmed || previous == ledger.SettlementIgnored {
|
||||
return ledger.Settlement{}, fmt.Errorf("confirmed or ignored settlement cannot be retried")
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err)
|
||||
}
|
||||
return r.Get(ctx, id, "")
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) IgnoreByOperator(ctx context.Context, id, actorID, reason string) (ledger.Settlement, error) {
|
||||
tx, err := r.database.Begin(ctx)
|
||||
if err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("begin operator dismissal: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var previous, lockedBy string
|
||||
if err := tx.QueryRow(ctx, `SELECT status, locked_by FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous, &lockedBy); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("lock settlement for dismissal: %w", err)
|
||||
}
|
||||
if lockedBy != "" || (previous != string(ledger.SettlementManualReview) && previous != string(ledger.SettlementRetryable)) {
|
||||
return ledger.Settlement{}, fmt.Errorf("only unlocked settlements requiring review or retry can be ignored")
|
||||
}
|
||||
// Preserve the error and signed payload as evidence; this is not a payment confirmation.
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='IGNORED', updated_at=clock_timestamp() WHERE id=$1`, id); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("ignore settlement: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'IGNORE',$3,$4,'IGNORED')`, id, actorID, reason, previous); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("audit operator dismissal: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues SET status='RESOLVED', resolved_at=clock_timestamp() WHERE settlement_id=$1 AND status='OPEN'`, id); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("resolve dismissed settlement issues: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ledger.Settlement{}, fmt.Errorf("commit operator dismissal: %w", err)
|
||||
}
|
||||
return r.Get(ctx, id, "")
|
||||
}
|
||||
|
||||
// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements
|
||||
// whose latest GL lifecycle evidence is missing or has a different hash.
|
||||
func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) {
|
||||
if grace <= 0 {
|
||||
grace = 10 * time.Minute
|
||||
}
|
||||
tx, err := r.database.Begin(ctx)
|
||||
if err != nil {
|
||||
return ReconciliationStats{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var detected int64
|
||||
err = tx.QueryRow(ctx, `WITH inconsistent AS (
|
||||
SELECT s.id,
|
||||
CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type,
|
||||
s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash
|
||||
FROM kuknos_settlements s
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, blockchain_transaction_hash FROM transaction_events
|
||||
WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id
|
||||
ORDER BY event_version DESC, recorded_at DESC LIMIT 1
|
||||
) e ON true
|
||||
WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval
|
||||
AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash)
|
||||
), upserted AS (
|
||||
INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash)
|
||||
SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent
|
||||
ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type,
|
||||
expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash,
|
||||
status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL
|
||||
WHERE kuknos_reconciliation_issues.status<>'OPEN'
|
||||
OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type
|
||||
OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash
|
||||
OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash
|
||||
RETURNING 1
|
||||
) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected)
|
||||
if err != nil {
|
||||
return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err)
|
||||
}
|
||||
result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp()
|
||||
WHERE status='OPEN' AND EXISTS (
|
||||
SELECT 1 FROM kuknos_settlements s JOIN transaction_events e
|
||||
ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id
|
||||
WHERE s.id=i.settlement_id AND s.status='CONFIRMED'
|
||||
AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>''
|
||||
)`)
|
||||
if err != nil {
|
||||
return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err)
|
||||
}
|
||||
var open int64
|
||||
if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil {
|
||||
return ReconciliationStats{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ReconciliationStats{}, err
|
||||
}
|
||||
return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
func TestIgnoreByOperatorRejectsActiveLockedAndTerminalSettlements(t *testing.T) {
|
||||
for _, state := range []struct{ status, lock string }{
|
||||
{"PENDING", ""}, {"SUBMITTED", ""}, {"CONFIRMED", ""}, {"IGNORED", ""}, {"RETRYABLE", "worker"},
|
||||
} {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{state.status, state.lock}}}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil {
|
||||
t.Fatalf("dismissed ineligible state: %+v", state)
|
||||
}
|
||||
if tx.execCount != 0 || tx.committed || !tx.rolledBack {
|
||||
t.Fatalf("ineligible dismissal changed state: %+v", tx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoreByOperatorAuditsAndResolvesAtomically(t *testing.T) {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"MANUAL_REVIEW", ""}}}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx, directRow: fakeRow{values: []any{
|
||||
"id", "wallet", "110", "wallet:110:kuknos", ledger.SettlementIgnored, "kuknos", "", 1, time.Now(), "tx_bad_seq", "signed-xdr",
|
||||
}}})
|
||||
got, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed")
|
||||
if err != nil || got.Status != ledger.SettlementIgnored || got.LastError != "tx_bad_seq" || !tx.committed || tx.execCount != 3 {
|
||||
t.Fatalf("unexpected dismissal: %+v tx=%+v err=%v", got, tx, err)
|
||||
}
|
||||
for _, failedStep := range []int{2, 3} {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"RETRYABLE", ""}}}, execErrAt: failedStep}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil || tx.committed || !tx.rolledBack {
|
||||
t.Fatalf("audit or issue failure did not roll back: %+v err=%v", tx, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredSettlementCannotBeRetried(t *testing.T) {
|
||||
tx := &fakeTx{rows: []Row{fakeRow{values: []any{ledger.SettlementIgnored}}}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
if _, err := repository.RetryByOperator(context.Background(), "id", "actor", "retry", time.Now()); err == nil || tx.execCount != 0 {
|
||||
t.Fatal("ignored settlement was queued again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) {
|
||||
tx := &fakeTx{rows: []Row{
|
||||
fakeRow{values: []any{int64(1)}},
|
||||
fakeRow{values: []any{int64(2)}},
|
||||
}}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
|
||||
stats, err := repository.ReconcileConfirmationEvidence(context.Background(), 10*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Detected != 1 || stats.Resolved != 1 || stats.Open != 2 {
|
||||
t.Fatalf("unexpected reconciliation stats: %+v", stats)
|
||||
}
|
||||
if !tx.committed || tx.execCount != 1 {
|
||||
t.Fatalf("unexpected transaction state: %+v", tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileConfirmationEvidenceRollsBackResolutionFailure(t *testing.T) {
|
||||
tx := &fakeTx{
|
||||
rows: []Row{fakeRow{values: []any{int64(1)}}},
|
||||
execErrAt: 1,
|
||||
}
|
||||
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
|
||||
|
||||
if _, err := repository.ReconcileConfirmationEvidence(context.Background(), time.Minute); err == nil {
|
||||
t.Fatal("expected resolution error")
|
||||
}
|
||||
if !tx.rolledBack || tx.committed {
|
||||
t.Fatalf("failed reconciliation was not rolled back: %+v", tx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
domain "gl/domain/ledger"
|
||||
basev1 "gl/gen/base/v1"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (h *Handler) requireAdmin(ctx context.Context) error {
|
||||
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token")
|
||||
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 {
|
||||
return status.Error(codes.PermissionDenied, "invalid administrative token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modeMessage(state domain.OperatingState) *ledgerv1.OperatingModeResponse {
|
||||
return &ledgerv1.OperatingModeResponse{Mode: string(state.Mode), Revision: state.Revision, ActorId: state.ActorID, Reason: state.Reason, ChangedAt: timestampMessage(state.ChangedAt)}
|
||||
}
|
||||
|
||||
func (h *Handler) GetOperatingMode(ctx context.Context, _ *basev1.Empty) (*ledgerv1.OperatingModeResponse, error) {
|
||||
state, err := h.ledger.OperatingMode(ctx)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return modeMessage(state), nil
|
||||
}
|
||||
|
||||
func (h *Handler) SetOperatingMode(ctx context.Context, request *ledgerv1.SetOperatingModeRequest) (*ledgerv1.OperatingModeResponse, error) {
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := h.ledger.SetOperatingMode(ctx, domain.OperatingMode(request.GetMode()), request.GetExpectedRevision(), request.GetActorId(), request.GetReason(), request.GetReconciliationConfirmed())
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return modeMessage(state), nil
|
||||
}
|
||||
|
||||
func (h *Handler) PostInternalJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) {
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command, err := appendJournalCommand(request)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
result, err := h.ledger.PostInternalJournal(ctx, command)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return &ledgerv1.AppendJournalResponse{Journal: journalMessage(result.Journal), AlreadyExisted: result.AlreadyExisted}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetUserBalances(ctx context.Context, request *ledgerv1.GetUserBalancesRequest) (*ledgerv1.GetUserBalancesResponse, error) {
|
||||
if request == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
balances, err := h.ledger.UserBalances(ctx, request.GetOwnerType(), request.GetOwnerId(), request.AssetId)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
response := &ledgerv1.GetUserBalancesResponse{OwnerType: request.GetOwnerType(), OwnerId: request.GetOwnerId()}
|
||||
for _, balance := range balances {
|
||||
response.Balances = append(response.Balances, &ledgerv1.UserAssetBalance{
|
||||
AssetId: balance.AssetID, Available: balance.Available.String(), Frozen: balance.Frozen.String(), Total: balance.Available.Add(balance.Frozen).String(),
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorityCommandsRequireOneConfiguredAdministrativeToken(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, configured string
|
||||
tokens []string
|
||||
}{
|
||||
{"unconfigured", "", []string{"token"}},
|
||||
{"missing", "token", nil},
|
||||
{"wrong", "token", []string{"wrong"}},
|
||||
{"duplicate", "token", []string{"token", "token"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler := NewHandler(nil, nil).WithSettlementAdminToken(tc.configured)
|
||||
ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{"x-admin-token": tc.tokens})
|
||||
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("toggle: %v", err)
|
||||
}
|
||||
if _, err := handler.PostInternalJournal(ctx, &ledgerv1.AppendJournalRequest{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("internal posting: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,27 @@ import (
|
||||
|
||||
"gl/application/health"
|
||||
applicationledger "gl/application/ledger"
|
||||
applicationsettlement "gl/application/settlement"
|
||||
basev1 "gl/gen/base/v1"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ledgerv1.UnimplementedGeneralLedgerServiceServer
|
||||
health *health.Service
|
||||
ledger *applicationledger.Service
|
||||
health *health.Service
|
||||
ledger *applicationledger.Service
|
||||
settlement *applicationsettlement.Service
|
||||
adminToken string
|
||||
}
|
||||
|
||||
func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service) *Handler {
|
||||
return &Handler{health: healthService, ledger: ledgerService}
|
||||
func (h *Handler) WithSettlementAdminToken(token string) *Handler { h.adminToken = token; return h }
|
||||
|
||||
func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service, settlementService ...*applicationsettlement.Service) *Handler {
|
||||
var settlement *applicationsettlement.Service
|
||||
if len(settlementService) > 0 {
|
||||
settlement = settlementService[0]
|
||||
}
|
||||
return &Handler{health: healthService, ledger: ledgerService, settlement: settlement}
|
||||
}
|
||||
|
||||
func NewHealthHandler(service *health.Service) *Handler {
|
||||
@@ -26,7 +35,13 @@ func NewHealthHandler(service *health.Service) *Handler {
|
||||
func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) {
|
||||
result := h.health.Check(ctx)
|
||||
return &ledgerv1.HealthResponse{
|
||||
Serving: result.Serving,
|
||||
DatabaseReady: result.DatabaseReady,
|
||||
Serving: result.Serving,
|
||||
DatabaseReady: result.DatabaseReady,
|
||||
SettlementEnabled: result.SettlementEnabled,
|
||||
SettlementReady: result.SettlementReady,
|
||||
SettlementPending: result.SettlementStats.Pending,
|
||||
SettlementRetryable: result.SettlementStats.Retryable,
|
||||
SettlementManualReview: result.SettlementStats.ManualReview,
|
||||
OldestPendingSeconds: result.SettlementStats.OldestPendingSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -398,6 +398,10 @@ func rpcError(err error) error {
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errors.Is(err, domain.ErrAlreadyReversed):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errors.Is(err, domain.ErrModeConflict), errors.Is(err, domain.ErrConcurrentPosting):
|
||||
return status.Error(codes.Aborted, err.Error())
|
||||
case errors.Is(err, domain.ErrModeRequired), errors.Is(err, domain.ErrInsufficientFunds), errors.Is(err, domain.ErrSettlementInFlight):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errors.Is(err, context.Canceled):
|
||||
return status.Error(codes.Canceled, err.Error())
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
|
||||
@@ -4,13 +4,18 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/reflection"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -30,7 +35,7 @@ func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerSe
|
||||
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
|
||||
defer listener.Close()
|
||||
|
||||
server := grpc.NewServer()
|
||||
server := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
|
||||
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
|
||||
reflection.Register(server)
|
||||
|
||||
@@ -67,3 +72,39 @@ func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listene
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func structuredUnaryLogger() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
started := time.Now()
|
||||
response, err := handler(ctx, req)
|
||||
code := status.Code(err)
|
||||
level := slog.LevelInfo
|
||||
if code != codes.OK {
|
||||
level = slog.LevelError
|
||||
}
|
||||
slog.Log(ctx, level, "grpc call finished",
|
||||
"component", "grpc_server",
|
||||
"grpc_method", info.FullMethod,
|
||||
"grpc_code", code.String(),
|
||||
"duration_ms", float64(time.Since(started).Microseconds())/1000,
|
||||
)
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
func panicRecovery() grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
slog.ErrorContext(ctx, "grpc panic recovered",
|
||||
"component", "grpc_server",
|
||||
"grpc_method", info.FullMethod,
|
||||
"panic", recovered,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
err = status.Error(codes.Internal, "internal server error")
|
||||
}
|
||||
}()
|
||||
return handler(ctx, req)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"gl/domain/ledger"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.EnqueueSettlementRequest) (*ledgerv1.EnqueueSettlementResponse, error) {
|
||||
if h.settlement == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
|
||||
}
|
||||
record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr())
|
||||
if err != nil {
|
||||
if errors.Is(err, ledger.ErrModeRequired) {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySettlementRequest) (*ledgerv1.RetrySettlementResponse, error) {
|
||||
if h.settlement == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
|
||||
}
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
}
|
||||
return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) IgnoreSettlement(ctx context.Context, request *ledgerv1.IgnoreSettlementRequest) (*ledgerv1.IgnoreSettlementResponse, error) {
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.settlement == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
|
||||
}
|
||||
record, err := h.settlement.IgnoreByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, err.Error())
|
||||
}
|
||||
return &ledgerv1.IgnoreSettlementResponse{Settlement: settlementMessage(record)}, nil
|
||||
}
|
||||
|
||||
func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement {
|
||||
return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError}
|
||||
}
|
||||
|
||||
func (h *Handler) GetSettlement(ctx context.Context, request *ledgerv1.GetSettlementRequest) (*ledgerv1.Settlement, error) {
|
||||
if h.settlement == nil {
|
||||
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
|
||||
}
|
||||
var id, key string
|
||||
switch lookup := request.GetLookup().(type) {
|
||||
case *ledgerv1.GetSettlementRequest_SettlementId:
|
||||
id = lookup.SettlementId
|
||||
case *ledgerv1.GetSettlementRequest_IdempotencyKey:
|
||||
key = lookup.IdempotencyKey
|
||||
}
|
||||
record, err := h.settlement.Get(ctx, id, key)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.NotFound, err.Error())
|
||||
}
|
||||
return settlementMessage(record), nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestIgnoreSettlementRequiresAdminToken(t *testing.T) {
|
||||
handler := NewHandler(nil, nil).WithSettlementAdminToken("secret")
|
||||
for _, token := range []string{"", "wrong"} {
|
||||
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-admin-token", token))
|
||||
if _, err := handler.IgnoreSettlement(ctx, &ledgerv1.IgnoreSettlementRequest{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("unauthorized dismissal: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ func TestDashboardRendersFullTemplPage(t *testing.T) {
|
||||
ID: "journal-1", EffectKind: "deposit", SourceService: "wallet",
|
||||
Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"},
|
||||
}},
|
||||
SettlementStats: explorer.SettlementStats{Confirmed: 9, Pending: 2},
|
||||
Settlements: []explorer.Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, Network: "kuknos", TransactionHash: "chain-hash", SourceService: "wallet", Attempts: 1}},
|
||||
}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
@@ -72,7 +74,7 @@ func TestDashboardRendersFullTemplPage(t *testing.T) {
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
|
||||
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "Blockchain settlements", "Confirmed on chain", "chain-hash", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("response did not contain %q", expected)
|
||||
}
|
||||
|
||||
@@ -109,6 +109,18 @@ var english = map[string]string{
|
||||
"tracked_accounts": "Tracked accounts",
|
||||
"last_recorded": "Last recorded",
|
||||
"latest_activity": "Latest ledger activity",
|
||||
"blockchain_settlements": "Blockchain settlements",
|
||||
"settlement_summary": "LATEST SETTLEMENT STATE",
|
||||
"confirmed_on_chain": "Confirmed on chain",
|
||||
"pending_submission": "Pending submission",
|
||||
"retryable": "Retryable",
|
||||
"manual_review": "Manual review",
|
||||
"blockchain_transaction": "Blockchain transaction",
|
||||
"status": "Status",
|
||||
"attempts": "Attempts",
|
||||
"updated": "Updated",
|
||||
"not_submitted": "Not submitted",
|
||||
"no_settlements": "No blockchain settlements have been queued yet.",
|
||||
"newest_first": "NEWEST FIRST",
|
||||
"top_asset_holders": "Top asset holders",
|
||||
"holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS",
|
||||
@@ -210,6 +222,18 @@ var persian = map[string]string{
|
||||
"tracked_accounts": "حسابهای ردیابیشده",
|
||||
"last_recorded": "آخرین ثبت",
|
||||
"latest_activity": "آخرین فعالیت دفتر کل",
|
||||
"blockchain_settlements": "تسویههای بلاکچین",
|
||||
"settlement_summary": "آخرین وضعیت تسویه",
|
||||
"confirmed_on_chain": "تأییدشده روی زنجیره",
|
||||
"pending_submission": "در انتظار ارسال",
|
||||
"retryable": "قابل تلاش مجدد",
|
||||
"manual_review": "نیازمند بررسی دستی",
|
||||
"blockchain_transaction": "تراکنش بلاکچین",
|
||||
"status": "وضعیت",
|
||||
"attempts": "تعداد تلاش",
|
||||
"updated": "آخرین تغییر",
|
||||
"not_submitted": "ارسالنشده",
|
||||
"no_settlements": "هنوز تسویهای برای بلاکچین در صف قرار نگرفته است.",
|
||||
"newest_first": "جدیدترین ابتدا",
|
||||
"top_asset_holders": "دارندگان برتر دارایی",
|
||||
"holder_balance_scope": "در دسترس + مسدود · داراییهای اخیر",
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -18,7 +20,7 @@ type ServerConfig struct {
|
||||
func Run(ctx context.Context, cfg ServerConfig, handler http.Handler) error {
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Handler: handler,
|
||||
Handler: otelhttp.NewHandler(handler, "gl-dashboard"),
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
}
|
||||
serveErr := make(chan error, 1)
|
||||
|
||||
@@ -72,6 +72,7 @@ templ Page(title string, active string, locale Locale, englishURL string, persia
|
||||
.pill { display:inline-flex; padding:6px 9px; border-radius:999px; background:var(--surface); font:750 10px/1 ui-monospace,SFMono-Regular,monospace; text-transform:uppercase; }
|
||||
.holder-id { max-width:330px; overflow-wrap:anywhere; font-weight:750; }
|
||||
.holder-id small { display:block; margin-top:5px; color:var(--muted); font:650 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
td small { display:block; margin-top:5px; color:var(--muted); font:600 10px/1.35 ui-monospace,SFMono-Regular,monospace; overflow-wrap:anywhere; }
|
||||
.empty { padding:45px 24px; text-align:center; color:var(--muted); }
|
||||
.notice + .transaction-list { margin-top:46px; }
|
||||
.pagination { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; direction:ltr; }
|
||||
@@ -200,6 +201,16 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) {
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "tracked_accounts") }</span><strong>{ count(data.Stats.AccountCount) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "last_recorded") }</span><time>{ formatTime(data.Stats.LastRecordedAt) }</time></div>
|
||||
</section>
|
||||
<section class="dashboard-section">
|
||||
<div class="section-head"><h2>{ tr(locale, "blockchain_settlements") }</h2><span>{ tr(locale, "settlement_summary") }</span></div>
|
||||
<section class="stats" aria-label={ tr(locale, "blockchain_settlements") }>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "confirmed_on_chain") }</span><strong>{ count(data.SettlementStats.Confirmed) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "pending_submission") }</span><strong>{ count(data.SettlementStats.Pending) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "retryable") }</span><strong>{ count(data.SettlementStats.Retryable) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "manual_review") }</span><strong>{ count(data.SettlementStats.ManualReview) }</strong></div>
|
||||
</section>
|
||||
@SettlementTable(data.Settlements, locale)
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-head"><h2>{ tr(locale, "latest_activity") }</h2><span>{ tr(locale, "newest_first") }</span></div>
|
||||
@JournalTable(data.Journals, locale)
|
||||
@@ -207,6 +218,39 @@ templ DashboardContent(data explorer.Dashboard, locale Locale) {
|
||||
</main>
|
||||
}
|
||||
|
||||
templ SettlementTable(settlements []explorer.Settlement, locale Locale) {
|
||||
<div class="panel">
|
||||
if len(settlements) == 0 {
|
||||
<div class="empty">{ tr(locale, "no_settlements") }</div>
|
||||
} else {
|
||||
<table>
|
||||
<thead><tr><th>{ tr(locale, "blockchain_transaction") }</th><th>{ tr(locale, "status") }</th><th>{ tr(locale, "network") }</th><th>{ tr(locale, "source") }</th><th>{ tr(locale, "attempts") }</th><th>{ tr(locale, "updated") }</th></tr></thead>
|
||||
<tbody>
|
||||
for _, settlement := range settlements {
|
||||
<tr>
|
||||
<td class="mono">
|
||||
if settlement.TransactionHash != "" {
|
||||
<a class="hash-link" href={ templ.URL(transactionURL(settlement.TransactionHash)) } title={ settlement.TransactionHash }>{ short(settlement.TransactionHash, 22) }</a>
|
||||
} else {
|
||||
<span title={ settlement.ID }>{ tr(locale, "not_submitted") } · { short(settlement.ID, 14) }</span>
|
||||
}
|
||||
if settlement.LastError != "" {
|
||||
<small>{ short(settlement.LastError, 70) }</small>
|
||||
}
|
||||
</td>
|
||||
<td><span class="pill">{ string(settlement.Status) }</span></td>
|
||||
<td>{ settlement.Network }</td>
|
||||
<td>{ settlement.SourceService }<small class="mono">{ short(settlement.SourceTransactionID, 18) }</small></td>
|
||||
<td class="mono">{ strconv.Itoa(settlement.Attempts) }</td>
|
||||
<td class="mono">{ formatTime(settlement.UpdatedAt) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AssetsContent(data explorer.Assets, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "assets") }</div>
|
||||
|
||||
+1280
-794
File diff suppressed because one or more lines are too long
@@ -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