feat(gl): add standalone ledger authority mode
CI/CD Stage / stage (push) Failing after 1m12s

This commit is contained in:
2026-10-06 22:40:07 +03:30
parent 01d13dbbf2
commit 88e227f80c
26 changed files with 2033 additions and 196 deletions
+38 -3
View File
@@ -1,6 +1,6 @@
# General Ledger service design # General Ledger service design
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28. Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28; standalone GL authority API implemented on 2026-10-05.
## Purpose and ownership ## Purpose and ownership
@@ -30,9 +30,10 @@ disabled automatically.
5. Account, asset, source transaction, correlation, actor, occurrence time, and 5. Account, asset, source transaction, correlation, actor, occurrence time, and
recording time are retained on every applicable record. recording time are retained on every applicable record.
6. A journal commits atomically with all its entries or not at all. 6. A journal commits atomically with all its entries or not at all.
7. A financial effect is journaled only when Wallet has committed that effect. 7. In normal Wallet ingestion, a financial effect is journaled only when Wallet has committed that effect.
Pending and failed lifecycle events may be retained as audit events but do Pending and failed lifecycle events may be retained as audit events but do
not create monetary entries. not create monetary entries. For standalone GL-only execution, the GL journal
commit itself is the financial effect; any Wallet copy is a projection.
8. Derived balances are rebuildable solely by ordering and summing immutable 8. Derived balances are rebuildable solely by ordering and summing immutable
entries. Cached balances are disposable projections, never source data. entries. Cached balances are disposable projections, never source data.
@@ -115,6 +116,37 @@ does not hold a wallet database transaction open.
## Operating modes and failure semantics ## Operating modes and failure semantics
GL now exposes its own persisted `NORMAL` / `GL_ONLY` operating state through
`GetOperatingMode` and the authenticated `SetOperatingMode` RPC. This is
distinct from Wallet's existing `KUKNOS_DISABLED` configuration, which still
makes Wallet read-only until its callers are integrated with this API.
In `GL_ONLY`, `PostInternalJournal` executes balanced internal effects
synchronously. PostgreSQL locks user accounts and checks available/frozen
balances in the same commit; conflicting spends cannot both succeed. All
journal append paths enforce the GL-only funds rules; ordinary ingestion rejects
new effects in this mode, so internal execution cannot be bypassed. Blockchain
references
and external blockchain account movements are forbidden, new settlement
requests are rejected, and the worker stops claiming existing settlements.
The mode survives restarts and changes have immutable actor/reason/revision
audits and OpenTelemetry spans. Switching drains posting transactions and
rejects in-flight settlement claims or negative user balances. Returning to
`NORMAL` requires explicit operator reconciliation confirmation.
The ledger must be complete before switching: restore/import opening balances
and drain committed Wallet outbox effects and in-flight Wallet processing.
GL-only mode keeps ledger reads/writes available while reporting settlement as
paused; existing normal-mode Wallet clients therefore fail closed rather than
continuing blockchain-dependent processing. Internal executions must not also
be delivered through that outbox as new monetary effects. User authorization,
asset eligibility, fees, and business counterparty mapping remain admission
responsibilities. Actual external payment settlement cannot occur internally.
See [the authority API](deployment/AUTHORITY_API.md) and
[standalone operation](deployment/STANDALONE.md).
The broader Wallet availability policy remains:
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful - `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
only after its required GL journal and Kuknos settlement evidence exist. only after its required GL journal and Kuknos settlement evidence exist.
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator - `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
@@ -145,6 +177,9 @@ The first `ledger.v1.GeneralLedgerService` contract provides:
- `AppendTransactionEvent` for non-monetary lifecycle history; - `AppendTransactionEvent` for non-monetary lifecycle history;
- `GetJournal` and `ListEntries` for audit and replay inspection; - `GetJournal` and `ListEntries` for audit and replay inspection;
- `GetBalance` for a rebuildable account/asset projection; - `GetBalance` for a rebuildable account/asset projection;
- `GetUserBalances` for available, frozen, and total balances per user asset;
- `GetOperatingMode` / `SetOperatingMode` for attributable operator cutover;
- `PostInternalJournal` for atomic internal execution in `GL_ONLY`;
- `Health` for readiness and database health. - `Health` for readiness and database health.
Wallet depends on its own `Ledger` port. Its gRPC implementation translates Wallet depends on its own `Ledger` port. Its gRPC implementation translates
+5
View File
@@ -6,6 +6,11 @@ blockchain or provider is unavailable.
The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md). The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md).
For an independent PostgreSQL + ledger API + dashboard deployment with no
blockchain network access, see [Standalone General Ledger](deployment/STANDALONE.md).
The [authority API](deployment/AUTHORITY_API.md) provides an authenticated,
persisted GL-only toggle, internal journal execution, and per-user balances.
Generate protobufs, test, and build with: Generate protobufs, test, and build with:
```bash ```bash
+8
View File
@@ -15,6 +15,10 @@ type SettlementMonitor interface {
Stats(context.Context) (ledger.SettlementStats, error) Stats(context.Context) (ledger.SettlementStats, error)
} }
type SettlementAuthority interface {
SettlementAllowed(context.Context) (bool, error)
}
type Status struct { type Status struct {
Serving bool Serving bool
DatabaseReady bool DatabaseReady bool
@@ -50,6 +54,10 @@ func (s *Service) Check(ctx context.Context) Status {
if err == nil { if err == nil {
status.SettlementStats = stats status.SettlementStats = stats
status.SettlementReady = true status.SettlementReady = true
if authority, ok := s.settlement.(SettlementAuthority); ok {
allowed, authorityErr := authority.SettlementAllowed(ctx)
status.SettlementReady = authorityErr == nil && allowed
}
} }
} }
return status return status
+12
View File
@@ -61,3 +61,15 @@ func TestCheckReportsSettlementQueryFailure(t *testing.T) {
t.Fatalf("unexpected status: %+v", got) t.Fatalf("unexpected status: %+v", got)
} }
} }
type pausedSettlementStub struct{ settlementStub }
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
got := NewService(databaseStub{}, monitor).Check(context.Background())
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
t.Fatalf("unexpected GL-only readiness: %+v", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ledger
import (
"context"
"strings"
domain "gl/domain/ledger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
type AuthorityRepository interface {
OperatingMode(context.Context) (domain.OperatingState, error)
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
}
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
return repository.OperatingMode(ctx)
}
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
defer span.End()
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "mode change failed")
return domain.OperatingState{}, err
}
if state.Revision != revision {
span.AddEvent("ledger.operating_mode_changed")
}
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
return state, nil
}
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return nil, domain.ErrNotFound
}
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
}
// PostInternalJournal executes exact balanced entries without network settlement.
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if strings.TrimSpace(command.ActorID) == "" {
return AppendJournalResult{}, invalid("actor id is required for internal execution")
}
if command.Blockchain != (domain.BlockchainReference{}) {
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
}
for _, entry := range command.Entries {
if entry.Account.Class == domain.AccountClassExternalBlockchain {
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
}
}
command.Metadata = cloneMetadata(command.Metadata)
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
return s.appendJournal(ctx, command)
}
+65
View File
@@ -0,0 +1,65 @@
package ledger
import (
"context"
"errors"
domain "gl/domain/ledger"
"testing"
)
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
for _, tc := range []struct {
name string
change func(*AppendJournalCommand)
}{
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
} {
t.Run(tc.name, func(t *testing.T) {
repository := newRepositoryStub()
command := validAppendCommand()
command.ActorID = "operator"
tc.change(&command)
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
t.Fatalf("invalid internal execution reached repository: %v", err)
}
})
}
}
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("test"))
command := validAppendCommand()
command.ActorID = "operator"
command.Metadata = map[string]string{"correlation": "value"}
result, err := service.PostInternalJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
t.Fatal("internal provenance missing")
}
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
t.Fatal("caller metadata mutated")
}
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
_, err = service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("ordinary API accepted internal marker: %v", err)
}
}
func TestAuthorityValidatesInputs(t *testing.T) {
service := NewService(newRepositoryStub(), nil)
for _, owner := range []string{"", " "} {
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid owner: %v", err)
}
}
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid mode: %v", err)
}
}
+7
View File
@@ -71,6 +71,13 @@ type AppendJournalResult struct {
} }
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) { func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
}
return s.appendJournal(ctx, command)
}
func (s *Service) appendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
journalID, err := s.newID() journalID, err := s.newID()
if err != nil { if err != nil {
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err) return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
+24
View File
@@ -3,15 +3,22 @@ package main
import ( import (
"context" "context"
"flag" "flag"
"net"
"os" "os"
"strconv"
"time" "time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"gl/infrastructure/config" "gl/infrastructure/config"
"gl/infrastructure/postgres" "gl/infrastructure/postgres"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
) )
func main() { func main() {
configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file") configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file")
grpcProbe := flag.Bool("grpc", false, "check the serving GL API instead of only its database")
flag.Parse() flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
@@ -19,6 +26,23 @@ func main() {
if err != nil { if err != nil {
os.Exit(1) os.Exit(1)
} }
if *grpcProbe {
host := cfg.GRPC.Host
if host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
connection, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(cfg.GRPC.Port)), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
os.Exit(1)
}
defer connection.Close()
health, err := ledgerv1.NewGeneralLedgerServiceClient(connection).Health(ctx, &basev1.Empty{})
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
os.Exit(1)
}
return
}
database, err := postgres.Open(ctx, cfg.Database) database, err := postgres.Open(ctx, cfg.Database)
if err != nil { if err != nil {
os.Exit(1) os.Exit(1)
+145
View File
@@ -0,0 +1,145 @@
# GL authority API
The `ledger.v1.GeneralLedgerService` gRPC service exposes four additional RPCs.
The contract is in `../proto/ledger/v1/{msg,srv}.proto` from the GL directory;
the generated Go client is in `gen/ledger/v1`.
## Operator toggle
In the existing AdminPanel, open **GL journals → GL operating mode**.
The checkbox, required reason, and recovery confirmation call GL directly.
The operator's ID comes from the authenticated session. Permission
`ledgerops.set_operating_mode` is required; superusers already have it.
Operators also need `ledgerops.view_generalledgerjournal` for journal navigation.
Register the permission using AdminPanel's usual Django `migrate` command
and grant it only to designated operators. Configure the existing
`GL_ADMIN_GRPC_ADDRESS` and `GL_ADMIN_GRPC_TOKEN` settings, with the token
matching GL's `DARANO_GL_ADMIN_TOKEN`. These are server-side settings.
The AdminPanel control changes GL's authority state. Existing Wallet flows
still need to call the internal posting API; changing Wallet's old
`kuknos-enabled` configuration alone continues to select read-only behavior.
## Future Wallet routing contract
Wallet remains the entry point in both modes. The public Wallet RPCs, request
and response schemas, authentication, and client UI stay unchanged; clients
must not call GL directly or choose the execution mode.
| GL mode | Transaction execution | User balance response |
| --- | --- | --- |
| `NORMAL` (default) | Existing Wallet admission → GL settlement queue → Stellar/Kuknos | Existing Wallet behavior |
| `GL_ONLY` | Wallet admission → GL `PostInternalJournal`, with no blockchain call | Wallet returns GL `GetUserBalances` values in its existing response format |
This routing is reserved for future activation. The GL backend and AdminPanel
control exist, but the Wallet routing above is not implemented yet. Keep
`NORMAL` selected for the current Wallet deployment. Enabling the control now
pauses settlement and causes existing Wallet financial writes to fail closed;
it does not automatically turn those endpoints into internal execution.
The future implementation must retain Wallet's authorization and business
validation, persist the execution choice and exact command for recovery, and
use the same transaction identity for retries. A GL commit establishes
financial success; subsequent Wallet history updates are recoverable
projections and must not produce a second financial journal through the normal
outbox. Reconciliation must preserve the original execution choice across
mode changes rather than reinterpret an older blockchain transaction as an
internal transfer. Cutover requires draining normal-mode work and checking
opening balances. Returning to normal requires reconciliation before external
settlement resumes.
Internal transfers, freezes, releases, trades, and their fees can execute
inside GL. An actual external blockchain payment cannot be reported as
externally settled while Stellar/Kuknos is unavailable.
`GetOperatingMode(base.v1.Empty)` returns `mode`, `revision`, `actor_id`,
`reason`, and `changed_at`.
`SetOperatingMode` requires exactly one `x-admin-token` metadata value matching
`DARANO_GL_ADMIN_TOKEN`. To enable internal authority, send:
```json
{
"mode": "GL_ONLY",
"expectedRevision": "0",
"actorId": "operator-17",
"reason": "Kuknos outage; opening balances and outbox checked"
}
```
Use the revision from the latest read, rather than always sending zero.
Mode persists across service restarts. A concurrent toggle returns `ABORTED`;
read the state before deciding whether another change is needed. A toggle
with in-flight settlements or negative user balances returns
`FAILED_PRECONDITION`. Returning to `NORMAL` requires
`reconciliationConfirmed: true` after operator reconciliation.
## Internal execution
`PostInternalJournal` accepts the existing `AppendJournalRequest` and requires
the administrative token, an actor ID, and `GL_ONLY` mode for new postings.
For a transfer, debit the sender's `USER_AVAILABLE` account and credit the
recipient's account by the same exact decimal amount. For a freeze, debit
available and credit frozen; for a release, reverse those legs. Multi-asset
trade and fee legs must balance independently per asset.
The API accepts exact balanced entries so it can represent all internal
business effects without depending on Stellar XDR, account sequences, or a
Horizon endpoint. It does not determine business fees, asset eligibility, or
whether the caller's user is allowed to move those funds; trusted application
admission must supply and authorize those legs. It forbids blockchain
references and `EXTERNAL_BLOCKCHAIN` accounts. The ordinary journal API
remains the normal-mode ingestion interface and rejects new effects in
`GL_ONLY`, while allowing identical retries of previously ingested journals.
Success means the journal and every entry committed atomically. Insufficient
available or frozen funds returns `FAILED_PRECONDITION`. Retry uncertain
outcomes with the exact same idempotency key and payload. Identical retries
return `already_existed=true`; conflicting payloads return `ALREADY_EXISTS`.
A concurrency abort returns `ABORTED`; retry that same request rather than
allocating a new transaction identity. Database errors never mean success.
The server reserves journal metadata key `gl_execution` and stores
`GL_ONLY` as immutable execution provenance. Callers do not set it.
## User balances
`GetUserBalances` reads GL entries without any blockchain call:
```json
{"ownerType": "user", "ownerId": "17", "assetId": "7"}
```
Example response:
```json
{
"ownerType": "user",
"ownerId": "17",
"balances": [
{"assetId": "7", "available": "34", "frozen": "6", "total": "40"}
]
}
```
Omit `assetId` to return all journaled assets for that owner. An unknown owner
has an empty list; a requested asset with no entries returns zero amounts.
Amounts are decimal strings. Available and frozen are read in one database
snapshot. The endpoint is available in either operating mode.
The API is an internal service: expose it through an authenticated application
gateway that binds the requested owner to the logged-in user. Do not expose
arbitrary owner lookups or the operator token directly to a browser.
## Verification
The opt-in PostgreSQL test creates and removes its own isolated schema:
```bash
GL_TEST_DATABASE_URL='<disposable PostgreSQL connection URL>' \
go test -race ./infrastructure/postgres -run TestAuthorityWithPostgres -v
```
It exercises mode authentication, revision conflicts, immutable audit records,
settlement suspension, concurrent overspending prevention, idempotent replay,
freezes/releases, internal RPC posting, and user balance reconstruction.
+133
View File
@@ -0,0 +1,133 @@
# Standalone General Ledger
GL's journal API and explorer require only GL's own PostgreSQL database.
Stellar, Kuknos, Wallet, and an OpenTelemetry collector are not startup or
posting dependencies. Blockchain references on journals are optional.
## Run independently
From the GL directory, set two runtime secrets and launch:
```bash
export DARANO_GL_DB_PASSWORD='<database password>'
export DARANO_GL_ADMIN_TOKEN='<administrative retry token>'
docker compose -f deployment/compose.standalone.yaml up --build -d --wait
```
The gRPC API is on `127.0.0.1:8600`; the read-only dashboard is on
`http://127.0.0.1:8601`. Images must be built/downloaded before an outage.
The runtime network is internal and has no external network access.
Settlement submission and telemetry export are explicitly disabled. The
settlement queue remains persisted and queryable; disabling its worker does
not confirm queued blockchain transactions.
The named `ledger-data` volume retains journals across container restarts and
ordinary `docker compose down`. Do not use `down -v` on a ledger containing
financial records.
This launch creates a **new, empty ledger**. Before treating it as the source
of truth, restore the existing authoritative GL database, or import approved,
balanced opening journals and replay all subsequent committed movements.
Running a second empty GL does not provide failover for the first.
## What remains available during a blockchain outage
- Atomic, balanced, append-only journal posting with idempotent retries.
- Journal lookup, history, lifecycle events, and balances rebuilt from entries.
- Read-only exploration of the same database.
- Durable storage of settlement requests already carrying signed XDR.
GL readiness reflects its database. Settlement backlog or submission failures
do not prevent ledger reads or writes. Database loss or failed journal commits
must prevent a caller from declaring an unrecorded financial effect successful.
## Financial authority versus settlement
The committed journal is the source of book balances. A blockchain hash is
settlement evidence, not the journal identity. Do not modify a journal to add
later confirmation; retain it as separate settlement/lifecycle evidence.
The persisted `GL_ONLY` operating mode makes GL the execution authority.
`PostInternalJournal` commits balanced internal effects synchronously, checks
that affected available/frozen accounts do not become negative, and serializes
concurrent spending inside PostgreSQL. An identical retry returns the original
journal even after the mode changes. In `GL_ONLY`, new effects must use the
authenticated internal posting API; ordinary ingestion rejects new journals
while still accepting identical retries. PostgreSQL enforces the funds checks
and rejects external blockchain movements at sealing. Business admission still
owns user authorization, asset eligibility, fees, and exact counterparty legs.
GL starts in `NORMAL`; mode is stored in its database and survives restarts.
Disabling the settlement worker in TOML is separate from choosing `GL_ONLY`.
Read the current revision, then use the authenticated `SetOperatingMode` API
to change mode. Each change retains actor, reason, revision, and time in an
immutable audit table. Stale revisions fail. Enabling `GL_ONLY` requires no
in-flight settlement claims and no negative user account balances. Drain the
Wallet outbox, pause and drain in-flight Wallet processing, and confirm the
ledger includes every committed effect before switching.
The existing AdminPanel controls GL mode through its GL journals →
GL operating mode page. Configure `GL_ADMIN_GRPC_ADDRESS` and
`GL_ADMIN_GRPC_TOKEN` (matching GL's `DARANO_GL_ADMIN_TOKEN`), and grant
`ledgerops.set_operating_mode` to designated operators. AdminPanel never
updates GL tables directly; mode changes go through GL's API.
Wallet remains the public entry point in both modes: the future GL-only route
must preserve the existing Wallet endpoints, response formats, and UI. In
normal operation, admitted requests continue through GL settlement to
Stellar/Kuknos. With GL-only routing implemented and enabled, Wallet will
instead submit internal effects directly to GL and serve GL balances through
those same Wallet endpoints. See [the future Wallet routing contract](AUTHORITY_API.md#future-wallet-routing-contract).
That Wallet routing is not implemented yet; keep `NORMAL` selected for the
current deployment. The existing Wallet configuration toggle still selects read-only behavior.
Wallet consumers must route internal execution to `PostInternalJournal`;
the AdminPanel change does not replace Wallet's blockchain-dependent workflows.
GL keeps its own readiness healthy but reports settlement as paused, so existing
normal-mode Wallet clients fail closed until their internal routes are integrated.
A committed GL journal is the financial
success result; Wallet state becomes an idempotent projection of it. Do not
also send the same effect through the normal Wallet outbox under a different
key: that would duplicate the movement. Existing normal-mode committed Wallet
effects continue using their durable outbox.
Internal transfers, freezes, releases, trades, and clearing movements are
represented by balanced entries with no blockchain reference. An operation
that actually sends money outside the internal books cannot become externally
settled during an outage. External blockchain account entries and settlement
submission are rejected in `GL_ONLY`; store any future external intent
separately and keep its settlement pending.
After blockchain recovery, reconcile journals against external evidence before
resuming normal settlement. Returning to `NORMAL` requires an operator to set
`reconciliation_confirmed=true`; this is an explicit assertion, not an automated
proof. While `GL_ONLY` is enabled, the worker leaves the existing settlement
queue untouched. Mode changes reject in-flight claims so a toggle cannot race
an already claimed submission. The existing queue requires signed XDR; it does not
turn arbitrary journals into blockchain transactions. Sequence numbers,
transaction expiry, and exhausted retries may require operator review and
reconstruction. Never blindly resubmit all journals or mark them settled.
## Verify and preserve the ledger
The existing write/read load test can exercise a **disposable** standalone
database without any blockchain endpoint:
```bash
go run ./cmd/gl-loadtest -duration 5s -concurrency 1 -wallets 10
```
This writes synthetic journals and must not run against an authoritative
financial database. Restart GL and confirm the same journal IDs and balances
remain queryable.
Keep PostgreSQL backups and WAL recovery independent of the blockchain
services, and periodically verify a restore. For example, create a logical
backup without deleting or changing ledger records:
```bash
docker compose -f deployment/compose.standalone.yaml exec -T postgres \
pg_dump -U gl -d gl_db -Fc > gl.dump
```
A named volume provides persistence on one host, not disaster recovery.
+65
View File
@@ -0,0 +1,65 @@
name: darano-gl-standalone
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: gl_db
POSTGRES_USER: gl
POSTGRES_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
volumes:
- ledger-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gl -d gl_db"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks: [ledger]
gl:
image: darano-gl:standalone
build:
context: ..
dockerfile: deployment/Dockerfile
args:
GO_PROXY: ${GO_PROXY:-https://proxy.golang.org}
command: ["-conf", "/app/standalone.toml"]
environment:
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
DARANO_GL_ADMIN_TOKEN: ${DARANO_GL_ADMIN_TOKEN:?Set DARANO_GL_ADMIN_TOKEN}
volumes:
- ./gl.standalone.toml:/app/standalone.toml:ro
ports: ["127.0.0.1:8600:8600"]
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/healthcheck", "-conf", "/app/standalone.toml", "-grpc"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
networks: [ledger]
dashboard:
image: darano-gl:standalone
entrypoint: ["/app/dashboard"]
command: ["-conf", "/app/standalone.toml"]
environment:
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
volumes:
- ./dashboard.standalone.toml:/app/standalone.toml:ro
ports: ["127.0.0.1:8601:8601"]
depends_on:
gl:
condition: service_healthy
restart: unless-stopped
networks: [ledger]
volumes:
ledger-data:
networks:
ledger:
internal: true
+16
View File
@@ -0,0 +1,16 @@
environment = "production"
[http]
host = "0.0.0.0"
port = 8601
read-header-timeout = "5s"
shutdown-timeout = "10s"
[db]
host = "postgres"
port = 5432
name = "gl_db"
user = "gl"
[telemetry]
enabled = false
+18
View File
@@ -0,0 +1,18 @@
environment = "production"
[grpc]
host = "0.0.0.0"
port = 8600
timeout = "10s"
[db]
host = "postgres"
port = 5432
name = "gl_db"
user = "gl"
[settlement]
enabled = false
[telemetry]
enabled = false
+36
View File
@@ -0,0 +1,36 @@
package ledger
import (
"errors"
"time"
)
type OperatingMode string
const (
ModeNormal OperatingMode = "NORMAL"
ModeGLOnly OperatingMode = "GL_ONLY"
InternalExecutionMetadata = "gl_execution"
)
var (
ErrModeConflict = errors.New("operating mode revision changed")
ErrModeRequired = errors.New("operation is not permitted in the current ledger mode")
ErrInsufficientFunds = errors.New("insufficient ledger funds")
ErrSettlementInFlight = errors.New("settlement is in flight; drain the worker before switching")
ErrConcurrentPosting = errors.New("concurrent posting conflicted; retry with the same idempotency key")
)
type OperatingState struct {
Mode OperatingMode
Revision int64
ActorID string
Reason string
ChangedAt time.Time
}
type UserAssetBalance struct {
AssetID int64
Available Amount
Frozen Amount
}
+523 -144
View File
File diff suppressed because it is too large Load Diff
+59 -43
View File
@@ -25,8 +25,12 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor
const file_ledger_v1_srv_proto_rawDesc = "" + const file_ledger_v1_srv_proto_rawDesc = "" +
"\n" + "\n" +
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xc1\x06\n" + "\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\x95\t\n" +
"\x14GeneralLedgerService\x123\n" + "\x14GeneralLedgerService\x12D\n" +
"\x10GetOperatingMode\x12\x0e.base.v1.Empty\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
"\x10SetOperatingMode\x12\".ledger.v1.SetOperatingModeRequest\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
"\x13PostInternalJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12X\n" +
"\x0fGetUserBalances\x12!.ledger.v1.GetUserBalancesRequest\x1a\".ledger.v1.GetUserBalancesResponse\x123\n" +
"\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" + "\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" +
"\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" + "\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" +
"\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" + "\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" +
@@ -44,49 +48,61 @@ const file_ledger_v1_srv_proto_rawDesc = "" +
var file_ledger_v1_srv_proto_goTypes = []any{ var file_ledger_v1_srv_proto_goTypes = []any{
(*v1.Empty)(nil), // 0: base.v1.Empty (*v1.Empty)(nil), // 0: base.v1.Empty
(*AppendJournalRequest)(nil), // 1: ledger.v1.AppendJournalRequest (*SetOperatingModeRequest)(nil), // 1: ledger.v1.SetOperatingModeRequest
(*AppendTransactionEventRequest)(nil), // 2: ledger.v1.AppendTransactionEventRequest (*AppendJournalRequest)(nil), // 2: ledger.v1.AppendJournalRequest
(*GetJournalRequest)(nil), // 3: ledger.v1.GetJournalRequest (*GetUserBalancesRequest)(nil), // 3: ledger.v1.GetUserBalancesRequest
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest (*AppendTransactionEventRequest)(nil), // 4: ledger.v1.AppendTransactionEventRequest
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest (*GetJournalRequest)(nil), // 5: ledger.v1.GetJournalRequest
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest (*ListEntriesRequest)(nil), // 6: ledger.v1.ListEntriesRequest
(*EnqueueSettlementRequest)(nil), // 7: ledger.v1.EnqueueSettlementRequest (*GetBalanceRequest)(nil), // 7: ledger.v1.GetBalanceRequest
(*GetSettlementRequest)(nil), // 8: ledger.v1.GetSettlementRequest (*ReplayJournalsRequest)(nil), // 8: ledger.v1.ReplayJournalsRequest
(*RetrySettlementRequest)(nil), // 9: ledger.v1.RetrySettlementRequest (*EnqueueSettlementRequest)(nil), // 9: ledger.v1.EnqueueSettlementRequest
(*HealthResponse)(nil), // 10: ledger.v1.HealthResponse (*GetSettlementRequest)(nil), // 10: ledger.v1.GetSettlementRequest
(*AppendJournalResponse)(nil), // 11: ledger.v1.AppendJournalResponse (*RetrySettlementRequest)(nil), // 11: ledger.v1.RetrySettlementRequest
(*AppendTransactionEventResponse)(nil), // 12: ledger.v1.AppendTransactionEventResponse (*OperatingModeResponse)(nil), // 12: ledger.v1.OperatingModeResponse
(*Journal)(nil), // 13: ledger.v1.Journal (*AppendJournalResponse)(nil), // 13: ledger.v1.AppendJournalResponse
(*ListEntriesResponse)(nil), // 14: ledger.v1.ListEntriesResponse (*GetUserBalancesResponse)(nil), // 14: ledger.v1.GetUserBalancesResponse
(*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse (*HealthResponse)(nil), // 15: ledger.v1.HealthResponse
(*ReplayJournalsResponse)(nil), // 16: ledger.v1.ReplayJournalsResponse (*AppendTransactionEventResponse)(nil), // 16: ledger.v1.AppendTransactionEventResponse
(*EnqueueSettlementResponse)(nil), // 17: ledger.v1.EnqueueSettlementResponse (*Journal)(nil), // 17: ledger.v1.Journal
(*Settlement)(nil), // 18: ledger.v1.Settlement (*ListEntriesResponse)(nil), // 18: ledger.v1.ListEntriesResponse
(*RetrySettlementResponse)(nil), // 19: ledger.v1.RetrySettlementResponse (*GetBalanceResponse)(nil), // 19: ledger.v1.GetBalanceResponse
(*ReplayJournalsResponse)(nil), // 20: ledger.v1.ReplayJournalsResponse
(*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse
(*Settlement)(nil), // 22: ledger.v1.Settlement
(*RetrySettlementResponse)(nil), // 23: ledger.v1.RetrySettlementResponse
} }
var file_ledger_v1_srv_proto_depIdxs = []int32{ var file_ledger_v1_srv_proto_depIdxs = []int32{
0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty 0, // 0: ledger.v1.GeneralLedgerService.GetOperatingMode:input_type -> base.v1.Empty
1, // 1: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest 1, // 1: ledger.v1.GeneralLedgerService.SetOperatingMode:input_type -> ledger.v1.SetOperatingModeRequest
2, // 2: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest 2, // 2: ledger.v1.GeneralLedgerService.PostInternalJournal:input_type -> ledger.v1.AppendJournalRequest
3, // 3: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest 3, // 3: ledger.v1.GeneralLedgerService.GetUserBalances:input_type -> ledger.v1.GetUserBalancesRequest
4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest 0, // 4: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest 2, // 5: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest 4, // 6: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
7, // 7: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest 5, // 7: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
8, // 8: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest 6, // 8: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
9, // 9: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest 7, // 9: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
10, // 10: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse 8, // 10: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
11, // 11: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse 9, // 11: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
12, // 12: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse 10, // 12: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
13, // 13: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal 11, // 13: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
14, // 14: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse 12, // 14: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
15, // 15: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse 12, // 15: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
16, // 16: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse 13, // 16: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse
17, // 17: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse 14, // 17: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse
18, // 18: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement 15, // 18: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
19, // 19: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse 13, // 19: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
10, // [10:20] is the sub-list for method output_type 16, // 20: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
0, // [0:10] is the sub-list for method input_type 17, // 21: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
18, // 22: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
19, // 23: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
20, // 24: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
21, // 25: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse
22, // 26: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement
23, // 27: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse
14, // [14:28] is the sub-list for method output_type
0, // [0:14] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name 0, // [0:0] is the sub-list for field type_name
+156
View File
@@ -20,6 +20,10 @@ import (
const _ = grpc.SupportPackageIsVersion9 const _ = grpc.SupportPackageIsVersion9
const ( const (
GeneralLedgerService_GetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/GetOperatingMode"
GeneralLedgerService_SetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/SetOperatingMode"
GeneralLedgerService_PostInternalJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/PostInternalJournal"
GeneralLedgerService_GetUserBalances_FullMethodName = "/ledger.v1.GeneralLedgerService/GetUserBalances"
GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health" GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health"
GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal" GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal"
GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent" GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent"
@@ -36,6 +40,12 @@ const (
// //
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type GeneralLedgerServiceClient interface { type GeneralLedgerServiceClient interface {
GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error)
// Operator-only; requires x-admin-token and an optimistic revision.
SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error)
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error)
Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error)
AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error) AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error)
@@ -57,6 +67,46 @@ func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerSer
return &generalLedgerServiceClient{cc} return &generalLedgerServiceClient{cc}
} }
func (c *generalLedgerServiceClient) GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OperatingModeResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetOperatingMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OperatingModeResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_SetOperatingMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AppendJournalResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_PostInternalJournal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserBalancesResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetUserBalances_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) { func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse) out := new(HealthResponse)
@@ -161,6 +211,12 @@ func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *Re
// All implementations should embed UnimplementedGeneralLedgerServiceServer // All implementations should embed UnimplementedGeneralLedgerServiceServer
// for forward compatibility. // for forward compatibility.
type GeneralLedgerServiceServer interface { type GeneralLedgerServiceServer interface {
GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error)
// Operator-only; requires x-admin-token and an optimistic revision.
SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error)
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error)
Health(context.Context, *v1.Empty) (*HealthResponse, error) Health(context.Context, *v1.Empty) (*HealthResponse, error)
AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error)
@@ -181,6 +237,18 @@ type GeneralLedgerServiceServer interface {
// pointer dereference when methods are called. // pointer dereference when methods are called.
type UnimplementedGeneralLedgerServiceServer struct{} type UnimplementedGeneralLedgerServiceServer struct{}
func (UnimplementedGeneralLedgerServiceServer) GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOperatingMode not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetOperatingMode not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PostInternalJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserBalances not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) { func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented") return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
} }
@@ -231,6 +299,78 @@ func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedg
s.RegisterService(&GeneralLedgerService_ServiceDesc, srv) s.RegisterService(&GeneralLedgerService_ServiceDesc, srv)
} }
func _GeneralLedgerService_GetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(v1.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetOperatingMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, req.(*v1.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_SetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetOperatingModeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_SetOperatingMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, req.(*SetOperatingModeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_PostInternalJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AppendJournalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_PostInternalJournal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, req.(*AppendJournalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetUserBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserBalancesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetUserBalances_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, req.(*GetUserBalancesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(v1.Empty) in := new(v1.Empty)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -418,6 +558,22 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "ledger.v1.GeneralLedgerService", ServiceName: "ledger.v1.GeneralLedgerService",
HandlerType: (*GeneralLedgerServiceServer)(nil), HandlerType: (*GeneralLedgerServiceServer)(nil),
Methods: []grpc.MethodDesc{ Methods: []grpc.MethodDesc{
{
MethodName: "GetOperatingMode",
Handler: _GeneralLedgerService_GetOperatingMode_Handler,
},
{
MethodName: "SetOperatingMode",
Handler: _GeneralLedgerService_SetOperatingMode_Handler,
},
{
MethodName: "PostInternalJournal",
Handler: _GeneralLedgerService_PostInternalJournal_Handler,
},
{
MethodName: "GetUserBalances",
Handler: _GeneralLedgerService_GetUserBalances_Handler,
},
{ {
MethodName: "Health", MethodName: "Health",
Handler: _GeneralLedgerService_Health_Handler, Handler: _GeneralLedgerService_Health_Handler,
@@ -0,0 +1,243 @@
package postgres
import (
"context"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
applicationledger "gl/application/ledger"
applicationsettlement "gl/application/settlement"
domain "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
grpcadapter "gl/interface/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Opt-in real PostgreSQL proof. Each run owns a new schema and removes only it.
func TestAuthorityWithPostgres(t *testing.T) {
dsn := os.Getenv("GL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set GL_TEST_DATABASE_URL to a disposable PostgreSQL database")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := fmt.Sprintf("gl_authority_test_%d", time.Now().UnixNano())
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer func() {
cleanup, stop := context.WithTimeout(context.Background(), 5*time.Second)
defer stop()
if _, err := admin.Exec(cleanup, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
database := &Pool{pool: pool}
if err := Migrate(ctx, database); err != nil {
t.Fatal(err)
}
repository := NewJournalRepository(database)
service := applicationledger.NewService(repository, nil)
settlementRepo := NewSettlementRepository(database)
settlements := applicationsettlement.NewService(settlementRepo)
handler := grpcadapter.NewHandler(nil, service, settlements).WithSettlementAdminToken("test-token")
authorized := metadata.NewIncomingContext(ctx, metadata.Pairs("x-admin-token", "test-token"))
account := func(class domain.AccountClass, owner string) domain.AccountReference {
return domain.AccountReference{Class: class, OwnerType: "user", OwnerID: owner, AssetID: 7}
}
command := func(key string, from, to domain.AccountReference, amount string) applicationledger.AppendJournalCommand {
credit, err := domain.ParseAmount(amount)
if err != nil {
t.Fatal(err)
}
return applicationledger.AppendJournalCommand{
SourceService: "internal", SourceTransactionID: key, IdempotencyKey: key, EffectKind: "transfer", EventVersion: 1,
ActorID: "operator", OccurredAt: time.Unix(100, 0).UTC(),
Entries: []applicationledger.EntryCommand{
{LineNumber: 1, Account: from, Amount: credit.Negate().String()},
{LineNumber: 2, Account: to, Amount: credit.String()},
},
}
}
alice := account(domain.AccountClassUserAvailable, "alice")
bob := account(domain.AccountClassUserAvailable, "bob")
opening := command("opening", domain.AccountReference{Class: domain.AccountClassTreasury, AssetID: 7}, alice, "100")
if _, err := service.AppendJournal(ctx, opening); err != nil {
t.Fatal(err)
}
transfer := command("transfer-1", alice, bob, "60")
if _, err := service.PostInternalJournal(ctx, transfer); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("NORMAL admitted internal execution: %v", err)
}
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("unauthorized toggle: %v", err)
}
if _, _, err := settlements.Enqueue(ctx, "wallet", "t1", "s1", "kuknos", "signed-xdr"); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by='in-flight'"); err != nil {
t.Fatal(err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 0, "operator", "outage", false); !errors.Is(err, domain.ErrSettlementInFlight) {
t.Fatalf("in-flight toggle: %v", err)
}
if _, err := database.Exec(ctx, "UPDATE kuknos_settlements SET locked_by=''"); err != nil {
t.Fatal(err)
}
state, err := handler.SetOperatingMode(authorized, &ledgerv1.SetOperatingModeRequest{Mode: "GL_ONLY", ActorId: "operator", Reason: "outage"})
if err != nil || state.GetRevision() != 1 {
t.Fatalf("enable GL_ONLY: %+v %v", state, err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 0, "operator", "stale", true); !errors.Is(err, domain.ErrModeConflict) {
t.Fatalf("stale revision accepted: %v", err)
}
if claimed, err := settlementRepo.ClaimDue(ctx, "worker", 10, time.Now()); err != nil || len(claimed) != 0 {
t.Fatalf("settlements claimed in GL_ONLY: %v %v", claimed, err)
}
if _, _, err := settlements.Enqueue(ctx, "wallet", "t2", "s2", "kuknos", "signed-xdr"); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("settlement admitted: %v", err)
}
// Two simultaneous spends cannot consume the same opening balance.
var wg sync.WaitGroup
results := make(chan error, 2)
for _, key := range []string{"transfer-1", "transfer-2"} {
next := command(key, alice, bob, "60")
wg.Add(1)
go func(next applicationledger.AppendJournalCommand) {
defer wg.Done()
_, err := service.PostInternalJournal(ctx, next)
results <- err
}(next)
}
wg.Wait()
close(results)
successes, insufficient := 0, 0
for err := range results {
if err == nil {
successes++
} else if errors.Is(err, domain.ErrInsufficientFunds) {
insufficient++
} else {
t.Fatal(err)
}
}
if successes != 1 || insufficient != 1 {
t.Fatalf("concurrent spends: success=%d insufficient=%d", successes, insufficient)
}
var successfulKey string
if err := database.QueryRow(ctx, "SELECT idempotency_key FROM journals WHERE metadata->>'gl_execution'='GL_ONLY'").Scan(&successfulKey); err != nil {
t.Fatal(err)
}
transfer = command(successfulKey, alice, bob, "60")
repeated, err := service.PostInternalJournal(ctx, transfer)
if err != nil || !repeated.AlreadyExisted {
t.Fatalf("idempotent internal retry: %+v %v", repeated, err)
}
conflicting := command(successfulKey, alice, bob, "1")
if _, err := service.PostInternalJournal(ctx, conflicting); !errors.Is(err, domain.ErrIdempotencyConflict) {
t.Fatalf("conflicting retry: %v", err)
}
frozen := account(domain.AccountClassUserFrozen, "alice")
if _, err := service.PostInternalJournal(ctx, command("freeze", alice, frozen, "10")); err != nil {
t.Fatal(err)
}
if _, err := service.PostInternalJournal(ctx, command("release", frozen, alice, "4")); err != nil {
t.Fatal(err)
}
// The legacy ingestion API cannot execute new effects in GL_ONLY.
if _, err := service.AppendJournal(ctx, command("legacy-overspend", alice, bob, "100")); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("legacy bypass: %v", err)
}
response, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "alice"})
if err != nil || len(response.GetBalances()) != 1 {
t.Fatalf("user balance: %+v %v", response, err)
}
balance := response.Balances[0]
if balance.Available != "34" || balance.Frozen != "6" || balance.Total != "40" {
t.Fatalf("incorrect balance: %+v", balance)
}
var sum string
if err := database.QueryRow(ctx, `SELECT sum(e.amount)::text FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')`).Scan(&sum); err != nil {
t.Fatal(err)
}
total, err := domain.ParseAmount(sum)
if err != nil || total.String() != "100" {
t.Fatalf("conservation failed: %s %v", sum, err)
}
// Exercise the internal RPC mapping with a real commit.
posted, err := handler.PostInternalJournal(authorized, &ledgerv1.AppendJournalRequest{
SourceService: "internal", SourceTransactionId: "rpc", IdempotencyKey: "rpc", EffectKind: "transfer", EventVersion: 1,
ActorId: "operator", OccurredAt: timestamppb.New(time.Unix(100, 0)),
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "alice", AssetId: 7}, Amount: "-1"},
{LineNumber: 2, Account: &ledgerv1.AccountReference{AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE, OwnerType: "user", OwnerId: "bob", AssetId: 7}, Amount: "1"},
},
})
if err != nil || posted.GetJournal().GetJournalId() == "" {
t.Fatalf("internal RPC: %+v %v", posted, err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", false); !errors.Is(err, domain.ErrModeConflict) {
t.Fatalf("unreconciled recovery: %v", err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeNormal, 1, "operator", "recovery", true); err != nil {
t.Fatal(err)
}
repeated, err = service.PostInternalJournal(ctx, transfer)
if err != nil || !repeated.AlreadyExisted {
t.Fatalf("retry after mode change: %+v %v", repeated, err)
}
if _, err := service.PostInternalJournal(ctx, command("after-recovery", alice, bob, "1")); !errors.Is(err, domain.ErrModeRequired) {
t.Fatalf("internal post after recovery: %v", err)
}
unknownAsset := int64(99)
zero, err := handler.GetUserBalances(ctx, &ledgerv1.GetUserBalancesRequest{OwnerType: "user", OwnerId: "unknown", AssetId: &unknownAsset})
if err != nil || len(zero.GetBalances()) != 1 || zero.Balances[0].Total != "0" {
t.Fatalf("unknown user balance: %+v %v", zero, err)
}
// New instances read the same persisted authority state.
if persisted, err := NewJournalRepository(database).OperatingMode(ctx); err != nil || persisted.Mode != domain.ModeNormal || persisted.Revision != 2 {
t.Fatalf("persisted mode: %+v %v", persisted, err)
}
if _, err := service.AppendJournal(ctx, command("negative-baseline", alice, bob, "1000")); err != nil {
t.Fatal(err)
}
if _, err := service.SetOperatingMode(ctx, domain.ModeGLOnly, 2, "operator", "uncorrected books", false); !errors.Is(err, domain.ErrInsufficientFunds) {
t.Fatalf("negative opening balances accepted: %v", err)
}
var audits int64
if err := database.QueryRow(ctx, "SELECT count(*) FROM ledger_mode_changes").Scan(&audits); err != nil || audits != 2 {
t.Fatalf("mode audit: %d %v", audits, err)
}
if _, err := database.Exec(ctx, "DELETE FROM ledger_mode_changes"); err == nil {
t.Fatal("mode audit is mutable")
}
if _, err := database.Exec(ctx, "UPDATE journals SET actor_id='changed'"); err == nil {
t.Fatal("journals are mutable")
}
}
@@ -0,0 +1,141 @@
package postgres
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/jackc/pgx/v5/pgconn"
"gl/domain/ledger"
)
func authorityError(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "PGL01":
return ledger.ErrModeRequired
case "PGL02":
return ledger.ErrInsufficientFunds
case "40P01", "40001":
return ledger.ErrConcurrentPosting
}
}
return nil
}
func (r *JournalRepository) OperatingMode(ctx context.Context) (ledger.OperatingState, error) {
return scanOperatingState(r.database.QueryRow(ctx, operatingStateSQL))
}
func scanOperatingState(row Row) (ledger.OperatingState, error) {
var state ledger.OperatingState
var mode string
err := row.Scan(&mode, &state.Revision, &state.ActorID, &state.Reason, &state.ChangedAt)
state.Mode = ledger.OperatingMode(mode)
return state, err
}
const operatingStateSQL = "SELECT mode, revision, actor_id, reason, changed_at FROM ledger_operating_state WHERE singleton"
func (r *JournalRepository) SetOperatingMode(ctx context.Context, mode ledger.OperatingMode, revision int64, actor, reason string, reconciled bool) (ledger.OperatingState, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.OperatingState{}, err
}
defer tx.Rollback(ctx)
current, err := scanOperatingState(tx.QueryRow(ctx, operatingStateSQL+" FOR UPDATE"))
if err != nil {
return ledger.OperatingState{}, err
}
if current.Revision != revision {
return ledger.OperatingState{}, ledger.ErrModeConflict
}
if current.Mode == mode {
return current, nil
}
if mode == ledger.ModeNormal && !reconciled {
return ledger.OperatingState{}, fmt.Errorf("%w: reconciliation must be confirmed before returning to NORMAL", ledger.ErrModeConflict)
}
if mode == ledger.ModeGLOnly {
var inFlight bool
if err := tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM kuknos_settlements WHERE locked_by <> '')").Scan(&inFlight); err != nil {
return ledger.OperatingState{}, err
}
if inFlight {
return ledger.OperatingState{}, ledger.ErrSettlementInFlight
}
var negativeBalance bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (
SELECT a.id FROM ledger_accounts a JOIN journal_entries e ON e.account_id=a.id
JOIN journals j ON j.id=e.journal_id AND j.sealed_at IS NOT NULL
WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')
GROUP BY a.id HAVING sum(e.amount)<0
)`).Scan(&negativeBalance); err != nil {
return ledger.OperatingState{}, err
}
if negativeBalance {
return ledger.OperatingState{}, ledger.ErrInsufficientFunds
}
}
state, err := scanOperatingState(tx.QueryRow(ctx, `UPDATE ledger_operating_state
SET mode=$1, revision=revision+1, actor_id=$2, reason=$3, changed_at=clock_timestamp()
WHERE singleton RETURNING mode, revision, actor_id, reason, changed_at`, string(mode), actor, reason))
if err != nil {
return ledger.OperatingState{}, err
}
if _, err := tx.Exec(ctx, `INSERT INTO ledger_mode_changes
(revision, previous_mode, mode, actor_id, reason, reconciliation_confirmed, changed_at)
VALUES ($1,$2,$3,$4,$5,$6,$7)`, state.Revision, string(current.Mode), string(mode), actor, reason, reconciled, state.ChangedAt); err != nil {
return ledger.OperatingState{}, err
}
if err := tx.Commit(ctx); err != nil {
return ledger.OperatingState{}, err
}
slog.WarnContext(ctx, "ledger operating mode changed", "previous_mode", current.Mode, "mode", mode, "revision", state.Revision, "actor_id", actor, "reason", reason)
return state, nil
}
// One statement provides a consistent snapshot across available and frozen funds.
func (r *JournalRepository) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]ledger.UserAssetBalance, error) {
rows, err := r.database.Query(ctx, `SELECT a.asset_id,
COALESCE(sum(e.amount) FILTER (WHERE a.class='USER_AVAILABLE'),0)::text,
COALESCE(sum(e.amount) FILTER (WHERE a.class='USER_FROZEN'),0)::text
FROM ledger_accounts a
LEFT JOIN journal_entries e ON e.account_id=a.id
LEFT JOIN journals j ON j.id=e.journal_id AND j.sealed_at IS NOT NULL
WHERE a.owner_type=$1 AND a.owner_id=$2
AND a.class IN ('USER_AVAILABLE','USER_FROZEN')
AND ($3::bigint IS NULL OR a.asset_id=$3)
AND (e.journal_id IS NULL OR j.id IS NOT NULL)
GROUP BY a.asset_id ORDER BY a.asset_id`, ownerType, ownerID, assetID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []ledger.UserAssetBalance{}
for rows.Next() {
var balance ledger.UserAssetBalance
var available, frozen string
if err := rows.Scan(&balance.AssetID, &available, &frozen); err != nil {
return nil, err
}
balance.Available, err = ledger.ParseAmount(available)
if err != nil {
return nil, err
}
balance.Frozen, err = ledger.ParseAmount(frozen)
if err != nil {
return nil, err
}
result = append(result, balance)
}
if err := rows.Err(); err != nil {
return nil, err
}
if assetID != nil && len(result) == 0 {
result = append(result, ledger.UserAssetBalance{AssetID: *assetID})
}
return result, nil
}
+29 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"gl/domain/ledger" "gl/domain/ledger"
@@ -47,6 +48,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
defer func() { defer func() {
if err != nil { if err != nil {
_ = tx.Rollback(ctx) _ = tx.Rollback(ctx)
if classified := authorityError(err); classified != nil {
err = classified
}
} }
}() }()
@@ -75,13 +79,34 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
} }
if err != nil { if err != nil {
var postgresError *pgconn.PgError var postgresError *pgconn.PgError
if classified := authorityError(err); classified != nil {
return AppendResult{}, classified
}
if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" { if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
return AppendResult{}, ledger.ErrAlreadyReversed return AppendResult{}, ledger.ErrAlreadyReversed
} }
return AppendResult{}, fmt.Errorf("insert journal: %w", err) return AppendResult{}, fmt.Errorf("insert journal: %w", err)
} }
for _, entry := range journal.Entries { // Resolve and lock accounts in stable order across every writer.
entries := append([]ledger.Entry(nil), journal.Entries...)
sort.Slice(entries, func(i, j int) bool {
left, right := entries[i].Account, entries[j].Account
if left.Class != right.Class {
return left.Class < right.Class
}
if left.OwnerType != right.OwnerType {
return left.OwnerType < right.OwnerType
}
if left.OwnerID != right.OwnerID {
return left.OwnerID < right.OwnerID
}
if left.AssetID != right.AssetID {
return left.AssetID < right.AssetID
}
return entries[i].LineNumber < entries[j].LineNumber
})
for _, entry := range entries {
var accountID int64 var accountID int64
err = tx.QueryRow(ctx, insertAccountSQL, err = tx.QueryRow(ctx, insertAccountSQL,
entry.Account.Class, entry.Account.Class,
@@ -114,6 +139,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
} }
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil { if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
if classified := authorityError(err); classified != nil {
return AppendResult{}, classified
}
return AppendResult{}, fmt.Errorf("seal journal: %w", err) return AppendResult{}, fmt.Errorf("seal journal: %w", err)
} }
if err = tx.Commit(ctx); err != nil { if err = tx.Commit(ctx); err != nil {
@@ -0,0 +1,95 @@
CREATE TABLE ledger_operating_state (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
mode text NOT NULL CHECK (mode IN ('NORMAL','GL_ONLY')),
revision bigint NOT NULL CHECK (revision >= 0),
actor_id text NOT NULL, reason text NOT NULL,
changed_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
INSERT INTO ledger_operating_state (mode,revision,actor_id,reason) VALUES ('NORMAL',0,'system','initial mode');
CREATE TABLE ledger_mode_changes (
revision bigint PRIMARY KEY,
previous_mode text NOT NULL CHECK (previous_mode IN ('NORMAL','GL_ONLY')),
mode text NOT NULL CHECK (mode IN ('NORMAL','GL_ONLY')),
actor_id text NOT NULL CHECK (btrim(actor_id) <> ''),
reason text NOT NULL CHECK (btrim(reason) <> ''),
reconciliation_confirmed boolean NOT NULL, changed_at timestamptz NOT NULL
);
CREATE TRIGGER mode_changes_reject_mutation BEFORE UPDATE OR DELETE ON ledger_mode_changes
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER operating_state_reject_delete BEFORE DELETE ON ledger_operating_state
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
-- A shared lock is held through each posting; toggles drain writers.
CREATE FUNCTION guard_journal_authority() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_mode text;
BEGIN
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton FOR SHARE;
IF NEW.metadata->>'gl_execution'='GL_ONLY' AND current_mode<>'GL_ONLY'
AND NOT EXISTS (SELECT 1 FROM journals WHERE idempotency_key=NEW.idempotency_key) THEN
RAISE EXCEPTION 'GL_ONLY mode is required' USING ERRCODE='PGL01';
END IF;
IF current_mode='GL_ONLY' AND COALESCE(NEW.metadata->>'gl_execution','')<>'GL_ONLY'
AND NOT EXISTS (SELECT 1 FROM journals WHERE idempotency_key=NEW.idempotency_key) THEN
RAISE EXCEPTION 'new GL_ONLY effects must use internal execution' USING ERRCODE='PGL01';
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER journals_guard_authority BEFORE INSERT ON journals
FOR EACH ROW EXECUTE FUNCTION guard_journal_authority();
-- Both credits and debits lock accounts, including normal recorders.
CREATE OR REPLACE FUNCTION guard_entry_insert() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id=NEW.journal_id) THEN
RAISE EXCEPTION 'cannot append to a sealed journal' USING ERRCODE='55000';
END IF;
PERFORM id FROM ledger_accounts WHERE id=NEW.account_id
AND class IN ('USER_AVAILABLE','USER_FROZEN') FOR UPDATE;
RETURN NEW;
END; $$;
CREATE OR REPLACE FUNCTION guard_journal_seal() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_mode text;
BEGIN
IF OLD.sealed_at IS NOT NULL OR NEW.sealed_at IS NULL
OR (to_jsonb(NEW)-'sealed_at') IS DISTINCT FROM (to_jsonb(OLD)-'sealed_at') THEN
RAISE EXCEPTION 'journals are immutable except for initial sealing' USING ERRCODE='55000';
END IF;
IF (SELECT count(*) FROM journal_entries WHERE journal_id=NEW.id)<2 THEN
RAISE EXCEPTION 'journal requires at least two entries' USING ERRCODE='23514';
END IF;
IF EXISTS (SELECT asset_id FROM journal_entries WHERE journal_id=NEW.id GROUP BY asset_id HAVING sum(amount)<>0) THEN
RAISE EXCEPTION 'journal is not balanced per asset' USING ERRCODE='23514';
END IF;
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton;
IF current_mode='GL_ONLY' THEN
IF NEW.blockchain_network<>'' OR NEW.blockchain_transaction_hash<>'' OR NEW.blockchain_ledger_sequence<>''
OR EXISTS (SELECT 1 FROM journal_entries e JOIN ledger_accounts a ON a.id=e.account_id
WHERE e.journal_id=NEW.id AND a.class='EXTERNAL_BLOCKCHAIN') THEN
RAISE EXCEPTION 'external blockchain movements are unavailable in GL_ONLY' USING ERRCODE='PGL01';
END IF;
IF EXISTS (
SELECT a.id FROM ledger_accounts a JOIN journal_entries e ON e.account_id=a.id JOIN journals j ON j.id=e.journal_id
WHERE a.class IN ('USER_AVAILABLE','USER_FROZEN')
AND a.id IN (SELECT account_id FROM journal_entries WHERE journal_id=NEW.id)
AND (j.sealed_at IS NOT NULL OR j.id=NEW.id)
GROUP BY a.id HAVING sum(e.amount)<0
) THEN
RAISE EXCEPTION 'insufficient ledger funds' USING ERRCODE='PGL02';
END IF;
END IF;
RETURN NEW;
END; $$;
CREATE FUNCTION guard_settlement_authority() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_mode text;
BEGIN
SELECT mode INTO STRICT current_mode FROM ledger_operating_state WHERE singleton FOR SHARE;
IF current_mode='GL_ONLY'
AND NOT EXISTS (SELECT 1 FROM kuknos_settlements WHERE idempotency_key=NEW.idempotency_key) THEN
RAISE EXCEPTION 'blockchain settlement is disabled in GL_ONLY' USING ERRCODE='PGL01';
END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER settlements_guard_authority BEFORE INSERT ON kuknos_settlements
FOR EACH ROW EXECUTE FUNCTION guard_settlement_authority();
@@ -23,6 +23,14 @@ func NewSettlementRepository(database Database) *SettlementRepository {
return &SettlementRepository{database: database} return &SettlementRepository{database: database}
} }
// Existing normal-mode Wallet clients require SettlementReady; a GL-only
// cutover must not let them continue admitting blockchain-dependent effects.
func (r *SettlementRepository) SettlementAllowed(ctx context.Context) (bool, error) {
var mode string
err := r.database.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton").Scan(&mode)
return mode == string(ledger.ModeNormal), err
}
func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) { func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) {
var stats ledger.SettlementStats var stats ledger.SettlementStats
err := r.database.QueryRow(ctx, `SELECT err := r.database.QueryRow(ctx, `SELECT
@@ -60,6 +68,9 @@ func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement)
existing, err := r.Get(ctx, inserted, "") existing, err := r.Get(ctx, inserted, "")
return existing, true, err return existing, true, err
} }
if classified := authorityError(err); classified != nil {
return ledger.Settlement{}, false, classified
}
return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err) return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err)
} }
@@ -78,6 +89,13 @@ func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, li
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer tx.Rollback(ctx)
var mode string
if err := tx.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton FOR SHARE").Scan(&mode); err != nil {
return nil, err
}
if mode == string(ledger.ModeGLOnly) {
return nil, nil
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil { 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) return nil, fmt.Errorf("release stale settlement locks: %w", err)
} }
+75
View File
@@ -0,0 +1,75 @@
package grpcadapter
import (
"context"
"crypto/subtle"
domain "gl/domain/ledger"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func (h *Handler) requireAdmin(ctx context.Context) error {
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token")
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 {
return status.Error(codes.PermissionDenied, "invalid administrative token")
}
return nil
}
func modeMessage(state domain.OperatingState) *ledgerv1.OperatingModeResponse {
return &ledgerv1.OperatingModeResponse{Mode: string(state.Mode), Revision: state.Revision, ActorId: state.ActorID, Reason: state.Reason, ChangedAt: timestampMessage(state.ChangedAt)}
}
func (h *Handler) GetOperatingMode(ctx context.Context, _ *basev1.Empty) (*ledgerv1.OperatingModeResponse, error) {
state, err := h.ledger.OperatingMode(ctx)
if err != nil {
return nil, rpcError(err)
}
return modeMessage(state), nil
}
func (h *Handler) SetOperatingMode(ctx context.Context, request *ledgerv1.SetOperatingModeRequest) (*ledgerv1.OperatingModeResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
state, err := h.ledger.SetOperatingMode(ctx, domain.OperatingMode(request.GetMode()), request.GetExpectedRevision(), request.GetActorId(), request.GetReason(), request.GetReconciliationConfirmed())
if err != nil {
return nil, rpcError(err)
}
return modeMessage(state), nil
}
func (h *Handler) PostInternalJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
command, err := appendJournalCommand(request)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
result, err := h.ledger.PostInternalJournal(ctx, command)
if err != nil {
return nil, rpcError(err)
}
return &ledgerv1.AppendJournalResponse{Journal: journalMessage(result.Journal), AlreadyExisted: result.AlreadyExisted}, nil
}
func (h *Handler) GetUserBalances(ctx context.Context, request *ledgerv1.GetUserBalancesRequest) (*ledgerv1.GetUserBalancesResponse, error) {
if request == nil {
return nil, status.Error(codes.InvalidArgument, "request is required")
}
balances, err := h.ledger.UserBalances(ctx, request.GetOwnerType(), request.GetOwnerId(), request.AssetId)
if err != nil {
return nil, rpcError(err)
}
response := &ledgerv1.GetUserBalancesResponse{OwnerType: request.GetOwnerType(), OwnerId: request.GetOwnerId()}
for _, balance := range balances {
response.Balances = append(response.Balances, &ledgerv1.UserAssetBalance{
AssetId: balance.AssetID, Available: balance.Available.String(), Frozen: balance.Frozen.String(), Total: balance.Available.Add(balance.Frozen).String(),
})
}
return response, nil
}
+33
View File
@@ -0,0 +1,33 @@
package grpcadapter
import (
"context"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"testing"
)
func TestAuthorityCommandsRequireOneConfiguredAdministrativeToken(t *testing.T) {
for _, tc := range []struct {
name, configured string
tokens []string
}{
{"unconfigured", "", []string{"token"}},
{"missing", "token", nil},
{"wrong", "token", []string{"wrong"}},
{"duplicate", "token", []string{"token", "token"}},
} {
t.Run(tc.name, func(t *testing.T) {
handler := NewHandler(nil, nil).WithSettlementAdminToken(tc.configured)
ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{"x-admin-token": tc.tokens})
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("toggle: %v", err)
}
if _, err := handler.PostInternalJournal(ctx, &ledgerv1.AppendJournalRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("internal posting: %v", err)
}
})
}
}
+4
View File
@@ -398,6 +398,10 @@ func rpcError(err error) error {
return status.Error(codes.FailedPrecondition, err.Error()) return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, domain.ErrAlreadyReversed): case errors.Is(err, domain.ErrAlreadyReversed):
return status.Error(codes.FailedPrecondition, err.Error()) return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, domain.ErrModeConflict), errors.Is(err, domain.ErrConcurrentPosting):
return status.Error(codes.Aborted, err.Error())
case errors.Is(err, domain.ErrModeRequired), errors.Is(err, domain.ErrInsufficientFunds), errors.Is(err, domain.ErrSettlementInFlight):
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, err.Error()) return status.Error(codes.Canceled, err.Error())
case errors.Is(err, context.DeadlineExceeded): case errors.Is(err, context.DeadlineExceeded):
+6 -5
View File
@@ -2,12 +2,11 @@ package grpcadapter
import ( import (
"context" "context"
"crypto/subtle" "errors"
"gl/domain/ledger" "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1" ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
) )
@@ -17,6 +16,9 @@ func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.Enque
} }
record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr()) record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr())
if err != nil { if err != nil {
if errors.Is(err, ledger.ErrModeRequired) {
return nil, rpcError(err)
}
return nil, status.Error(codes.InvalidArgument, err.Error()) return nil, status.Error(codes.InvalidArgument, err.Error())
} }
return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil
@@ -26,9 +28,8 @@ func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySe
if h.settlement == nil { if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured") return nil, status.Error(codes.Unimplemented, "settlement is not configured")
} }
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token") if err := h.requireAdmin(ctx); err != nil {
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 { return nil, err
return nil, status.Error(codes.PermissionDenied, "invalid administrative token")
} }
record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason()) record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
if err != nil { if err != nil {