This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# General Ledger service design
|
||||
|
||||
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28.
|
||||
Status: append-only model accepted on 2026-08-14; authority and availability policy revised on 2026-08-28; standalone GL authority API implemented on 2026-10-05.
|
||||
|
||||
## Purpose and ownership
|
||||
|
||||
@@ -30,9 +30,10 @@ disabled automatically.
|
||||
5. Account, asset, source transaction, correlation, actor, occurrence time, and
|
||||
recording time are retained on every applicable record.
|
||||
6. A journal commits atomically with all its entries or not at all.
|
||||
7. A financial effect is journaled only when Wallet has committed that effect.
|
||||
7. In normal Wallet ingestion, a financial effect is journaled only when Wallet has committed that effect.
|
||||
Pending and failed lifecycle events may be retained as audit events but do
|
||||
not create monetary entries.
|
||||
not create monetary entries. For standalone GL-only execution, the GL journal
|
||||
commit itself is the financial effect; any Wallet copy is a projection.
|
||||
8. Derived balances are rebuildable solely by ordering and summing immutable
|
||||
entries. Cached balances are disposable projections, never source data.
|
||||
|
||||
@@ -115,6 +116,37 @@ does not hold a wallet database transaction open.
|
||||
|
||||
## Operating modes and failure semantics
|
||||
|
||||
GL now exposes its own persisted `NORMAL` / `GL_ONLY` operating state through
|
||||
`GetOperatingMode` and the authenticated `SetOperatingMode` RPC. This is
|
||||
distinct from Wallet's existing `KUKNOS_DISABLED` configuration, which still
|
||||
makes Wallet read-only until its callers are integrated with this API.
|
||||
|
||||
In `GL_ONLY`, `PostInternalJournal` executes balanced internal effects
|
||||
synchronously. PostgreSQL locks user accounts and checks available/frozen
|
||||
balances in the same commit; conflicting spends cannot both succeed. All
|
||||
journal append paths enforce the GL-only funds rules; ordinary ingestion rejects
|
||||
new effects in this mode, so internal execution cannot be bypassed. Blockchain
|
||||
references
|
||||
and external blockchain account movements are forbidden, new settlement
|
||||
requests are rejected, and the worker stops claiming existing settlements.
|
||||
The mode survives restarts and changes have immutable actor/reason/revision
|
||||
audits and OpenTelemetry spans. Switching drains posting transactions and
|
||||
rejects in-flight settlement claims or negative user balances. Returning to
|
||||
`NORMAL` requires explicit operator reconciliation confirmation.
|
||||
|
||||
The ledger must be complete before switching: restore/import opening balances
|
||||
and drain committed Wallet outbox effects and in-flight Wallet processing.
|
||||
GL-only mode keeps ledger reads/writes available while reporting settlement as
|
||||
paused; existing normal-mode Wallet clients therefore fail closed rather than
|
||||
continuing blockchain-dependent processing. Internal executions must not also
|
||||
be delivered through that outbox as new monetary effects. User authorization,
|
||||
asset eligibility, fees, and business counterparty mapping remain admission
|
||||
responsibilities. Actual external payment settlement cannot occur internally.
|
||||
See [the authority API](deployment/AUTHORITY_API.md) and
|
||||
[standalone operation](deployment/STANDALONE.md).
|
||||
|
||||
The broader Wallet availability policy remains:
|
||||
|
||||
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
|
||||
only after its required GL journal and Kuknos settlement evidence exist.
|
||||
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
|
||||
@@ -145,6 +177,9 @@ The first `ledger.v1.GeneralLedgerService` contract provides:
|
||||
- `AppendTransactionEvent` for non-monetary lifecycle history;
|
||||
- `GetJournal` and `ListEntries` for audit and replay inspection;
|
||||
- `GetBalance` for a rebuildable account/asset projection;
|
||||
- `GetUserBalances` for available, frozen, and total balances per user asset;
|
||||
- `GetOperatingMode` / `SetOperatingMode` for attributable operator cutover;
|
||||
- `PostInternalJournal` for atomic internal execution in `GL_ONLY`;
|
||||
- `Health` for readiness and database health.
|
||||
|
||||
Wallet depends on its own `Ledger` port. Its gRPC implementation translates
|
||||
|
||||
@@ -6,6 +6,11 @@ blockchain or provider is unavailable.
|
||||
|
||||
The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md).
|
||||
|
||||
For an independent PostgreSQL + ledger API + dashboard deployment with no
|
||||
blockchain network access, see [Standalone General Ledger](deployment/STANDALONE.md).
|
||||
The [authority API](deployment/AUTHORITY_API.md) provides an authenticated,
|
||||
persisted GL-only toggle, internal journal execution, and per-user balances.
|
||||
|
||||
Generate protobufs, test, and build with:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -15,6 +15,10 @@ type SettlementMonitor interface {
|
||||
Stats(context.Context) (ledger.SettlementStats, error)
|
||||
}
|
||||
|
||||
type SettlementAuthority interface {
|
||||
SettlementAllowed(context.Context) (bool, error)
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Serving bool
|
||||
DatabaseReady bool
|
||||
@@ -50,6 +54,10 @@ func (s *Service) Check(ctx context.Context) Status {
|
||||
if err == nil {
|
||||
status.SettlementStats = stats
|
||||
status.SettlementReady = true
|
||||
if authority, ok := s.settlement.(SettlementAuthority); ok {
|
||||
allowed, authorityErr := authority.SettlementAllowed(ctx)
|
||||
status.SettlementReady = authorityErr == nil && allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
return status
|
||||
|
||||
@@ -61,3 +61,15 @@ func TestCheckReportsSettlementQueryFailure(t *testing.T) {
|
||||
t.Fatalf("unexpected status: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type pausedSettlementStub struct{ settlementStub }
|
||||
|
||||
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
|
||||
|
||||
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
|
||||
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
|
||||
got := NewService(databaseStub{}, monitor).Check(context.Background())
|
||||
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
|
||||
t.Fatalf("unexpected GL-only readiness: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
domain "gl/domain/ledger"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
type AuthorityRepository interface {
|
||||
OperatingMode(context.Context) (domain.OperatingState, error)
|
||||
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
|
||||
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
|
||||
}
|
||||
|
||||
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return domain.OperatingState{}, domain.ErrModeRequired
|
||||
}
|
||||
return repository.OperatingMode(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
|
||||
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
|
||||
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
|
||||
}
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return domain.OperatingState{}, domain.ErrModeRequired
|
||||
}
|
||||
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
|
||||
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, "mode change failed")
|
||||
return domain.OperatingState{}, err
|
||||
}
|
||||
if state.Revision != revision {
|
||||
span.AddEvent("ledger.operating_mode_changed")
|
||||
}
|
||||
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
|
||||
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
|
||||
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
|
||||
}
|
||||
repository, ok := s.repository.(AuthorityRepository)
|
||||
if !ok {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
|
||||
}
|
||||
|
||||
// PostInternalJournal executes exact balanced entries without network settlement.
|
||||
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
|
||||
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
if strings.TrimSpace(command.ActorID) == "" {
|
||||
return AppendJournalResult{}, invalid("actor id is required for internal execution")
|
||||
}
|
||||
if command.Blockchain != (domain.BlockchainReference{}) {
|
||||
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
|
||||
}
|
||||
for _, entry := range command.Entries {
|
||||
if entry.Account.Class == domain.AccountClassExternalBlockchain {
|
||||
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
|
||||
}
|
||||
}
|
||||
command.Metadata = cloneMetadata(command.Metadata)
|
||||
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
|
||||
return s.appendJournal(ctx, command)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
domain "gl/domain/ledger"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
change func(*AppendJournalCommand)
|
||||
}{
|
||||
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
|
||||
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
|
||||
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repository := newRepositoryStub()
|
||||
command := validAppendCommand()
|
||||
command.ActorID = "operator"
|
||||
tc.change(&command)
|
||||
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
|
||||
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
|
||||
t.Fatalf("invalid internal execution reached repository: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
|
||||
repository := newRepositoryStub()
|
||||
service := NewService(repository, fixedID("test"))
|
||||
command := validAppendCommand()
|
||||
command.ActorID = "operator"
|
||||
command.Metadata = map[string]string{"correlation": "value"}
|
||||
result, err := service.PostInternalJournal(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
|
||||
t.Fatal("internal provenance missing")
|
||||
}
|
||||
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
|
||||
t.Fatal("caller metadata mutated")
|
||||
}
|
||||
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
|
||||
_, err = service.AppendJournal(context.Background(), command)
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("ordinary API accepted internal marker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorityValidatesInputs(t *testing.T) {
|
||||
service := NewService(newRepositoryStub(), nil)
|
||||
for _, owner := range []string{"", " "} {
|
||||
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid owner: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("invalid mode: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,13 @@ type AppendJournalResult struct {
|
||||
}
|
||||
|
||||
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
|
||||
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
|
||||
}
|
||||
return s.appendJournal(ctx, command)
|
||||
}
|
||||
|
||||
func (s *Service) appendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
|
||||
journalID, err := s.newID()
|
||||
if err != nil {
|
||||
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
|
||||
|
||||
@@ -3,15 +3,22 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
basev1 "gl/gen/base/v1"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"gl/infrastructure/config"
|
||||
"gl/infrastructure/postgres"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file")
|
||||
grpcProbe := flag.Bool("grpc", false, "check the serving GL API instead of only its database")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
@@ -19,6 +26,23 @@ func main() {
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *grpcProbe {
|
||||
host := cfg.GRPC.Host
|
||||
if host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
connection, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(cfg.GRPC.Port)), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer connection.Close()
|
||||
health, err := ledgerv1.NewGeneralLedgerServiceClient(connection).Health(ctx, &basev1.Empty{})
|
||||
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
database, err := postgres.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Standalone General Ledger
|
||||
|
||||
GL's journal API and explorer require only GL's own PostgreSQL database.
|
||||
Stellar, Kuknos, Wallet, and an OpenTelemetry collector are not startup or
|
||||
posting dependencies. Blockchain references on journals are optional.
|
||||
|
||||
## Run independently
|
||||
|
||||
From the GL directory, set two runtime secrets and launch:
|
||||
|
||||
```bash
|
||||
export DARANO_GL_DB_PASSWORD='<database password>'
|
||||
export DARANO_GL_ADMIN_TOKEN='<administrative retry token>'
|
||||
docker compose -f deployment/compose.standalone.yaml up --build -d --wait
|
||||
```
|
||||
|
||||
The gRPC API is on `127.0.0.1:8600`; the read-only dashboard is on
|
||||
`http://127.0.0.1:8601`. Images must be built/downloaded before an outage.
|
||||
The runtime network is internal and has no external network access.
|
||||
Settlement submission and telemetry export are explicitly disabled. The
|
||||
settlement queue remains persisted and queryable; disabling its worker does
|
||||
not confirm queued blockchain transactions.
|
||||
|
||||
The named `ledger-data` volume retains journals across container restarts and
|
||||
ordinary `docker compose down`. Do not use `down -v` on a ledger containing
|
||||
financial records.
|
||||
|
||||
This launch creates a **new, empty ledger**. Before treating it as the source
|
||||
of truth, restore the existing authoritative GL database, or import approved,
|
||||
balanced opening journals and replay all subsequent committed movements.
|
||||
Running a second empty GL does not provide failover for the first.
|
||||
|
||||
## What remains available during a blockchain outage
|
||||
|
||||
- Atomic, balanced, append-only journal posting with idempotent retries.
|
||||
- Journal lookup, history, lifecycle events, and balances rebuilt from entries.
|
||||
- Read-only exploration of the same database.
|
||||
- Durable storage of settlement requests already carrying signed XDR.
|
||||
|
||||
GL readiness reflects its database. Settlement backlog or submission failures
|
||||
do not prevent ledger reads or writes. Database loss or failed journal commits
|
||||
must prevent a caller from declaring an unrecorded financial effect successful.
|
||||
|
||||
## Financial authority versus settlement
|
||||
|
||||
The committed journal is the source of book balances. A blockchain hash is
|
||||
settlement evidence, not the journal identity. Do not modify a journal to add
|
||||
later confirmation; retain it as separate settlement/lifecycle evidence.
|
||||
|
||||
The persisted `GL_ONLY` operating mode makes GL the execution authority.
|
||||
`PostInternalJournal` commits balanced internal effects synchronously, checks
|
||||
that affected available/frozen accounts do not become negative, and serializes
|
||||
concurrent spending inside PostgreSQL. An identical retry returns the original
|
||||
journal even after the mode changes. In `GL_ONLY`, new effects must use the
|
||||
authenticated internal posting API; ordinary ingestion rejects new journals
|
||||
while still accepting identical retries. PostgreSQL enforces the funds checks
|
||||
and rejects external blockchain movements at sealing. Business admission still
|
||||
owns user authorization, asset eligibility, fees, and exact counterparty legs.
|
||||
|
||||
GL starts in `NORMAL`; mode is stored in its database and survives restarts.
|
||||
Disabling the settlement worker in TOML is separate from choosing `GL_ONLY`.
|
||||
Read the current revision, then use the authenticated `SetOperatingMode` API
|
||||
to change mode. Each change retains actor, reason, revision, and time in an
|
||||
immutable audit table. Stale revisions fail. Enabling `GL_ONLY` requires no
|
||||
in-flight settlement claims and no negative user account balances. Drain the
|
||||
Wallet outbox, pause and drain in-flight Wallet processing, and confirm the
|
||||
ledger includes every committed effect before switching.
|
||||
|
||||
The existing AdminPanel controls GL mode through its GL journals →
|
||||
GL operating mode page. Configure `GL_ADMIN_GRPC_ADDRESS` and
|
||||
`GL_ADMIN_GRPC_TOKEN` (matching GL's `DARANO_GL_ADMIN_TOKEN`), and grant
|
||||
`ledgerops.set_operating_mode` to designated operators. AdminPanel never
|
||||
updates GL tables directly; mode changes go through GL's API.
|
||||
|
||||
Wallet remains the public entry point in both modes: the future GL-only route
|
||||
must preserve the existing Wallet endpoints, response formats, and UI. In
|
||||
normal operation, admitted requests continue through GL settlement to
|
||||
Stellar/Kuknos. With GL-only routing implemented and enabled, Wallet will
|
||||
instead submit internal effects directly to GL and serve GL balances through
|
||||
those same Wallet endpoints. See [the future Wallet routing contract](AUTHORITY_API.md#future-wallet-routing-contract).
|
||||
|
||||
That Wallet routing is not implemented yet; keep `NORMAL` selected for the
|
||||
current deployment. The existing Wallet configuration toggle still selects read-only behavior.
|
||||
Wallet consumers must route internal execution to `PostInternalJournal`;
|
||||
the AdminPanel change does not replace Wallet's blockchain-dependent workflows.
|
||||
GL keeps its own readiness healthy but reports settlement as paused, so existing
|
||||
normal-mode Wallet clients fail closed until their internal routes are integrated.
|
||||
A committed GL journal is the financial
|
||||
success result; Wallet state becomes an idempotent projection of it. Do not
|
||||
also send the same effect through the normal Wallet outbox under a different
|
||||
key: that would duplicate the movement. Existing normal-mode committed Wallet
|
||||
effects continue using their durable outbox.
|
||||
|
||||
Internal transfers, freezes, releases, trades, and clearing movements are
|
||||
represented by balanced entries with no blockchain reference. An operation
|
||||
that actually sends money outside the internal books cannot become externally
|
||||
settled during an outage. External blockchain account entries and settlement
|
||||
submission are rejected in `GL_ONLY`; store any future external intent
|
||||
separately and keep its settlement pending.
|
||||
|
||||
After blockchain recovery, reconcile journals against external evidence before
|
||||
resuming normal settlement. Returning to `NORMAL` requires an operator to set
|
||||
`reconciliation_confirmed=true`; this is an explicit assertion, not an automated
|
||||
proof. While `GL_ONLY` is enabled, the worker leaves the existing settlement
|
||||
queue untouched. Mode changes reject in-flight claims so a toggle cannot race
|
||||
an already claimed submission. The existing queue requires signed XDR; it does not
|
||||
turn arbitrary journals into blockchain transactions. Sequence numbers,
|
||||
transaction expiry, and exhausted retries may require operator review and
|
||||
reconstruction. Never blindly resubmit all journals or mark them settled.
|
||||
|
||||
## Verify and preserve the ledger
|
||||
|
||||
The existing write/read load test can exercise a **disposable** standalone
|
||||
database without any blockchain endpoint:
|
||||
|
||||
```bash
|
||||
go run ./cmd/gl-loadtest -duration 5s -concurrency 1 -wallets 10
|
||||
```
|
||||
|
||||
This writes synthetic journals and must not run against an authoritative
|
||||
financial database. Restart GL and confirm the same journal IDs and balances
|
||||
remain queryable.
|
||||
|
||||
Keep PostgreSQL backups and WAL recovery independent of the blockchain
|
||||
services, and periodically verify a restore. For example, create a logical
|
||||
backup without deleting or changing ledger records:
|
||||
|
||||
```bash
|
||||
docker compose -f deployment/compose.standalone.yaml exec -T postgres \
|
||||
pg_dump -U gl -d gl_db -Fc > gl.dump
|
||||
```
|
||||
|
||||
A named volume provides persistence on one host, not disaster recovery.
|
||||
@@ -0,0 +1,65 @@
|
||||
name: darano-gl-standalone
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_DB: gl_db
|
||||
POSTGRES_USER: gl
|
||||
POSTGRES_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
|
||||
volumes:
|
||||
- ledger-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U gl -d gl_db"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
networks: [ledger]
|
||||
|
||||
gl:
|
||||
image: darano-gl:standalone
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deployment/Dockerfile
|
||||
args:
|
||||
GO_PROXY: ${GO_PROXY:-https://proxy.golang.org}
|
||||
command: ["-conf", "/app/standalone.toml"]
|
||||
environment:
|
||||
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
|
||||
DARANO_GL_ADMIN_TOKEN: ${DARANO_GL_ADMIN_TOKEN:?Set DARANO_GL_ADMIN_TOKEN}
|
||||
volumes:
|
||||
- ./gl.standalone.toml:/app/standalone.toml:ro
|
||||
ports: ["127.0.0.1:8600:8600"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "/app/healthcheck", "-conf", "/app/standalone.toml", "-grpc"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
networks: [ledger]
|
||||
|
||||
dashboard:
|
||||
image: darano-gl:standalone
|
||||
entrypoint: ["/app/dashboard"]
|
||||
command: ["-conf", "/app/standalone.toml"]
|
||||
environment:
|
||||
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
|
||||
volumes:
|
||||
- ./dashboard.standalone.toml:/app/standalone.toml:ro
|
||||
ports: ["127.0.0.1:8601:8601"]
|
||||
depends_on:
|
||||
gl:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks: [ledger]
|
||||
|
||||
volumes:
|
||||
ledger-data:
|
||||
|
||||
networks:
|
||||
ledger:
|
||||
internal: true
|
||||
@@ -0,0 +1,16 @@
|
||||
environment = "production"
|
||||
|
||||
[http]
|
||||
host = "0.0.0.0"
|
||||
port = 8601
|
||||
read-header-timeout = "5s"
|
||||
shutdown-timeout = "10s"
|
||||
|
||||
[db]
|
||||
host = "postgres"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "gl"
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
@@ -0,0 +1,18 @@
|
||||
environment = "production"
|
||||
|
||||
[grpc]
|
||||
host = "0.0.0.0"
|
||||
port = 8600
|
||||
timeout = "10s"
|
||||
|
||||
[db]
|
||||
host = "postgres"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "gl"
|
||||
|
||||
[settlement]
|
||||
enabled = false
|
||||
|
||||
[telemetry]
|
||||
enabled = false
|
||||
@@ -0,0 +1,36 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OperatingMode string
|
||||
|
||||
const (
|
||||
ModeNormal OperatingMode = "NORMAL"
|
||||
ModeGLOnly OperatingMode = "GL_ONLY"
|
||||
InternalExecutionMetadata = "gl_execution"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrModeConflict = errors.New("operating mode revision changed")
|
||||
ErrModeRequired = errors.New("operation is not permitted in the current ledger mode")
|
||||
ErrInsufficientFunds = errors.New("insufficient ledger funds")
|
||||
ErrSettlementInFlight = errors.New("settlement is in flight; drain the worker before switching")
|
||||
ErrConcurrentPosting = errors.New("concurrent posting conflicted; retry with the same idempotency key")
|
||||
)
|
||||
|
||||
type OperatingState struct {
|
||||
Mode OperatingMode
|
||||
Revision int64
|
||||
ActorID string
|
||||
Reason string
|
||||
ChangedAt time.Time
|
||||
}
|
||||
|
||||
type UserAssetBalance struct {
|
||||
AssetID int64
|
||||
Available Amount
|
||||
Frozen Amount
|
||||
}
|
||||
+523
-144
File diff suppressed because it is too large
Load Diff
+59
-43
@@ -25,8 +25,12 @@ var File_ledger_v1_srv_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_ledger_v1_srv_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xc1\x06\n" +
|
||||
"\x14GeneralLedgerService\x123\n" +
|
||||
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\x95\t\n" +
|
||||
"\x14GeneralLedgerService\x12D\n" +
|
||||
"\x10GetOperatingMode\x12\x0e.base.v1.Empty\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
|
||||
"\x10SetOperatingMode\x12\".ledger.v1.SetOperatingModeRequest\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
|
||||
"\x13PostInternalJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12X\n" +
|
||||
"\x0fGetUserBalances\x12!.ledger.v1.GetUserBalancesRequest\x1a\".ledger.v1.GetUserBalancesResponse\x123\n" +
|
||||
"\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" +
|
||||
"\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" +
|
||||
"\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" +
|
||||
@@ -44,49 +48,61 @@ const file_ledger_v1_srv_proto_rawDesc = "" +
|
||||
|
||||
var file_ledger_v1_srv_proto_goTypes = []any{
|
||||
(*v1.Empty)(nil), // 0: base.v1.Empty
|
||||
(*AppendJournalRequest)(nil), // 1: ledger.v1.AppendJournalRequest
|
||||
(*AppendTransactionEventRequest)(nil), // 2: ledger.v1.AppendTransactionEventRequest
|
||||
(*GetJournalRequest)(nil), // 3: ledger.v1.GetJournalRequest
|
||||
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest
|
||||
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest
|
||||
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest
|
||||
(*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
|
||||
(*SetOperatingModeRequest)(nil), // 1: ledger.v1.SetOperatingModeRequest
|
||||
(*AppendJournalRequest)(nil), // 2: ledger.v1.AppendJournalRequest
|
||||
(*GetUserBalancesRequest)(nil), // 3: ledger.v1.GetUserBalancesRequest
|
||||
(*AppendTransactionEventRequest)(nil), // 4: ledger.v1.AppendTransactionEventRequest
|
||||
(*GetJournalRequest)(nil), // 5: ledger.v1.GetJournalRequest
|
||||
(*ListEntriesRequest)(nil), // 6: ledger.v1.ListEntriesRequest
|
||||
(*GetBalanceRequest)(nil), // 7: ledger.v1.GetBalanceRequest
|
||||
(*ReplayJournalsRequest)(nil), // 8: ledger.v1.ReplayJournalsRequest
|
||||
(*EnqueueSettlementRequest)(nil), // 9: ledger.v1.EnqueueSettlementRequest
|
||||
(*GetSettlementRequest)(nil), // 10: ledger.v1.GetSettlementRequest
|
||||
(*RetrySettlementRequest)(nil), // 11: ledger.v1.RetrySettlementRequest
|
||||
(*OperatingModeResponse)(nil), // 12: ledger.v1.OperatingModeResponse
|
||||
(*AppendJournalResponse)(nil), // 13: ledger.v1.AppendJournalResponse
|
||||
(*GetUserBalancesResponse)(nil), // 14: ledger.v1.GetUserBalancesResponse
|
||||
(*HealthResponse)(nil), // 15: ledger.v1.HealthResponse
|
||||
(*AppendTransactionEventResponse)(nil), // 16: ledger.v1.AppendTransactionEventResponse
|
||||
(*Journal)(nil), // 17: ledger.v1.Journal
|
||||
(*ListEntriesResponse)(nil), // 18: ledger.v1.ListEntriesResponse
|
||||
(*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{
|
||||
0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
|
||||
1, // 1: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
2, // 2: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
|
||||
3, // 3: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
|
||||
4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
|
||||
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
||||
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
||||
7, // 7: ledger.v1.GeneralLedgerService.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: ledger.v1.GeneralLedgerService.GetOperatingMode:input_type -> base.v1.Empty
|
||||
1, // 1: ledger.v1.GeneralLedgerService.SetOperatingMode:input_type -> ledger.v1.SetOperatingModeRequest
|
||||
2, // 2: ledger.v1.GeneralLedgerService.PostInternalJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
3, // 3: ledger.v1.GeneralLedgerService.GetUserBalances:input_type -> ledger.v1.GetUserBalancesRequest
|
||||
0, // 4: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
|
||||
2, // 5: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
4, // 6: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
|
||||
5, // 7: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
|
||||
6, // 8: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
|
||||
7, // 9: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
||||
8, // 10: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
||||
9, // 11: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
|
||||
10, // 12: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
|
||||
11, // 13: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
|
||||
12, // 14: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
|
||||
12, // 15: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
|
||||
13, // 16: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||
14, // 17: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse
|
||||
15, // 18: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
|
||||
13, // 19: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||
16, // 20: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
|
||||
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 extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
|
||||
@@ -20,6 +20,10 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
GeneralLedgerService_GetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/GetOperatingMode"
|
||||
GeneralLedgerService_SetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/SetOperatingMode"
|
||||
GeneralLedgerService_PostInternalJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/PostInternalJournal"
|
||||
GeneralLedgerService_GetUserBalances_FullMethodName = "/ledger.v1.GeneralLedgerService/GetUserBalances"
|
||||
GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health"
|
||||
GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal"
|
||||
GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent"
|
||||
@@ -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.
|
||||
type GeneralLedgerServiceClient interface {
|
||||
GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error)
|
||||
// Operator-only; requires x-admin-token and an optimistic revision.
|
||||
SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error)
|
||||
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
|
||||
PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
|
||||
GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error)
|
||||
Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error)
|
||||
AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
|
||||
AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error)
|
||||
@@ -57,6 +67,46 @@ func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerSer
|
||||
return &generalLedgerServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(OperatingModeResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_GetOperatingMode_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(OperatingModeResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_SetOperatingMode_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AppendJournalResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_PostInternalJournal_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetUserBalancesResponse)
|
||||
err := c.cc.Invoke(ctx, GeneralLedgerService_GetUserBalances_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(HealthResponse)
|
||||
@@ -161,6 +211,12 @@ func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *Re
|
||||
// All implementations should embed UnimplementedGeneralLedgerServiceServer
|
||||
// for forward compatibility.
|
||||
type GeneralLedgerServiceServer interface {
|
||||
GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error)
|
||||
// Operator-only; requires x-admin-token and an optimistic revision.
|
||||
SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error)
|
||||
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
|
||||
PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
|
||||
GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error)
|
||||
Health(context.Context, *v1.Empty) (*HealthResponse, error)
|
||||
AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
|
||||
AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error)
|
||||
@@ -181,6 +237,18 @@ type GeneralLedgerServiceServer interface {
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedGeneralLedgerServiceServer struct{}
|
||||
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetOperatingMode not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetOperatingMode not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method PostInternalJournal not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetUserBalances not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
@@ -231,6 +299,78 @@ func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedg
|
||||
s.RegisterService(&GeneralLedgerService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_GetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(v1.Empty)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_GetOperatingMode_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, req.(*v1.Empty))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_SetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetOperatingModeRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_SetOperatingMode_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, req.(*SetOperatingModeRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_PostInternalJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AppendJournalRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_PostInternalJournal_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, req.(*AppendJournalRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_GetUserBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserBalancesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: GeneralLedgerService_GetUserBalances_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, req.(*GetUserBalancesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(v1.Empty)
|
||||
if err := dec(in); err != nil {
|
||||
@@ -418,6 +558,22 @@ var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "ledger.v1.GeneralLedgerService",
|
||||
HandlerType: (*GeneralLedgerServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "GetOperatingMode",
|
||||
Handler: _GeneralLedgerService_GetOperatingMode_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetOperatingMode",
|
||||
Handler: _GeneralLedgerService_SetOperatingMode_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "PostInternalJournal",
|
||||
Handler: _GeneralLedgerService_PostInternalJournal_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUserBalances",
|
||||
Handler: _GeneralLedgerService_GetUserBalances_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Health",
|
||||
Handler: _GeneralLedgerService_Health_Handler,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gl/domain/ledger"
|
||||
|
||||
@@ -47,6 +48,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
if classified := authorityError(err); classified != nil {
|
||||
err = classified
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -75,13 +79,34 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
}
|
||||
if err != nil {
|
||||
var postgresError *pgconn.PgError
|
||||
if classified := authorityError(err); classified != nil {
|
||||
return AppendResult{}, classified
|
||||
}
|
||||
if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
|
||||
return AppendResult{}, ledger.ErrAlreadyReversed
|
||||
}
|
||||
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range journal.Entries {
|
||||
// Resolve and lock accounts in stable order across every writer.
|
||||
entries := append([]ledger.Entry(nil), journal.Entries...)
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
left, right := entries[i].Account, entries[j].Account
|
||||
if left.Class != right.Class {
|
||||
return left.Class < right.Class
|
||||
}
|
||||
if left.OwnerType != right.OwnerType {
|
||||
return left.OwnerType < right.OwnerType
|
||||
}
|
||||
if left.OwnerID != right.OwnerID {
|
||||
return left.OwnerID < right.OwnerID
|
||||
}
|
||||
if left.AssetID != right.AssetID {
|
||||
return left.AssetID < right.AssetID
|
||||
}
|
||||
return entries[i].LineNumber < entries[j].LineNumber
|
||||
})
|
||||
for _, entry := range entries {
|
||||
var accountID int64
|
||||
err = tx.QueryRow(ctx, insertAccountSQL,
|
||||
entry.Account.Class,
|
||||
@@ -114,6 +139,9 @@ func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal)
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
|
||||
if classified := authorityError(err); classified != nil {
|
||||
return AppendResult{}, classified
|
||||
}
|
||||
return AppendResult{}, fmt.Errorf("seal journal: %w", err)
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
// Existing normal-mode Wallet clients require SettlementReady; a GL-only
|
||||
// cutover must not let them continue admitting blockchain-dependent effects.
|
||||
func (r *SettlementRepository) SettlementAllowed(ctx context.Context) (bool, error) {
|
||||
var mode string
|
||||
err := r.database.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton").Scan(&mode)
|
||||
return mode == string(ledger.ModeNormal), err
|
||||
}
|
||||
|
||||
func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) {
|
||||
var stats ledger.SettlementStats
|
||||
err := r.database.QueryRow(ctx, `SELECT
|
||||
@@ -60,6 +68,9 @@ func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement)
|
||||
existing, err := r.Get(ctx, inserted, "")
|
||||
return existing, true, err
|
||||
}
|
||||
if classified := authorityError(err); classified != nil {
|
||||
return ledger.Settlement{}, false, classified
|
||||
}
|
||||
return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err)
|
||||
}
|
||||
|
||||
@@ -78,6 +89,13 @@ func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, li
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var mode string
|
||||
if err := tx.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton FOR SHARE").Scan(&mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mode == string(ledger.ModeGLOnly) {
|
||||
return nil, nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil {
|
||||
return nil, fmt.Errorf("release stale settlement locks: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
domain "gl/domain/ledger"
|
||||
basev1 "gl/gen/base/v1"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func (h *Handler) requireAdmin(ctx context.Context) error {
|
||||
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token")
|
||||
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 {
|
||||
return status.Error(codes.PermissionDenied, "invalid administrative token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func modeMessage(state domain.OperatingState) *ledgerv1.OperatingModeResponse {
|
||||
return &ledgerv1.OperatingModeResponse{Mode: string(state.Mode), Revision: state.Revision, ActorId: state.ActorID, Reason: state.Reason, ChangedAt: timestampMessage(state.ChangedAt)}
|
||||
}
|
||||
|
||||
func (h *Handler) GetOperatingMode(ctx context.Context, _ *basev1.Empty) (*ledgerv1.OperatingModeResponse, error) {
|
||||
state, err := h.ledger.OperatingMode(ctx)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return modeMessage(state), nil
|
||||
}
|
||||
|
||||
func (h *Handler) SetOperatingMode(ctx context.Context, request *ledgerv1.SetOperatingModeRequest) (*ledgerv1.OperatingModeResponse, error) {
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state, err := h.ledger.SetOperatingMode(ctx, domain.OperatingMode(request.GetMode()), request.GetExpectedRevision(), request.GetActorId(), request.GetReason(), request.GetReconciliationConfirmed())
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return modeMessage(state), nil
|
||||
}
|
||||
|
||||
func (h *Handler) PostInternalJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) {
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command, err := appendJournalCommand(request)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
result, err := h.ledger.PostInternalJournal(ctx, command)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return &ledgerv1.AppendJournalResponse{Journal: journalMessage(result.Journal), AlreadyExisted: result.AlreadyExisted}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetUserBalances(ctx context.Context, request *ledgerv1.GetUserBalancesRequest) (*ledgerv1.GetUserBalancesResponse, error) {
|
||||
if request == nil {
|
||||
return nil, status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
balances, err := h.ledger.UserBalances(ctx, request.GetOwnerType(), request.GetOwnerId(), request.AssetId)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
response := &ledgerv1.GetUserBalancesResponse{OwnerType: request.GetOwnerType(), OwnerId: request.GetOwnerId()}
|
||||
for _, balance := range balances {
|
||||
response.Balances = append(response.Balances, &ledgerv1.UserAssetBalance{
|
||||
AssetId: balance.AssetID, Available: balance.Available.String(), Frozen: balance.Frozen.String(), Total: balance.Available.Add(balance.Frozen).String(),
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorityCommandsRequireOneConfiguredAdministrativeToken(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, configured string
|
||||
tokens []string
|
||||
}{
|
||||
{"unconfigured", "", []string{"token"}},
|
||||
{"missing", "token", nil},
|
||||
{"wrong", "token", []string{"wrong"}},
|
||||
{"duplicate", "token", []string{"token", "token"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler := NewHandler(nil, nil).WithSettlementAdminToken(tc.configured)
|
||||
ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{"x-admin-token": tc.tokens})
|
||||
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("toggle: %v", err)
|
||||
}
|
||||
if _, err := handler.PostInternalJournal(ctx, &ledgerv1.AppendJournalRequest{}); status.Code(err) != codes.PermissionDenied {
|
||||
t.Fatalf("internal posting: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,10 @@ func rpcError(err error) error {
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errors.Is(err, domain.ErrAlreadyReversed):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errors.Is(err, domain.ErrModeConflict), errors.Is(err, domain.ErrConcurrentPosting):
|
||||
return status.Error(codes.Aborted, err.Error())
|
||||
case errors.Is(err, domain.ErrModeRequired), errors.Is(err, domain.ErrInsufficientFunds), errors.Is(err, domain.ErrSettlementInFlight):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
case errors.Is(err, context.Canceled):
|
||||
return status.Error(codes.Canceled, err.Error())
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
|
||||
@@ -2,12 +2,11 @@ package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
|
||||
"gl/domain/ledger"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"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())
|
||||
if err != nil {
|
||||
if errors.Is(err, ledger.ErrModeRequired) {
|
||||
return nil, rpcError(err)
|
||||
}
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil
|
||||
@@ -26,9 +28,8 @@ func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySe
|
||||
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")
|
||||
if err := h.requireAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user