From 12c478cf3a245e831134ab073f5a4648543c7defeeb30a076667a7a8eca75d27 Mon Sep 17 00:00:00 2001 From: Navid Date: Tue, 15 Sep 2026 13:59:13 +0330 Subject: [PATCH] feat: add durable Kuknos settlement coordination --- .dashboard.air.toml | 39 ++ .gitignore | 1 + .gl.air.toml | 39 ++ application/health/service.go | 38 +- application/health/service_test.go | 33 + application/settlement/service.go | 62 ++ application/settlement/service_test.go | 43 ++ application/settlement/worker.go | 57 ++ application/settlement/worker_test.go | 60 ++ build/Dockerfile | 9 +- cmd/dashboard-loadtest/main_test.go | 16 +- cmd/gl/main.go | 64 +- cmd/healthcheck/main.go | 30 + dashboard.cfg.toml | 4 +- domain/ledger/settlement.go | 67 ++ gen/base/v1/msg.pb.go | 2 +- gen/ledger/v1/msg.pb.go | 592 +++++++++++++++++- gen/ledger/v1/srv.pb.go | 53 +- gen/ledger/v1/srv_grpc.pb.go | 134 +++- gl.cfg.toml | 15 +- go.mod | 15 +- go.sum | 23 + infrastructure/config/config.go | 59 +- infrastructure/config/config_test.go | 51 +- infrastructure/kuknos/submitter.go | 55 ++ infrastructure/postgres/database.go | 4 + .../000003_kuknos_settlements.up.sql | 22 + .../000004_settlement_operator_audit.up.sql | 13 + ...05_settlement_reconciliation_issues.up.sql | 13 + .../postgres/settlement_repository.go | 204 ++++++ .../postgres/settlement_repository_test.go | 41 ++ interface/grpc/health.go | 27 +- interface/grpc/settlement.go | 60 ++ scripts/air-run.sh | 23 - 34 files changed, 1846 insertions(+), 122 deletions(-) create mode 100644 .dashboard.air.toml create mode 100644 .gl.air.toml create mode 100644 application/settlement/service.go create mode 100644 application/settlement/service_test.go create mode 100644 application/settlement/worker.go create mode 100644 application/settlement/worker_test.go create mode 100644 cmd/healthcheck/main.go create mode 100644 domain/ledger/settlement.go create mode 100644 infrastructure/kuknos/submitter.go create mode 100644 infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql create mode 100644 infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql create mode 100644 infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql create mode 100644 infrastructure/postgres/settlement_repository.go create mode 100644 infrastructure/postgres/settlement_repository_test.go create mode 100644 interface/grpc/settlement.go delete mode 100644 scripts/air-run.sh diff --git a/.dashboard.air.toml b/.dashboard.air.toml new file mode 100644 index 0000000..d38ee93 --- /dev/null +++ b/.dashboard.air.toml @@ -0,0 +1,39 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["-conf", "./dashboard.cfg.toml"] +bin = "./tmp/dashboard" +cmd = "go build -o ./tmp/dashboard ./cmd/dashboard" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"] +exclude_file = [] +exclude_regex = ["_test.go", ".pb.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "toml"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +main_only = false +time = true + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/.gitignore b/.gitignore index 998901b..cde82a1 100644 --- a/.gitignore +++ b/.gitignore @@ -246,3 +246,4 @@ cython_debug/ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* +.tmp-go-cache \ No newline at end of file diff --git a/.gl.air.toml b/.gl.air.toml new file mode 100644 index 0000000..71bc718 --- /dev/null +++ b/.gl.air.toml @@ -0,0 +1,39 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = ["-conf", "./gl.cfg.toml"] +bin = "./tmp/gl" +cmd = "go build -o ./tmp/gl ./cmd/gl" +delay = 1000 +exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"] +exclude_file = [] +exclude_regex = ["_test.go", ".pb.go"] +exclude_unchanged = false +follow_symlink = false +full_bin = "" +include_dir = [] +include_ext = ["go", "toml"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = true +stop_on_error = true + +[log] +main_only = false +time = true + +[misc] +clean_on_exit = false + +[screen] +clear_on_rebuild = true +keep_scroll = true diff --git a/application/health/service.go b/application/health/service.go index 764a920..ff710b8 100644 --- a/application/health/service.go +++ b/application/health/service.go @@ -1,23 +1,41 @@ // Package health provides the service readiness use case. package health -import "context" +import ( + "context" + + "gl/domain/ledger" +) type Database interface { Ping(context.Context) error } +type SettlementMonitor interface { + Stats(context.Context) (ledger.SettlementStats, error) +} + type Status struct { - Serving bool - DatabaseReady bool + Serving bool + DatabaseReady bool + SettlementEnabled bool + SettlementReady bool + SettlementStats ledger.SettlementStats } type Service struct { - database Database + database Database + settlement SettlementMonitor + settlementEnabled bool } -func NewService(database Database) *Service { - return &Service{database: database} +func NewService(database Database, settlement ...SettlementMonitor) *Service { + service := &Service{database: database} + if len(settlement) > 0 && settlement[0] != nil { + service.settlement = settlement[0] + service.settlementEnabled = true + } + return service } func (s *Service) Check(ctx context.Context) Status { @@ -26,5 +44,13 @@ func (s *Service) Check(ctx context.Context) Status { status.DatabaseReady = s.database.Ping(ctx) == nil } status.Serving = status.DatabaseReady + status.SettlementEnabled = s.settlementEnabled + if status.DatabaseReady && s.settlementEnabled { + stats, err := s.settlement.Stats(ctx) + if err == nil { + status.SettlementStats = stats + status.SettlementReady = true + } + } return status } diff --git a/application/health/service_test.go b/application/health/service_test.go index b8d2d9e..ff0d3e1 100644 --- a/application/health/service_test.go +++ b/application/health/service_test.go @@ -4,12 +4,23 @@ import ( "context" "errors" "testing" + + "gl/domain/ledger" ) type databaseStub struct{ err error } func (s databaseStub) Ping(context.Context) error { return s.err } +type settlementStub struct { + stats ledger.SettlementStats + err error +} + +func (s settlementStub) Stats(context.Context) (ledger.SettlementStats, error) { + return s.stats, s.err +} + func TestCheckReportsDatabaseReadiness(t *testing.T) { for _, tc := range []struct { name string @@ -28,3 +39,25 @@ func TestCheckReportsDatabaseReadiness(t *testing.T) { }) } } + +func TestCheckReportsSettlementBacklog(t *testing.T) { + monitor := settlementStub{stats: ledger.SettlementStats{Pending: 2, Retryable: 3, OldestPendingSeconds: 60}} + got := NewService(databaseStub{}, monitor).Check(context.Background()) + if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || !got.SettlementReady || got.SettlementStats != monitor.stats { + t.Fatalf("unexpected status: %+v", got) + } +} + +func TestCheckExposesManualReviewWithoutStoppingSettlementQueue(t *testing.T) { + got := NewService(databaseStub{}, settlementStub{stats: ledger.SettlementStats{ManualReview: 1}}).Check(context.Background()) + if !got.Serving || !got.SettlementReady || got.SettlementStats.ManualReview != 1 { + t.Fatalf("unexpected status: %+v", got) + } +} + +func TestCheckReportsSettlementQueryFailure(t *testing.T) { + got := NewService(databaseStub{}, settlementStub{err: errors.New("query failed")}).Check(context.Background()) + if !got.Serving || got.SettlementReady { + t.Fatalf("unexpected status: %+v", got) + } +} diff --git a/application/settlement/service.go b/application/settlement/service.go new file mode 100644 index 0000000..169b204 --- /dev/null +++ b/application/settlement/service.go @@ -0,0 +1,62 @@ +package settlement + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "gl/domain/ledger" +) + +type EnqueueRepository interface { + Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) + Get(context.Context, string, string) (ledger.Settlement, error) + RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error) +} + +func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) { + if settlementID == "" || actorID == "" || reason == "" { + return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required") + } + now := time.Now().UTC() + if s.now != nil { + now = s.now().UTC() + } + return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now) +} + +type Service struct { + repository EnqueueRepository + now func() time.Time +} + +func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} } + +func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) { + if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" { + return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required") + } + id := make([]byte, 16) + if _, err := rand.Read(id); err != nil { + return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err) + } + now := time.Now().UTC() + if s.now != nil { + now = s.now().UTC() + } + encoded := hex.EncodeToString(id) + record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now} + if err := record.Validate(); err != nil { + return ledger.Settlement{}, false, err + } + return s.repository.Enqueue(ctx, record) +} + +func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) { + if settlementID == "" && idempotencyKey == "" { + return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required") + } + return s.repository.Get(ctx, settlementID, idempotencyKey) +} diff --git a/application/settlement/service_test.go b/application/settlement/service_test.go new file mode 100644 index 0000000..f9f5b9f --- /dev/null +++ b/application/settlement/service_test.go @@ -0,0 +1,43 @@ +package settlement + +import ( + "context" + "testing" + "time" + + "gl/domain/ledger" +) + +type serviceRepository struct { + retriedID, actor, reason string +} + +func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) { + return ledger.Settlement{}, false, nil +} +func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) { + return ledger.Settlement{}, nil +} +func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) { + r.retriedID, r.actor, r.reason = id, actor, reason + return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil +} + +func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) { + service := NewService(&serviceRepository{}) + if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil { + t.Fatal("expected missing reason to be rejected") + } +} + +func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) { + repository := &serviceRepository{} + service := NewService(repository) + got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved") + if err != nil { + t.Fatal(err) + } + if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" { + t.Fatalf("unexpected retry: %+v repo=%+v", got, repository) + } +} diff --git a/application/settlement/worker.go b/application/settlement/worker.go new file mode 100644 index 0000000..1a6c50d --- /dev/null +++ b/application/settlement/worker.go @@ -0,0 +1,57 @@ +package settlement + +import ( + "context" + "fmt" + "time" + + "gl/domain/ledger" +) + +type Repository interface { + ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) + Save(context.Context, ledger.Settlement, string) error +} + +type Submitter interface { + Submit(context.Context, ledger.Settlement) (string, error) +} + +type Worker struct { + Repository Repository + Submitter Submitter + WorkerID string + MaxAttempts int + Now func() time.Time +} + +func (w Worker) RunOnce(ctx context.Context, limit int) error { + if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 { + return fmt.Errorf("settlement worker is not configured") + } + now := time.Now().UTC() + if w.Now != nil { + now = w.Now().UTC() + } + records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now) + if err != nil { + return err + } + for _, record := range records { + record.Attempts++ + hash, submitErr := w.Submitter.Submit(ctx, record) + if submitErr != nil { + record = record.Retry(now, w.MaxAttempts, submitErr) + } else { + // Horizon returns success only after the transaction is accepted into + // a ledger, so a successful response is already confirmation. + record.Status = ledger.SettlementConfirmed + record.TransactionHash = hash + record.LastError = "" + } + if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil { + return err + } + } + return nil +} diff --git a/application/settlement/worker_test.go b/application/settlement/worker_test.go new file mode 100644 index 0000000..4b277c1 --- /dev/null +++ b/application/settlement/worker_test.go @@ -0,0 +1,60 @@ +package settlement + +import ( + "context" + "errors" + "testing" + "time" + + "gl/domain/ledger" +) + +type memoryRepository struct{ record ledger.Settlement } + +func (m *memoryRepository) ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) { + return []ledger.Settlement{m.record}, nil +} +func (m *memoryRepository) Save(_ context.Context, record ledger.Settlement, _ string) error { + m.record = record + return nil +} + +type submitter struct { + hash string + err error +} + +func (s submitter) Submit(context.Context, ledger.Settlement) (string, error) { return s.hash, s.err } + +func TestWorkerPersistsSubmission(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{hash: "hash-42"}, WorkerID: "worker-1", Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementConfirmed || repository.record.TransactionHash != "hash-42" || repository.record.Attempts != 1 { + t.Fatalf("unexpected settlement: %+v", repository.record) + } +} + +func TestWorkerSchedulesRetryAfterFirstFailedAttempt(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementRetryable || repository.record.Attempts != 1 || !repository.record.AvailableAt.Equal(time.Unix(3, 0)) { + t.Fatalf("unexpected settlement: %+v", repository.record) + } +} + +func TestWorkerMovesExhaustedSubmissionToManualReview(t *testing.T) { + repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementRetryable, Attempts: 2, AvailableAt: time.Unix(1, 0)}} + worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }} + if err := worker.RunOnce(context.Background(), 1); err != nil { + t.Fatal(err) + } + if repository.record.Status != ledger.SettlementManualReview { + t.Fatalf("status=%s", repository.record.Status) + } +} diff --git a/build/Dockerfile b/build/Dockerfile index 58b9b55..7e2e055 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -15,7 +15,9 @@ RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ COPY . . RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \ --mount=type=cache,target=/root/.cache/go-build,sharing=locked \ - go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl + go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl && \ + go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard && \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck FROM alpine:3.24.1 @@ -26,11 +28,14 @@ RUN apk add --no-cache ca-certificates \ WORKDIR /app COPY --from=builder /out/gl /app/gl +COPY --from=builder /out/dashboard /app/dashboard +COPY --from=builder /out/healthcheck /app/healthcheck COPY gl.cfg.toml /app/gl.cfg.toml +COPY dashboard.cfg.toml /app/dashboard.cfg.toml USER darano -EXPOSE 8600 +EXPOSE 8600 8601 ENTRYPOINT ["/app/gl"] CMD ["-conf", "/app/gl.cfg.toml"] diff --git a/cmd/dashboard-loadtest/main_test.go b/cmd/dashboard-loadtest/main_test.go index 12d4d23..98bc1b5 100644 --- a/cmd/dashboard-loadtest/main_test.go +++ b/cmd/dashboard-loadtest/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "net" "net/http" "net/http/httptest" "sync/atomic" @@ -10,7 +11,7 @@ import ( ) func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) _, _ = response.Write([]byte("ok")) })) @@ -49,7 +50,7 @@ func TestPercentileUsesNearestRank(t *testing.T) { func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { var canceled atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { time.Sleep(20 * time.Millisecond) if request.Context().Err() != nil { canceled.Store(true) @@ -66,3 +67,14 @@ func TestRunDrainsInflightRequestAfterDuration(t *testing.T) { t.Fatalf("in-flight request was not drained cleanly: %+v", result) } } + +func newIPv4TestServer(handler http.Handler) *httptest.Server { + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + panic(err) + } + server := httptest.NewUnstartedServer(handler) + server.Listener = listener + server.Start() + return server +} diff --git a/cmd/gl/main.go b/cmd/gl/main.go index 45c723a..122f6f3 100644 --- a/cmd/gl/main.go +++ b/cmd/gl/main.go @@ -7,10 +7,13 @@ import ( "os" "os/signal" "syscall" + "time" "gl/application/health" applicationledger "gl/application/ledger" + applicationsettlement "gl/application/settlement" "gl/infrastructure/config" + "gl/infrastructure/kuknos" "gl/infrastructure/observability" "gl/infrastructure/postgres" grpcadapter "gl/interface/grpc" @@ -42,7 +45,21 @@ func main() { } repository := postgres.NewJournalRepository(database) - handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil)) + settlementRepository := postgres.NewSettlementRepository(database) + settlementService := applicationsettlement.NewService(settlementRepository) + var healthService *health.Service + if cfg.Settlement.Enabled { + healthService = health.NewService(database, settlementRepository) + } else { + healthService = health.NewService(database) + } + handler := grpcadapter.NewHandler(healthService, applicationledger.NewService(repository, nil), settlementService).WithSettlementAdminToken(cfg.Settlement.AdminToken) + if cfg.Settlement.Enabled { + worker := applicationsettlement.Worker{Repository: settlementRepository, Submitter: kuknos.Submitter{Endpoint: cfg.Settlement.Endpoint}, WorkerID: cfg.Settlement.WorkerID, MaxAttempts: cfg.Settlement.MaxAttempts} + go runSettlementWorker(ctx, worker, cfg.Settlement.PollInterval, cfg.Settlement.BatchSize) + go runSettlementReconciliation(ctx, settlementRepository) + slog.Info("Kuknos settlement worker enabled", "endpoint", cfg.Settlement.Endpoint, "worker_id", cfg.Settlement.WorkerID) + } slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port) serverConfig := grpcadapter.ServerConfig{ Host: cfg.GRPC.Host, @@ -54,3 +71,48 @@ func main() { os.Exit(1) } } + +func runSettlementReconciliation(ctx context.Context, repository *postgres.SettlementRepository) { + run := func() { + stats, err := repository.ReconcileConfirmationEvidence(ctx, 10*time.Minute) + if err != nil { + slog.ErrorContext(ctx, "settlement reconciliation failed", "error", err) + return + } + if stats.Detected > 0 || stats.Resolved > 0 || stats.Open > 0 { + slog.WarnContext(ctx, "settlement reconciliation completed", "detected", stats.Detected, "resolved", stats.Resolved, "open", stats.Open) + } + } + run() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} + +func runSettlementWorker(ctx context.Context, worker applicationsettlement.Worker, interval time.Duration, batchSize int) { + if interval <= 0 { + interval = 5 * time.Second + } + if batchSize <= 0 { + batchSize = 20 + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := worker.RunOnce(ctx, batchSize); err != nil { + slog.ErrorContext(ctx, "settlement worker cycle failed", "error", err) + } + } + } +} diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go new file mode 100644 index 0000000..ca703eb --- /dev/null +++ b/cmd/healthcheck/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "context" + "flag" + "os" + "time" + + "gl/infrastructure/config" + "gl/infrastructure/postgres" +) + +func main() { + configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file") + flag.Parse() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + cfg, err := config.Load(*configPath) + if err != nil { + os.Exit(1) + } + database, err := postgres.Open(ctx, cfg.Database) + if err != nil { + os.Exit(1) + } + defer database.Close() + if err := database.Ping(ctx); err != nil { + os.Exit(1) + } +} diff --git a/dashboard.cfg.toml b/dashboard.cfg.toml index 4c9f4fd..6f13756 100644 --- a/dashboard.cfg.toml +++ b/dashboard.cfg.toml @@ -6,10 +6,10 @@ port = 8601 read-header-timeout = "5s" shutdown-timeout = "10s" -[database] +[db] host = "127.0.0.1" port = 5432 name = "gl_db" user = "postgres" -password = "postgres" +password = "" ssl-mode = "disable" diff --git a/domain/ledger/settlement.go b/domain/ledger/settlement.go new file mode 100644 index 0000000..0b9b586 --- /dev/null +++ b/domain/ledger/settlement.go @@ -0,0 +1,67 @@ +package ledger + +import ( + "fmt" + "time" +) + +type SettlementStatus string + +const ( + SettlementPending SettlementStatus = "PENDING" + SettlementSubmitted SettlementStatus = "SUBMITTED" + SettlementConfirmed SettlementStatus = "CONFIRMED" + SettlementRetryable SettlementStatus = "RETRYABLE" + SettlementManualReview SettlementStatus = "MANUAL_REVIEW" +) + +type Settlement struct { + ID string + SourceService string + SourceTxID string + IdempotencyKey string + Status SettlementStatus + Network string + TransactionHash string + Attempts int + AvailableAt time.Time + LastError string + SignedTransactionXDR string +} + +type SettlementStats struct { + Pending int64 + Retryable int64 + ManualReview int64 + OldestPendingSeconds int64 +} + +func (s Settlement) Validate() error { + if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" { + return fmt.Errorf("settlement identity fields are required") + } + if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview { + return fmt.Errorf("invalid settlement status %q", s.Status) + } + if s.Attempts < 0 || s.AvailableAt.IsZero() { + return fmt.Errorf("invalid settlement retry state") + } + return nil +} + +func (s Settlement) Retry(now time.Time, maxAttempts int, err error) Settlement { + s.LastError = err.Error() + s.Status = SettlementRetryable + if maxAttempts > 0 && s.Attempts >= maxAttempts { + s.Status = SettlementManualReview + } + delay := time.Second + for i := 1; i < s.Attempts && delay < 15*time.Minute; i++ { + delay *= 2 + } + if delay > 15*time.Minute { + delay = 15 * time.Minute + } + s.AvailableAt = now.UTC().Add(delay) + return s +} diff --git a/gen/base/v1/msg.pb.go b/gen/base/v1/msg.pb.go index c90445a..af9a4c7 100644 --- a/gen/base/v1/msg.pb.go +++ b/gen/base/v1/msg.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: base/v1/msg.proto diff --git a/gen/ledger/v1/msg.pb.go b/gen/ledger/v1/msg.pb.go index 2c39792..0afadbc 100644 --- a/gen/ledger/v1/msg.pb.go +++ b/gen/ledger/v1/msg.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: ledger/v1/msg.proto @@ -1441,11 +1441,17 @@ func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult { } type HealthResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"` - DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Serving bool `protobuf:"varint,1,opt,name=serving,proto3" json:"serving,omitempty"` + DatabaseReady bool `protobuf:"varint,2,opt,name=database_ready,json=databaseReady,proto3" json:"database_ready,omitempty"` + SettlementEnabled bool `protobuf:"varint,3,opt,name=settlement_enabled,json=settlementEnabled,proto3" json:"settlement_enabled,omitempty"` + SettlementReady bool `protobuf:"varint,4,opt,name=settlement_ready,json=settlementReady,proto3" json:"settlement_ready,omitempty"` + SettlementPending int64 `protobuf:"varint,5,opt,name=settlement_pending,json=settlementPending,proto3" json:"settlement_pending,omitempty"` + SettlementRetryable int64 `protobuf:"varint,6,opt,name=settlement_retryable,json=settlementRetryable,proto3" json:"settlement_retryable,omitempty"` + SettlementManualReview int64 `protobuf:"varint,7,opt,name=settlement_manual_review,json=settlementManualReview,proto3" json:"settlement_manual_review,omitempty"` + OldestPendingSeconds int64 `protobuf:"varint,8,opt,name=oldest_pending_seconds,json=oldestPendingSeconds,proto3" json:"oldest_pending_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *HealthResponse) Reset() { @@ -1492,6 +1498,479 @@ func (x *HealthResponse) GetDatabaseReady() bool { return false } +func (x *HealthResponse) GetSettlementEnabled() bool { + if x != nil { + return x.SettlementEnabled + } + return false +} + +func (x *HealthResponse) GetSettlementReady() bool { + if x != nil { + return x.SettlementReady + } + return false +} + +func (x *HealthResponse) GetSettlementPending() int64 { + if x != nil { + return x.SettlementPending + } + return 0 +} + +func (x *HealthResponse) GetSettlementRetryable() int64 { + if x != nil { + return x.SettlementRetryable + } + return 0 +} + +func (x *HealthResponse) GetSettlementManualReview() int64 { + if x != nil { + return x.SettlementManualReview + } + return 0 +} + +func (x *HealthResponse) GetOldestPendingSeconds() int64 { + if x != nil { + return x.OldestPendingSeconds + } + return 0 +} + +type EnqueueSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceService string `protobuf:"bytes,1,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + SourceTransactionId string `protobuf:"bytes,2,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + IdempotencyKey string `protobuf:"bytes,3,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Network string `protobuf:"bytes,4,opt,name=network,proto3" json:"network,omitempty"` + // Signed transaction XDR. GL persists and submits this payload durably. + SignedTransactionXdr string `protobuf:"bytes,5,opt,name=signed_transaction_xdr,json=signedTransactionXdr,proto3" json:"signed_transaction_xdr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSettlementRequest) Reset() { + *x = EnqueueSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSettlementRequest) ProtoMessage() {} + +func (x *EnqueueSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSettlementRequest.ProtoReflect.Descriptor instead. +func (*EnqueueSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{18} +} + +func (x *EnqueueSettlementRequest) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *EnqueueSettlementRequest) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *EnqueueSettlementRequest) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *EnqueueSettlementRequest) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *EnqueueSettlementRequest) GetSignedTransactionXdr() string { + if x != nil { + return x.SignedTransactionXdr + } + return "" +} + +type EnqueueSettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + AlreadyExisted bool `protobuf:"varint,3,opt,name=already_existed,json=alreadyExisted,proto3" json:"already_existed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EnqueueSettlementResponse) Reset() { + *x = EnqueueSettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EnqueueSettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EnqueueSettlementResponse) ProtoMessage() {} + +func (x *EnqueueSettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EnqueueSettlementResponse.ProtoReflect.Descriptor instead. +func (*EnqueueSettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{19} +} + +func (x *EnqueueSettlementResponse) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *EnqueueSettlementResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *EnqueueSettlementResponse) GetAlreadyExisted() bool { + if x != nil { + return x.AlreadyExisted + } + return false +} + +type GetSettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Lookup: + // + // *GetSettlementRequest_SettlementId + // *GetSettlementRequest_IdempotencyKey + Lookup isGetSettlementRequest_Lookup `protobuf_oneof:"lookup"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSettlementRequest) Reset() { + *x = GetSettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSettlementRequest) ProtoMessage() {} + +func (x *GetSettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetSettlementRequest.ProtoReflect.Descriptor instead. +func (*GetSettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{20} +} + +func (x *GetSettlementRequest) GetLookup() isGetSettlementRequest_Lookup { + if x != nil { + return x.Lookup + } + return nil +} + +func (x *GetSettlementRequest) GetSettlementId() string { + if x != nil { + if x, ok := x.Lookup.(*GetSettlementRequest_SettlementId); ok { + return x.SettlementId + } + } + return "" +} + +func (x *GetSettlementRequest) GetIdempotencyKey() string { + if x != nil { + if x, ok := x.Lookup.(*GetSettlementRequest_IdempotencyKey); ok { + return x.IdempotencyKey + } + } + return "" +} + +type isGetSettlementRequest_Lookup interface { + isGetSettlementRequest_Lookup() +} + +type GetSettlementRequest_SettlementId struct { + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3,oneof"` +} + +type GetSettlementRequest_IdempotencyKey struct { + IdempotencyKey string `protobuf:"bytes,2,opt,name=idempotency_key,json=idempotencyKey,proto3,oneof"` +} + +func (*GetSettlementRequest_SettlementId) isGetSettlementRequest_Lookup() {} + +func (*GetSettlementRequest_IdempotencyKey) isGetSettlementRequest_Lookup() {} + +type Settlement struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + SourceService string `protobuf:"bytes,2,opt,name=source_service,json=sourceService,proto3" json:"source_service,omitempty"` + SourceTransactionId string `protobuf:"bytes,3,opt,name=source_transaction_id,json=sourceTransactionId,proto3" json:"source_transaction_id,omitempty"` + IdempotencyKey string `protobuf:"bytes,4,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` + Network string `protobuf:"bytes,6,opt,name=network,proto3" json:"network,omitempty"` + TransactionHash string `protobuf:"bytes,7,opt,name=transaction_hash,json=transactionHash,proto3" json:"transaction_hash,omitempty"` + Attempts uint32 `protobuf:"varint,8,opt,name=attempts,proto3" json:"attempts,omitempty"` + LastError string `protobuf:"bytes,9,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Settlement) Reset() { + *x = Settlement{} + mi := &file_ledger_v1_msg_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Settlement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Settlement) ProtoMessage() {} + +func (x *Settlement) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Settlement.ProtoReflect.Descriptor instead. +func (*Settlement) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{21} +} + +func (x *Settlement) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *Settlement) GetSourceService() string { + if x != nil { + return x.SourceService + } + return "" +} + +func (x *Settlement) GetSourceTransactionId() string { + if x != nil { + return x.SourceTransactionId + } + return "" +} + +func (x *Settlement) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +func (x *Settlement) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Settlement) GetNetwork() string { + if x != nil { + return x.Network + } + return "" +} + +func (x *Settlement) GetTransactionHash() string { + if x != nil { + return x.TransactionHash + } + return "" +} + +func (x *Settlement) GetAttempts() uint32 { + if x != nil { + return x.Attempts + } + return 0 +} + +func (x *Settlement) GetLastError() string { + if x != nil { + return x.LastError + } + return "" +} + +type RetrySettlementRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SettlementId string `protobuf:"bytes,1,opt,name=settlement_id,json=settlementId,proto3" json:"settlement_id,omitempty"` + ActorId string `protobuf:"bytes,2,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetrySettlementRequest) Reset() { + *x = RetrySettlementRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetrySettlementRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetrySettlementRequest) ProtoMessage() {} + +func (x *RetrySettlementRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetrySettlementRequest.ProtoReflect.Descriptor instead. +func (*RetrySettlementRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{22} +} + +func (x *RetrySettlementRequest) GetSettlementId() string { + if x != nil { + return x.SettlementId + } + return "" +} + +func (x *RetrySettlementRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *RetrySettlementRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type RetrySettlementResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Settlement *Settlement `protobuf:"bytes,1,opt,name=settlement,proto3" json:"settlement,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RetrySettlementResponse) Reset() { + *x = RetrySettlementResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RetrySettlementResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RetrySettlementResponse) ProtoMessage() {} + +func (x *RetrySettlementResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RetrySettlementResponse.ProtoReflect.Descriptor instead. +func (*RetrySettlementResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{23} +} + +func (x *RetrySettlementResponse) GetSettlement() *Settlement { + if x != nil { + return x.Settlement + } + return nil +} + var File_ledger_v1_msg_proto protoreflect.FileDescriptor const file_ledger_v1_msg_proto_rawDesc = "" + @@ -1632,10 +2111,50 @@ const file_ledger_v1_msg_proto_rawDesc = "" + "\ajournal\x18\x02 \x01(\v2\x12.ledger.v1.JournalR\ajournal\x12'\n" + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"R\n" + "\x16ReplayJournalsResponse\x128\n" + - "\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"Q\n" + + "\aresults\x18\x01 \x03(\v2\x1e.ledger.v1.ReplayJournalResultR\aresults\"\xfd\x02\n" + "\x0eHealthResponse\x12\x18\n" + "\aserving\x18\x01 \x01(\bR\aserving\x12%\n" + - "\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady*\x9a\x02\n" + + "\x0edatabase_ready\x18\x02 \x01(\bR\rdatabaseReady\x12-\n" + + "\x12settlement_enabled\x18\x03 \x01(\bR\x11settlementEnabled\x12)\n" + + "\x10settlement_ready\x18\x04 \x01(\bR\x0fsettlementReady\x12-\n" + + "\x12settlement_pending\x18\x05 \x01(\x03R\x11settlementPending\x121\n" + + "\x14settlement_retryable\x18\x06 \x01(\x03R\x13settlementRetryable\x128\n" + + "\x18settlement_manual_review\x18\a \x01(\x03R\x16settlementManualReview\x124\n" + + "\x16oldest_pending_seconds\x18\b \x01(\x03R\x14oldestPendingSeconds\"\xee\x01\n" + + "\x18EnqueueSettlementRequest\x12%\n" + + "\x0esource_service\x18\x01 \x01(\tR\rsourceService\x122\n" + + "\x15source_transaction_id\x18\x02 \x01(\tR\x13sourceTransactionId\x12'\n" + + "\x0fidempotency_key\x18\x03 \x01(\tR\x0eidempotencyKey\x12\x18\n" + + "\anetwork\x18\x04 \x01(\tR\anetwork\x124\n" + + "\x16signed_transaction_xdr\x18\x05 \x01(\tR\x14signedTransactionXdr\"\x81\x01\n" + + "\x19EnqueueSettlementResponse\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12'\n" + + "\x0falready_existed\x18\x03 \x01(\bR\x0ealreadyExisted\"r\n" + + "\x14GetSettlementRequest\x12%\n" + + "\rsettlement_id\x18\x01 \x01(\tH\x00R\fsettlementId\x12)\n" + + "\x0fidempotency_key\x18\x02 \x01(\tH\x00R\x0eidempotencyKeyB\b\n" + + "\x06lookup\"\xcd\x02\n" + + "\n" + + "Settlement\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12%\n" + + "\x0esource_service\x18\x02 \x01(\tR\rsourceService\x122\n" + + "\x15source_transaction_id\x18\x03 \x01(\tR\x13sourceTransactionId\x12'\n" + + "\x0fidempotency_key\x18\x04 \x01(\tR\x0eidempotencyKey\x12\x16\n" + + "\x06status\x18\x05 \x01(\tR\x06status\x12\x18\n" + + "\anetwork\x18\x06 \x01(\tR\anetwork\x12)\n" + + "\x10transaction_hash\x18\a \x01(\tR\x0ftransactionHash\x12\x1a\n" + + "\battempts\x18\b \x01(\rR\battempts\x12\x1d\n" + + "\n" + + "last_error\x18\t \x01(\tR\tlastError\"p\n" + + "\x16RetrySettlementRequest\x12#\n" + + "\rsettlement_id\x18\x01 \x01(\tR\fsettlementId\x12\x19\n" + + "\bactor_id\x18\x02 \x01(\tR\aactorId\x12\x16\n" + + "\x06reason\x18\x03 \x01(\tR\x06reason\"P\n" + + "\x17RetrySettlementResponse\x125\n" + + "\n" + + "settlement\x18\x01 \x01(\v2\x15.ledger.v1.SettlementR\n" + + "settlement*\x9a\x02\n" + "\fAccountClass\x12\x1d\n" + "\x19ACCOUNT_CLASS_UNSPECIFIED\x10\x00\x12 \n" + "\x1cACCOUNT_CLASS_USER_AVAILABLE\x10\x01\x12\x1d\n" + @@ -1669,7 +2188,7 @@ func file_ledger_v1_msg_proto_rawDescGZIP() []byte { } var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_ledger_v1_msg_proto_goTypes = []any{ (AccountClass)(0), // 0: ledger.v1.AccountClass (TransactionState)(0), // 1: ledger.v1.TransactionState @@ -1691,47 +2210,54 @@ var file_ledger_v1_msg_proto_goTypes = []any{ (*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult (*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse (*HealthResponse)(nil), // 19: ledger.v1.HealthResponse - nil, // 20: ledger.v1.Journal.MetadataEntry - nil, // 21: ledger.v1.AppendJournalRequest.MetadataEntry - nil, // 22: ledger.v1.AppendTransactionEventRequest.MetadataEntry - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp + (*EnqueueSettlementRequest)(nil), // 20: ledger.v1.EnqueueSettlementRequest + (*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse + (*GetSettlementRequest)(nil), // 22: ledger.v1.GetSettlementRequest + (*Settlement)(nil), // 23: ledger.v1.Settlement + (*RetrySettlementRequest)(nil), // 24: ledger.v1.RetrySettlementRequest + (*RetrySettlementResponse)(nil), // 25: ledger.v1.RetrySettlementResponse + nil, // 26: ledger.v1.Journal.MetadataEntry + nil, // 27: ledger.v1.AppendJournalRequest.MetadataEntry + nil, // 28: ledger.v1.AppendTransactionEventRequest.MetadataEntry + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp } var file_ledger_v1_msg_proto_depIdxs = []int32{ 0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass 2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference 3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry - 23, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp - 23, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp + 29, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp 4, // 5: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference - 20, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry + 26, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry 3, // 7: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry - 23, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp 4, // 9: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 21, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry + 27, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry 5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal 1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState - 23, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp + 29, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp 4, // 14: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 22, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry + 28, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry 8, // 16: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest - 23, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp + 29, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp 9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent 2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference - 23, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp - 23, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp + 29, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp + 29, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp 5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal 2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference - 23, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp + 29, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp 2, // 25: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference - 23, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp + 29, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp 6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest 5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal 17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult - 30, // [30:30] is the sub-list for method output_type - 30, // [30:30] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 23, // 30: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement + 31, // [31:31] is the sub-list for method output_type + 31, // [31:31] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_ledger_v1_msg_proto_init() } @@ -1746,13 +2272,17 @@ func file_ledger_v1_msg_proto_init() { (*GetJournalRequest_IdempotencyKey)(nil), } file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[20].OneofWrappers = []any{ + (*GetSettlementRequest_SettlementId)(nil), + (*GetSettlementRequest_IdempotencyKey)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)), NumEnums: 2, - NumMessages: 21, + NumMessages: 27, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/ledger/v1/srv.pb.go b/gen/ledger/v1/srv.pb.go index 3f90d43..ed563a9 100644 --- a/gen/ledger/v1/srv.pb.go +++ b/gen/ledger/v1/srv.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 +// protoc-gen-go v1.36.6 // protoc (unknown) // source: ledger/v1/srv.proto @@ -25,7 +25,7 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor const file_ledger_v1_srv_proto_rawDesc = "" + "\n" + - "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xbe\x04\n" + + "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xc1\x06\n" + "\x14GeneralLedgerService\x123\n" + "\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" + "\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" + @@ -35,7 +35,10 @@ const file_ledger_v1_srv_proto_rawDesc = "" + "\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" + "\n" + "GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" + - "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponseBy\n" + + "\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" + + "\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" + + "\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" + + "\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponseBy\n" + "\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" + "Ledger::V1b\x06proto3" @@ -47,13 +50,19 @@ var file_ledger_v1_srv_proto_goTypes = []any{ (*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest (*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest (*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest - (*HealthResponse)(nil), // 7: ledger.v1.HealthResponse - (*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse - (*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse - (*Journal)(nil), // 10: ledger.v1.Journal - (*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse - (*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse - (*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse + (*EnqueueSettlementRequest)(nil), // 7: ledger.v1.EnqueueSettlementRequest + (*GetSettlementRequest)(nil), // 8: ledger.v1.GetSettlementRequest + (*RetrySettlementRequest)(nil), // 9: ledger.v1.RetrySettlementRequest + (*HealthResponse)(nil), // 10: ledger.v1.HealthResponse + (*AppendJournalResponse)(nil), // 11: ledger.v1.AppendJournalResponse + (*AppendTransactionEventResponse)(nil), // 12: ledger.v1.AppendTransactionEventResponse + (*Journal)(nil), // 13: ledger.v1.Journal + (*ListEntriesResponse)(nil), // 14: ledger.v1.ListEntriesResponse + (*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse + (*ReplayJournalsResponse)(nil), // 16: ledger.v1.ReplayJournalsResponse + (*EnqueueSettlementResponse)(nil), // 17: ledger.v1.EnqueueSettlementResponse + (*Settlement)(nil), // 18: ledger.v1.Settlement + (*RetrySettlementResponse)(nil), // 19: ledger.v1.RetrySettlementResponse } var file_ledger_v1_srv_proto_depIdxs = []int32{ 0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty @@ -63,15 +72,21 @@ var file_ledger_v1_srv_proto_depIdxs = []int32{ 4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest 5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest 6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest - 7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse - 8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse - 9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse - 10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal - 11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse - 12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse - 13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse - 7, // [7:14] is the sub-list for method output_type - 0, // [0:7] is the sub-list for method input_type + 7, // 7: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest + 8, // 8: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest + 9, // 9: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest + 10, // 10: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse + 11, // 11: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse + 12, // 12: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse + 13, // 13: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal + 14, // 14: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse + 15, // 15: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse + 16, // 16: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse + 17, // 17: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse + 18, // 18: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement + 19, // 19: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse + 10, // [10:20] is the sub-list for method output_type + 0, // [0:10] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name diff --git a/gen/ledger/v1/srv_grpc.pb.go b/gen/ledger/v1/srv_grpc.pb.go index e16298a..13290b2 100644 --- a/gen/ledger/v1/srv_grpc.pb.go +++ b/gen/ledger/v1/srv_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.2 +// - protoc-gen-go-grpc v1.5.1 // - protoc (unknown) // source: ledger/v1/srv.proto @@ -27,6 +27,9 @@ const ( GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries" GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance" GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals" + GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement" + GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement" + GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement" ) // GeneralLedgerServiceClient is the client API for GeneralLedgerService service. @@ -40,6 +43,10 @@ type GeneralLedgerServiceClient interface { ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) + EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) + GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) + // Restricted operator command. Requires the configured x-admin-token metadata. + RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) } type generalLedgerServiceClient struct { @@ -120,6 +127,36 @@ func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *Rep return out, nil } +func (c *generalLedgerServiceClient) EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EnqueueSettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_EnqueueSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Settlement) + err := c.cc.Invoke(ctx, GeneralLedgerService_GetSettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RetrySettlementResponse) + err := c.cc.Invoke(ctx, GeneralLedgerService_RetrySettlement_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // GeneralLedgerServiceServer is the server API for GeneralLedgerService service. // All implementations should embed UnimplementedGeneralLedgerServiceServer // for forward compatibility. @@ -131,6 +168,10 @@ type GeneralLedgerServiceServer interface { ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) + EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) + GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) + // Restricted operator command. Requires the configured x-admin-token metadata. + RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) } // UnimplementedGeneralLedgerServiceServer should be embedded to have @@ -141,25 +182,34 @@ type GeneralLedgerServiceServer interface { type UnimplementedGeneralLedgerServiceServer struct{} func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Health not implemented") + return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") } func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AppendJournal not implemented") + return nil, status.Errorf(codes.Unimplemented, "method AppendJournal not implemented") } func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) { - return nil, status.Error(codes.Unimplemented, "method AppendTransactionEvent not implemented") + return nil, status.Errorf(codes.Unimplemented, "method AppendTransactionEvent not implemented") } func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) { - return nil, status.Error(codes.Unimplemented, "method GetJournal not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetJournal not implemented") } func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ListEntries not implemented") + return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented") } func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) { - return nil, status.Error(codes.Unimplemented, "method GetBalance not implemented") + return nil, status.Errorf(codes.Unimplemented, "method GetBalance not implemented") } func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) { - return nil, status.Error(codes.Unimplemented, "method ReplayJournals not implemented") + return nil, status.Errorf(codes.Unimplemented, "method ReplayJournals not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EnqueueSettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetSettlement not implemented") +} +func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented") } func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {} @@ -171,7 +221,7 @@ type UnsafeGeneralLedgerServiceServer interface { } func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) { - // If the following call panics, it indicates UnimplementedGeneralLedgerServiceServer was + // If the following call pancis, it indicates UnimplementedGeneralLedgerServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -307,6 +357,60 @@ func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.C return interceptor(ctx, in, info, handler) } +func _GeneralLedgerService_EnqueueSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EnqueueSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_EnqueueSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, req.(*EnqueueSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_GetSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetSettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_GetSettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, req.(*GetSettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RetrySettlementRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: GeneralLedgerService_RetrySettlement_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, req.(*RetrySettlementRequest)) + } + return interceptor(ctx, in, info, handler) +} + // GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -342,6 +446,18 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ReplayJournals", Handler: _GeneralLedgerService_ReplayJournals_Handler, }, + { + MethodName: "EnqueueSettlement", + Handler: _GeneralLedgerService_EnqueueSettlement_Handler, + }, + { + MethodName: "GetSettlement", + Handler: _GeneralLedgerService_GetSettlement_Handler, + }, + { + MethodName: "RetrySettlement", + Handler: _GeneralLedgerService_RetrySettlement_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "ledger/v1/srv.proto", diff --git a/gl.cfg.toml b/gl.cfg.toml index 3c8ae30..ae21f10 100644 --- a/gl.cfg.toml +++ b/gl.cfg.toml @@ -3,12 +3,21 @@ environment = "local" [grpc] host = "0.0.0.0" port = 8600 -shutdown-timeout = "10s" +timeout = "10s" -[database] +[db] host = "127.0.0.1" port = 5432 name = "gl_db" user = "postgres" -password = "postgres" +password = "" ssl-mode = "disable" + +[settlement] +enabled = false +endpoint = "" +worker-id = "gl-local" +poll-interval = "5s" +batch-size = 20 +max-attempts = 8 +admin-token = "" diff --git a/go.mod b/go.mod index 357f00d..617c6fe 100644 --- a/go.mod +++ b/go.mod @@ -13,21 +13,34 @@ require ( ) require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/natefinch/atomic v1.0.1 // indirect github.com/pelletier/go-toml v1.9.5 // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect ) -tool github.com/a-h/templ +tool ( + github.com/a-h/templ + github.com/a-h/templ/cmd/templ +) diff --git a/go.sum b/go.sum index 12fe333..fac2ebf 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,18 @@ +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -25,10 +35,17 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -40,14 +57,20 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go index ad430e9..f0619d1 100644 --- a/infrastructure/config/config.go +++ b/infrastructure/config/config.go @@ -3,6 +3,8 @@ package config import ( "fmt" + "os" + "strings" "time" "github.com/knadh/koanf/parsers/toml" @@ -11,21 +13,22 @@ import ( ) type Config struct { - Environment string `koanf:"environment"` - GRPC GRPCConfig `koanf:"grpc"` - Database DatabaseConfig `koanf:"database"` + Environment string `koanf:"environment"` + GRPC GRPCConfig `koanf:"grpc"` + Database DatabaseConfig `koanf:"db"` + Settlement SettlementConfig `koanf:"settlement"` } type DashboardConfig struct { Environment string `koanf:"environment"` HTTP HTTPConfig `koanf:"http"` - Database DatabaseConfig `koanf:"database"` + Database DatabaseConfig `koanf:"db"` } type GRPCConfig struct { Host string `koanf:"host"` Port int `koanf:"port"` - ShutdownTimeout time.Duration `koanf:"shutdown-timeout"` + ShutdownTimeout time.Duration `koanf:"timeout"` } type HTTPConfig struct { @@ -36,12 +39,23 @@ type HTTPConfig struct { } type DatabaseConfig struct { - Host string `koanf:"host"` - Port int `koanf:"port"` - Name string `koanf:"name"` - User string `koanf:"user"` - Password string `koanf:"password"` - SSLMode string `koanf:"ssl-mode"` + Host string `koanf:"host"` + Port int `koanf:"port"` + Name string `koanf:"name"` + User string `koanf:"user"` + Password string `koanf:"password"` + GormLogLevel int `koanf:"gorm-log-level"` + SSLMode string `koanf:"-"` +} + +type SettlementConfig struct { + Enabled bool `koanf:"enabled"` + Endpoint string `koanf:"endpoint"` + WorkerID string `koanf:"worker-id"` + PollInterval time.Duration `koanf:"poll-interval"` + BatchSize int `koanf:"batch-size"` + MaxAttempts int `koanf:"max-attempts"` + AdminToken string `koanf:"admin-token"` } func Load(path string) (*Config, error) { @@ -52,12 +66,14 @@ func Load(path string) (*Config, error) { Port: 8600, ShutdownTimeout: 10 * time.Second, }, - Database: defaultDatabaseConfig(), + Database: defaultDatabaseConfig(), + Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8}, } if err := load(path, cfg); err != nil { return nil, err } + applyEnvironment(&cfg.Database, &cfg.Settlement) if err := cfg.Validate(); err != nil { return nil, err } @@ -78,12 +94,28 @@ func LoadDashboard(path string) (*DashboardConfig, error) { if err := load(path, cfg); err != nil { return nil, err } + applyEnvironment(&cfg.Database, nil) if err := cfg.Validate(); err != nil { return nil, err } return cfg, nil } +func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) { + if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok { + database.Password = value + } + if settlement == nil { + return + } + if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok { + settlement.Endpoint = strings.TrimRight(value, "/") + } + if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok { + settlement.AdminToken = value + } +} + func load(path string, target any) error { k := koanf.New(".") if err := k.Load(file.Provider(path), toml.Parser()); err != nil { @@ -115,6 +147,9 @@ func (c *Config) Validate() error { if c.GRPC.ShutdownTimeout <= 0 { return fmt.Errorf("grpc shutdown timeout must be positive") } + if c.Environment == "production" && c.Settlement.AdminToken == "" { + return fmt.Errorf("settlement admin token is required in production") + } return validateDatabase(c.Database) } diff --git a/infrastructure/config/config_test.go b/infrastructure/config/config_test.go index 8deb720..7959462 100644 --- a/infrastructure/config/config_test.go +++ b/infrastructure/config/config_test.go @@ -10,7 +10,7 @@ import ( func TestLoadUsesDefaultsAndOverrides(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "gl.toml") - contents := []byte("[grpc]\nport = 0\nshutdown-timeout = \"3s\"\n") + contents := []byte("[grpc]\nport = 0\ntimeout = \"3s\"\n") if err := os.WriteFile(path, contents, 0o600); err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestLoadUsesDefaultsAndOverrides(t *testing.T) { func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "dashboard.toml") - contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n") + contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n[db]\nname = \"dashboard_db\"\n") if err := os.WriteFile(path, contents, 0o600); err != nil { t.Fatal(err) } @@ -42,7 +42,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second { t.Fatalf("unexpected http config: %+v", cfg.HTTP) } - if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 { + if cfg.Database.Name != "dashboard_db" || cfg.Database.Port != 5432 { t.Fatalf("unexpected database defaults: %+v", cfg.Database) } } @@ -50,7 +50,7 @@ func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) { func TestLoadRejectsInvalidConfiguration(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "gl.toml") - if err := os.WriteFile(path, []byte("[grpc]\nshutdown-timeout = \"0s\"\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte("[grpc]\ntimeout = \"0s\"\n"), 0o600); err != nil { t.Fatal(err) } @@ -64,3 +64,46 @@ func TestLoadReturnsMissingFileError(t *testing.T) { t.Fatal("expected missing file error") } } + +func TestLoadAppliesSecretEnvironmentOverrides(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "gl.toml") + contents := []byte("environment = \"production\"\n[grpc]\ntimeout = \"3s\"\n[settlement]\nenabled = true\nendpoint = \"https://invalid.example\"\n") + if err := os.WriteFile(path, contents, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret") + t.Setenv("DARANO_GL_ADMIN_TOKEN", "runtime-admin-secret") + t.Setenv("DARANO_GL_SETTLEMENT_ENDPOINT", "https://horizon.example/") + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Database.Password != "runtime-db-secret" { + t.Fatal("database password was not loaded from the environment") + } + if cfg.Settlement.AdminToken != "runtime-admin-secret" { + t.Fatal("admin token was not loaded from the environment") + } + if cfg.Settlement.Endpoint != "https://horizon.example" { + t.Fatalf("unexpected settlement endpoint: %q", cfg.Settlement.Endpoint) + } +} + +func TestLoadDashboardAppliesDatabasePasswordEnvironmentOverride(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "dashboard.toml") + if err := os.WriteFile(path, []byte("[http]\nport = 0\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret") + + cfg, err := LoadDashboard(path) + if err != nil { + t.Fatal(err) + } + if cfg.Database.Password != "runtime-db-secret" { + t.Fatal("database password was not loaded from the environment") + } +} diff --git a/infrastructure/kuknos/submitter.go b/infrastructure/kuknos/submitter.go new file mode 100644 index 0000000..fea33ef --- /dev/null +++ b/infrastructure/kuknos/submitter.go @@ -0,0 +1,55 @@ +package kuknos + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "gl/domain/ledger" +) + +// Submitter sends a pre-signed Stellar/Kuknos transaction to Horizon. Signing +// remains outside GL; GL owns durable admission, retry, and reconciliation. +type Submitter struct { + Endpoint string + Client *http.Client +} + +func (s Submitter) Submit(ctx context.Context, record ledger.Settlement) (string, error) { + if s.Endpoint == "" || record.SignedTransactionXDR == "" { + return "", fmt.Errorf("kuknos submission endpoint and signed xdr are required") + } + form := url.Values{"tx": {record.SignedTransactionXDR}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.Endpoint, "/")+"/transactions", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := s.Client + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var body struct { + Hash string `json:"hash"` + Extras struct { + ResultCodes struct { + Transaction string `json:"transaction"` + } `json:"result_codes"` + } `json:"extras"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decode kuknos response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || body.Hash == "" { + return "", fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, body.Extras.ResultCodes.Transaction) + } + return body.Hash, nil +} diff --git a/infrastructure/postgres/database.go b/infrastructure/postgres/database.go index f5e5a16..939b12b 100644 --- a/infrastructure/postgres/database.go +++ b/infrastructure/postgres/database.go @@ -98,3 +98,7 @@ type txAdapter struct { func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row { return t.Tx.QueryRow(ctx, sql, args...) } + +func (t txAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) { + return t.Tx.Query(ctx, sql, args...) +} diff --git a/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql b/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql new file mode 100644 index 0000000..ea080c7 --- /dev/null +++ b/infrastructure/postgres/migrations/000003_kuknos_settlements.up.sql @@ -0,0 +1,22 @@ +CREATE TABLE kuknos_settlements ( + id uuid PRIMARY KEY, + source_service text NOT NULL CHECK (source_service <> ''), + source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''), + idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''), + status text NOT NULL CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW')), + network text NOT NULL DEFAULT 'kuknos', + signed_transaction_xdr text NOT NULL DEFAULT '', + transaction_hash text NOT NULL DEFAULT '', + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + available_at timestamptz NOT NULL DEFAULT clock_timestamp(), + locked_at timestamptz, + locked_by text NOT NULL DEFAULT '', + last_error text NOT NULL DEFAULT '', + submitted_at timestamptz, + confirmed_at timestamptz, + manual_review_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX kuknos_settlements_due_idx ON kuknos_settlements (status, available_at); diff --git a/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql b/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql new file mode 100644 index 0000000..e91c4e2 --- /dev/null +++ b/infrastructure/postgres/migrations/000004_settlement_operator_audit.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE kuknos_settlement_operator_audits ( + id bigserial PRIMARY KEY, + settlement_id uuid NOT NULL REFERENCES kuknos_settlements(id), + actor_id text NOT NULL CHECK (actor_id <> ''), + action text NOT NULL CHECK (action IN ('RETRY')), + reason text NOT NULL CHECK (reason <> ''), + previous_status text NOT NULL, + resulting_status text NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX kuknos_settlement_operator_audits_settlement_idx + ON kuknos_settlement_operator_audits (settlement_id, created_at DESC); diff --git a/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql b/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql new file mode 100644 index 0000000..f17fbbf --- /dev/null +++ b/infrastructure/postgres/migrations/000005_settlement_reconciliation_issues.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE kuknos_reconciliation_issues ( + settlement_id uuid PRIMARY KEY REFERENCES kuknos_settlements(id), + issue_type text NOT NULL CHECK (issue_type IN ('MISSING_LEDGER_CONFIRMATION','TRANSACTION_HASH_MISMATCH')), + expected_hash text NOT NULL DEFAULT '', + actual_hash text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','RESOLVED')), + first_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(), + last_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(), + resolved_at timestamptz +); + +CREATE INDEX kuknos_reconciliation_issues_open_idx + ON kuknos_reconciliation_issues (status, last_detected_at DESC); diff --git a/infrastructure/postgres/settlement_repository.go b/infrastructure/postgres/settlement_repository.go new file mode 100644 index 0000000..236cbe1 --- /dev/null +++ b/infrastructure/postgres/settlement_repository.go @@ -0,0 +1,204 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "gl/domain/ledger" +) + +type SettlementRepository struct{ database Database } + +type ReconciliationStats struct { + Open, Detected, Resolved int64 +} + +type txQueryer interface { + Query(context.Context, string, ...any) (Rows, error) +} + +func NewSettlementRepository(database Database) *SettlementRepository { + return &SettlementRepository{database: database} +} + +func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) { + var stats ledger.SettlementStats + err := r.database.QueryRow(ctx, `SELECT + COUNT(*) FILTER (WHERE status='PENDING'), + COUNT(*) FILTER (WHERE status='RETRYABLE'), + COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'), + COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0) +FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds) + if err != nil { + return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err) + } + return stats, nil +} + +func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) { + if err := s.Validate(); err != nil { + return ledger.Settlement{}, false, err + } + var inserted string + err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements + (id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`, + s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted) + if err == nil { + return s, false, nil + } + if err == pgx.ErrNoRows { + var existingXDR string + if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil { + return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err) + } + if existingXDR != s.SignedTransactionXDR { + return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict + } + existing, err := r.Get(ctx, inserted, "") + return existing, true, err + } + return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err) +} + +func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) { + var s ledger.Settlement + err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR) + if err != nil { + return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err) + } + return s, nil +} + +func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) { + tx, err := r.database.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil { + return nil, fmt.Errorf("release stale settlement locks: %w", err) + } + queryer, ok := tx.(txQueryer) + if !ok { + return nil, fmt.Errorf("settlement transaction does not support row queries") + } + rows, err := queryer.Query(ctx, `WITH due AS ( +SELECT id FROM kuknos_settlements +WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by='' +ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2 +) +UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp() +FROM due WHERE s.id=due.id +RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []ledger.Settlement + for rows.Next() { + var s ledger.Settlement + if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil { + return nil, err + } + result = append(result, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + return result, nil +} + +func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error { + _, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID) + return err +} + +func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) { + tx, err := r.database.Begin(ctx) + if err != nil { + return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err) + } + defer tx.Rollback(ctx) + var previous ledger.SettlementStatus + if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil { + return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err) + } + if previous == ledger.SettlementConfirmed { + return ledger.Settlement{}, fmt.Errorf("confirmed settlement cannot be retried") + } + if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil { + return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil { + return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err) + } + return r.Get(ctx, id, "") +} + +// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements +// whose latest GL lifecycle evidence is missing or has a different hash. +func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) { + if grace <= 0 { + grace = 10 * time.Minute + } + tx, err := r.database.Begin(ctx) + if err != nil { + return ReconciliationStats{}, err + } + defer tx.Rollback(ctx) + var detected int64 + err = tx.QueryRow(ctx, `WITH inconsistent AS ( + SELECT s.id, + CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type, + s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash + FROM kuknos_settlements s + LEFT JOIN LATERAL ( + SELECT id, blockchain_transaction_hash FROM transaction_events + WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id + ORDER BY event_version DESC, recorded_at DESC LIMIT 1 + ) e ON true + WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval + AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash) +), upserted AS ( + INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash) + SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent + ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type, + expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash, + status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL + WHERE kuknos_reconciliation_issues.status<>'OPEN' + OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type + OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash + OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash + RETURNING 1 +) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected) + if err != nil { + return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err) + } + result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp() + WHERE status='OPEN' AND EXISTS ( + SELECT 1 FROM kuknos_settlements s JOIN transaction_events e + ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id + WHERE s.id=i.settlement_id AND s.status='CONFIRMED' + AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>'' + )`) + if err != nil { + return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err) + } + var open int64 + if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil { + return ReconciliationStats{}, err + } + if err := tx.Commit(ctx); err != nil { + return ReconciliationStats{}, err + } + return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil +} diff --git a/infrastructure/postgres/settlement_repository_test.go b/infrastructure/postgres/settlement_repository_test.go new file mode 100644 index 0000000..d2f14ae --- /dev/null +++ b/infrastructure/postgres/settlement_repository_test.go @@ -0,0 +1,41 @@ +package postgres + +import ( + "context" + "testing" + "time" +) + +func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) { + tx := &fakeTx{rows: []Row{ + fakeRow{values: []any{int64(1)}}, + fakeRow{values: []any{int64(2)}}, + }} + repository := NewSettlementRepository(&fakeDatabase{tx: tx}) + + stats, err := repository.ReconcileConfirmationEvidence(context.Background(), 10*time.Minute) + if err != nil { + t.Fatal(err) + } + if stats.Detected != 1 || stats.Resolved != 1 || stats.Open != 2 { + t.Fatalf("unexpected reconciliation stats: %+v", stats) + } + if !tx.committed || tx.execCount != 1 { + t.Fatalf("unexpected transaction state: %+v", tx) + } +} + +func TestReconcileConfirmationEvidenceRollsBackResolutionFailure(t *testing.T) { + tx := &fakeTx{ + rows: []Row{fakeRow{values: []any{int64(1)}}}, + execErrAt: 1, + } + repository := NewSettlementRepository(&fakeDatabase{tx: tx}) + + if _, err := repository.ReconcileConfirmationEvidence(context.Background(), time.Minute); err == nil { + t.Fatal("expected resolution error") + } + if !tx.rolledBack || tx.committed { + t.Fatalf("failed reconciliation was not rolled back: %+v", tx) + } +} diff --git a/interface/grpc/health.go b/interface/grpc/health.go index 4757e5a..9b8b5e9 100644 --- a/interface/grpc/health.go +++ b/interface/grpc/health.go @@ -5,18 +5,27 @@ import ( "gl/application/health" applicationledger "gl/application/ledger" + applicationsettlement "gl/application/settlement" basev1 "gl/gen/base/v1" ledgerv1 "gl/gen/ledger/v1" ) type Handler struct { ledgerv1.UnimplementedGeneralLedgerServiceServer - health *health.Service - ledger *applicationledger.Service + health *health.Service + ledger *applicationledger.Service + settlement *applicationsettlement.Service + adminToken string } -func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service) *Handler { - return &Handler{health: healthService, ledger: ledgerService} +func (h *Handler) WithSettlementAdminToken(token string) *Handler { h.adminToken = token; return h } + +func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service, settlementService ...*applicationsettlement.Service) *Handler { + var settlement *applicationsettlement.Service + if len(settlementService) > 0 { + settlement = settlementService[0] + } + return &Handler{health: healthService, ledger: ledgerService, settlement: settlement} } func NewHealthHandler(service *health.Service) *Handler { @@ -26,7 +35,13 @@ func NewHealthHandler(service *health.Service) *Handler { func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) { result := h.health.Check(ctx) return &ledgerv1.HealthResponse{ - Serving: result.Serving, - DatabaseReady: result.DatabaseReady, + Serving: result.Serving, + DatabaseReady: result.DatabaseReady, + SettlementEnabled: result.SettlementEnabled, + SettlementReady: result.SettlementReady, + SettlementPending: result.SettlementStats.Pending, + SettlementRetryable: result.SettlementStats.Retryable, + SettlementManualReview: result.SettlementStats.ManualReview, + OldestPendingSeconds: result.SettlementStats.OldestPendingSeconds, }, nil } diff --git a/interface/grpc/settlement.go b/interface/grpc/settlement.go new file mode 100644 index 0000000..6411ca2 --- /dev/null +++ b/interface/grpc/settlement.go @@ -0,0 +1,60 @@ +package grpcadapter + +import ( + "context" + "crypto/subtle" + + "gl/domain/ledger" + ledgerv1 "gl/gen/ledger/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.EnqueueSettlementRequest) (*ledgerv1.EnqueueSettlementResponse, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil +} + +func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySettlementRequest) (*ledgerv1.RetrySettlementResponse, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + values := metadata.ValueFromIncomingContext(ctx, "x-admin-token") + if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 { + return nil, status.Error(codes.PermissionDenied, "invalid administrative token") + } + record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason()) + if err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil +} + +func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement { + return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError} +} + +func (h *Handler) GetSettlement(ctx context.Context, request *ledgerv1.GetSettlementRequest) (*ledgerv1.Settlement, error) { + if h.settlement == nil { + return nil, status.Error(codes.Unimplemented, "settlement is not configured") + } + var id, key string + switch lookup := request.GetLookup().(type) { + case *ledgerv1.GetSettlementRequest_SettlementId: + id = lookup.SettlementId + case *ledgerv1.GetSettlementRequest_IdempotencyKey: + key = lookup.IdempotencyKey + } + record, err := h.settlement.Get(ctx, id, key) + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + return settlementMessage(record), nil +} diff --git a/scripts/air-run.sh b/scripts/air-run.sh deleted file mode 100644 index d44e8d7..0000000 --- a/scripts/air-run.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash - -set -u - -./tmp/gl -conf ./gl.cfg.toml & -gl_pid=$! - -./tmp/dashboard -conf ./dashboard.cfg.toml & -dashboard_pid=$! - -shutdown() { - trap - EXIT - kill -TERM "$gl_pid" "$dashboard_pid" 2>/dev/null || true - wait "$gl_pid" 2>/dev/null || true - wait "$dashboard_pid" 2>/dev/null || true -} - -trap 'exit 0' INT TERM -trap shutdown EXIT - -wait -n "$gl_pid" "$dashboard_pid" -status=$? -exit "$status"