diff --git a/DESIGN.md b/DESIGN.md index 69e399c..81d5360 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/README.md b/README.md index 61e7b24..3df9236 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/application/health/service.go b/application/health/service.go index ff710b8..56f5067 100644 --- a/application/health/service.go +++ b/application/health/service.go @@ -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 diff --git a/application/health/service_test.go b/application/health/service_test.go index ff0d3e1..2d8b0c5 100644 --- a/application/health/service_test.go +++ b/application/health/service_test.go @@ -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) + } +} diff --git a/application/ledger/authority.go b/application/ledger/authority.go new file mode 100644 index 0000000..156dcda --- /dev/null +++ b/application/ledger/authority.go @@ -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) +} diff --git a/application/ledger/authority_test.go b/application/ledger/authority_test.go new file mode 100644 index 0000000..5340a0b --- /dev/null +++ b/application/ledger/authority_test.go @@ -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) + } +} diff --git a/application/ledger/service.go b/application/ledger/service.go index 22fd19e..30e3cec 100644 --- a/application/ledger/service.go +++ b/application/ledger/service.go @@ -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) diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go index ca703eb..92257b5 100644 --- a/cmd/healthcheck/main.go +++ b/cmd/healthcheck/main.go @@ -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) diff --git a/deployment/AUTHORITY_API.md b/deployment/AUTHORITY_API.md new file mode 100644 index 0000000..fdb8de5 --- /dev/null +++ b/deployment/AUTHORITY_API.md @@ -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='' \ + 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. diff --git a/deployment/STANDALONE.md b/deployment/STANDALONE.md new file mode 100644 index 0000000..01f4b72 --- /dev/null +++ b/deployment/STANDALONE.md @@ -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='' +export DARANO_GL_ADMIN_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. diff --git a/deployment/compose.standalone.yaml b/deployment/compose.standalone.yaml new file mode 100644 index 0000000..174d3ce --- /dev/null +++ b/deployment/compose.standalone.yaml @@ -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 diff --git a/deployment/dashboard.standalone.toml b/deployment/dashboard.standalone.toml new file mode 100644 index 0000000..9c28cdf --- /dev/null +++ b/deployment/dashboard.standalone.toml @@ -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 diff --git a/deployment/gl.standalone.toml b/deployment/gl.standalone.toml new file mode 100644 index 0000000..cb39524 --- /dev/null +++ b/deployment/gl.standalone.toml @@ -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 diff --git a/domain/ledger/authority.go b/domain/ledger/authority.go new file mode 100644 index 0000000..1c24020 --- /dev/null +++ b/domain/ledger/authority.go @@ -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 +} diff --git a/gen/ledger/v1/msg.pb.go b/gen/ledger/v1/msg.pb.go index 0afadbc..f3066dc 100644 --- a/gen/ledger/v1/msg.pb.go +++ b/gen/ledger/v1/msg.pb.go @@ -147,6 +147,348 @@ func (TransactionState) EnumDescriptor() ([]byte, []int) { return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} } +type OperatingModeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // NORMAL or GL_ONLY. No automatic outage failover. + Mode string `protobuf:"bytes,1,opt,name=mode,proto3" json:"mode,omitempty"` + Revision int64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` + ActorId string `protobuf:"bytes,3,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + ChangedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=changed_at,json=changedAt,proto3" json:"changed_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OperatingModeResponse) Reset() { + *x = OperatingModeResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OperatingModeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperatingModeResponse) ProtoMessage() {} + +func (x *OperatingModeResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperatingModeResponse.ProtoReflect.Descriptor instead. +func (*OperatingModeResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{0} +} + +func (x *OperatingModeResponse) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *OperatingModeResponse) GetRevision() int64 { + if x != nil { + return x.Revision + } + return 0 +} + +func (x *OperatingModeResponse) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *OperatingModeResponse) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *OperatingModeResponse) GetChangedAt() *timestamppb.Timestamp { + if x != nil { + return x.ChangedAt + } + return nil +} + +type SetOperatingModeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Mode string `protobuf:"bytes,1,opt,name=mode,proto3" json:"mode,omitempty"` + ExpectedRevision int64 `protobuf:"varint,2,opt,name=expected_revision,json=expectedRevision,proto3" json:"expected_revision,omitempty"` + ActorId string `protobuf:"bytes,3,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Reason string `protobuf:"bytes,4,opt,name=reason,proto3" json:"reason,omitempty"` + // Required when returning from GL_ONLY to NORMAL. + ReconciliationConfirmed bool `protobuf:"varint,5,opt,name=reconciliation_confirmed,json=reconciliationConfirmed,proto3" json:"reconciliation_confirmed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetOperatingModeRequest) Reset() { + *x = SetOperatingModeRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetOperatingModeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetOperatingModeRequest) ProtoMessage() {} + +func (x *SetOperatingModeRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetOperatingModeRequest.ProtoReflect.Descriptor instead. +func (*SetOperatingModeRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} +} + +func (x *SetOperatingModeRequest) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *SetOperatingModeRequest) GetExpectedRevision() int64 { + if x != nil { + return x.ExpectedRevision + } + return 0 +} + +func (x *SetOperatingModeRequest) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *SetOperatingModeRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *SetOperatingModeRequest) GetReconciliationConfirmed() bool { + if x != nil { + return x.ReconciliationConfirmed + } + return false +} + +type GetUserBalancesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + OwnerType string `protobuf:"bytes,1,opt,name=owner_type,json=ownerType,proto3" json:"owner_type,omitempty"` + OwnerId string `protobuf:"bytes,2,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + AssetId *int64 `protobuf:"varint,3,opt,name=asset_id,json=assetId,proto3,oneof" json:"asset_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUserBalancesRequest) Reset() { + *x = GetUserBalancesRequest{} + mi := &file_ledger_v1_msg_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUserBalancesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUserBalancesRequest) ProtoMessage() {} + +func (x *GetUserBalancesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUserBalancesRequest.ProtoReflect.Descriptor instead. +func (*GetUserBalancesRequest) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{2} +} + +func (x *GetUserBalancesRequest) GetOwnerType() string { + if x != nil { + return x.OwnerType + } + return "" +} + +func (x *GetUserBalancesRequest) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *GetUserBalancesRequest) GetAssetId() int64 { + if x != nil && x.AssetId != nil { + return *x.AssetId + } + return 0 +} + +type UserAssetBalance struct { + state protoimpl.MessageState `protogen:"open.v1"` + AssetId int64 `protobuf:"varint,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + Available string `protobuf:"bytes,2,opt,name=available,proto3" json:"available,omitempty"` + Frozen string `protobuf:"bytes,3,opt,name=frozen,proto3" json:"frozen,omitempty"` + Total string `protobuf:"bytes,4,opt,name=total,proto3" json:"total,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserAssetBalance) Reset() { + *x = UserAssetBalance{} + mi := &file_ledger_v1_msg_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserAssetBalance) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserAssetBalance) ProtoMessage() {} + +func (x *UserAssetBalance) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserAssetBalance.ProtoReflect.Descriptor instead. +func (*UserAssetBalance) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{3} +} + +func (x *UserAssetBalance) GetAssetId() int64 { + if x != nil { + return x.AssetId + } + return 0 +} + +func (x *UserAssetBalance) GetAvailable() string { + if x != nil { + return x.Available + } + return "" +} + +func (x *UserAssetBalance) GetFrozen() string { + if x != nil { + return x.Frozen + } + return "" +} + +func (x *UserAssetBalance) GetTotal() string { + if x != nil { + return x.Total + } + return "" +} + +type GetUserBalancesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + OwnerType string `protobuf:"bytes,1,opt,name=owner_type,json=ownerType,proto3" json:"owner_type,omitempty"` + OwnerId string `protobuf:"bytes,2,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + Balances []*UserAssetBalance `protobuf:"bytes,3,rep,name=balances,proto3" json:"balances,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUserBalancesResponse) Reset() { + *x = GetUserBalancesResponse{} + mi := &file_ledger_v1_msg_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUserBalancesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUserBalancesResponse) ProtoMessage() {} + +func (x *GetUserBalancesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ledger_v1_msg_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetUserBalancesResponse.ProtoReflect.Descriptor instead. +func (*GetUserBalancesResponse) Descriptor() ([]byte, []int) { + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{4} +} + +func (x *GetUserBalancesResponse) GetOwnerType() string { + if x != nil { + return x.OwnerType + } + return "" +} + +func (x *GetUserBalancesResponse) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *GetUserBalancesResponse) GetBalances() []*UserAssetBalance { + if x != nil { + return x.Balances + } + return nil +} + type AccountReference struct { state protoimpl.MessageState `protogen:"open.v1"` AccountClass AccountClass `protobuf:"varint,1,opt,name=account_class,json=accountClass,proto3,enum=ledger.v1.AccountClass" json:"account_class,omitempty"` @@ -159,7 +501,7 @@ type AccountReference struct { func (x *AccountReference) Reset() { *x = AccountReference{} - mi := &file_ledger_v1_msg_proto_msgTypes[0] + mi := &file_ledger_v1_msg_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -171,7 +513,7 @@ func (x *AccountReference) String() string { func (*AccountReference) ProtoMessage() {} func (x *AccountReference) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[0] + mi := &file_ledger_v1_msg_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -184,7 +526,7 @@ func (x *AccountReference) ProtoReflect() protoreflect.Message { // Deprecated: Use AccountReference.ProtoReflect.Descriptor instead. func (*AccountReference) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{0} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{5} } func (x *AccountReference) GetAccountClass() AccountClass { @@ -228,7 +570,7 @@ type JournalEntry struct { func (x *JournalEntry) Reset() { *x = JournalEntry{} - mi := &file_ledger_v1_msg_proto_msgTypes[1] + mi := &file_ledger_v1_msg_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -240,7 +582,7 @@ func (x *JournalEntry) String() string { func (*JournalEntry) ProtoMessage() {} func (x *JournalEntry) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[1] + mi := &file_ledger_v1_msg_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -253,7 +595,7 @@ func (x *JournalEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use JournalEntry.ProtoReflect.Descriptor instead. func (*JournalEntry) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{1} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{6} } func (x *JournalEntry) GetLineNumber() uint32 { @@ -295,7 +637,7 @@ type BlockchainReference struct { func (x *BlockchainReference) Reset() { *x = BlockchainReference{} - mi := &file_ledger_v1_msg_proto_msgTypes[2] + mi := &file_ledger_v1_msg_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -307,7 +649,7 @@ func (x *BlockchainReference) String() string { func (*BlockchainReference) ProtoMessage() {} func (x *BlockchainReference) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[2] + mi := &file_ledger_v1_msg_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -320,7 +662,7 @@ func (x *BlockchainReference) ProtoReflect() protoreflect.Message { // Deprecated: Use BlockchainReference.ProtoReflect.Descriptor instead. func (*BlockchainReference) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{2} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{7} } func (x *BlockchainReference) GetNetwork() string { @@ -368,7 +710,7 @@ type Journal struct { func (x *Journal) Reset() { *x = Journal{} - mi := &file_ledger_v1_msg_proto_msgTypes[3] + mi := &file_ledger_v1_msg_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -380,7 +722,7 @@ func (x *Journal) String() string { func (*Journal) ProtoMessage() {} func (x *Journal) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[3] + mi := &file_ledger_v1_msg_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -393,7 +735,7 @@ func (x *Journal) ProtoReflect() protoreflect.Message { // Deprecated: Use Journal.ProtoReflect.Descriptor instead. func (*Journal) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{3} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{8} } func (x *Journal) GetJournalId() string { @@ -529,7 +871,7 @@ type AppendJournalRequest struct { func (x *AppendJournalRequest) Reset() { *x = AppendJournalRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[4] + mi := &file_ledger_v1_msg_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -541,7 +883,7 @@ func (x *AppendJournalRequest) String() string { func (*AppendJournalRequest) ProtoMessage() {} func (x *AppendJournalRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[4] + mi := &file_ledger_v1_msg_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -554,7 +896,7 @@ func (x *AppendJournalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AppendJournalRequest.ProtoReflect.Descriptor instead. func (*AppendJournalRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{4} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{9} } func (x *AppendJournalRequest) GetSourceService() string { @@ -658,7 +1000,7 @@ type AppendJournalResponse struct { func (x *AppendJournalResponse) Reset() { *x = AppendJournalResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[5] + mi := &file_ledger_v1_msg_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -670,7 +1012,7 @@ func (x *AppendJournalResponse) String() string { func (*AppendJournalResponse) ProtoMessage() {} func (x *AppendJournalResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[5] + mi := &file_ledger_v1_msg_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -683,7 +1025,7 @@ func (x *AppendJournalResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AppendJournalResponse.ProtoReflect.Descriptor instead. func (*AppendJournalResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{5} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{10} } func (x *AppendJournalResponse) GetJournal() *Journal { @@ -721,7 +1063,7 @@ type AppendTransactionEventRequest struct { func (x *AppendTransactionEventRequest) Reset() { *x = AppendTransactionEventRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[6] + mi := &file_ledger_v1_msg_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -733,7 +1075,7 @@ func (x *AppendTransactionEventRequest) String() string { func (*AppendTransactionEventRequest) ProtoMessage() {} func (x *AppendTransactionEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[6] + mi := &file_ledger_v1_msg_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -746,7 +1088,7 @@ func (x *AppendTransactionEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use AppendTransactionEventRequest.ProtoReflect.Descriptor instead. func (*AppendTransactionEventRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{6} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{11} } func (x *AppendTransactionEventRequest) GetSourceService() string { @@ -852,7 +1194,7 @@ type TransactionEvent struct { func (x *TransactionEvent) Reset() { *x = TransactionEvent{} - mi := &file_ledger_v1_msg_proto_msgTypes[7] + mi := &file_ledger_v1_msg_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -864,7 +1206,7 @@ func (x *TransactionEvent) String() string { func (*TransactionEvent) ProtoMessage() {} func (x *TransactionEvent) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[7] + mi := &file_ledger_v1_msg_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -877,7 +1219,7 @@ func (x *TransactionEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TransactionEvent.ProtoReflect.Descriptor instead. func (*TransactionEvent) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{7} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{12} } func (x *TransactionEvent) GetEventId() string { @@ -918,7 +1260,7 @@ type AppendTransactionEventResponse struct { func (x *AppendTransactionEventResponse) Reset() { *x = AppendTransactionEventResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[8] + mi := &file_ledger_v1_msg_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -930,7 +1272,7 @@ func (x *AppendTransactionEventResponse) String() string { func (*AppendTransactionEventResponse) ProtoMessage() {} func (x *AppendTransactionEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[8] + mi := &file_ledger_v1_msg_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -943,7 +1285,7 @@ func (x *AppendTransactionEventResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use AppendTransactionEventResponse.ProtoReflect.Descriptor instead. func (*AppendTransactionEventResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{8} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{13} } func (x *AppendTransactionEventResponse) GetEvent() *TransactionEvent { @@ -973,7 +1315,7 @@ type GetJournalRequest struct { func (x *GetJournalRequest) Reset() { *x = GetJournalRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[9] + mi := &file_ledger_v1_msg_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -985,7 +1327,7 @@ func (x *GetJournalRequest) String() string { func (*GetJournalRequest) ProtoMessage() {} func (x *GetJournalRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[9] + mi := &file_ledger_v1_msg_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -998,7 +1340,7 @@ func (x *GetJournalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetJournalRequest.ProtoReflect.Descriptor instead. func (*GetJournalRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{9} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{14} } func (x *GetJournalRequest) GetLookup() isGetJournalRequest_Lookup { @@ -1056,7 +1398,7 @@ type ListEntriesRequest struct { func (x *ListEntriesRequest) Reset() { *x = ListEntriesRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[10] + mi := &file_ledger_v1_msg_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1068,7 +1410,7 @@ func (x *ListEntriesRequest) String() string { func (*ListEntriesRequest) ProtoMessage() {} func (x *ListEntriesRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[10] + mi := &file_ledger_v1_msg_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1081,7 +1423,7 @@ func (x *ListEntriesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListEntriesRequest.ProtoReflect.Descriptor instead. func (*ListEntriesRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{10} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{15} } func (x *ListEntriesRequest) GetAccount() *AccountReference { @@ -1136,7 +1478,7 @@ type ListEntriesResponse struct { func (x *ListEntriesResponse) Reset() { *x = ListEntriesResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[11] + mi := &file_ledger_v1_msg_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1148,7 +1490,7 @@ func (x *ListEntriesResponse) String() string { func (*ListEntriesResponse) ProtoMessage() {} func (x *ListEntriesResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[11] + mi := &file_ledger_v1_msg_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1161,7 +1503,7 @@ func (x *ListEntriesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListEntriesResponse.ProtoReflect.Descriptor instead. func (*ListEntriesResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{11} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{16} } func (x *ListEntriesResponse) GetJournals() []*Journal { @@ -1188,7 +1530,7 @@ type GetBalanceRequest struct { func (x *GetBalanceRequest) Reset() { *x = GetBalanceRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[12] + mi := &file_ledger_v1_msg_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1200,7 +1542,7 @@ func (x *GetBalanceRequest) String() string { func (*GetBalanceRequest) ProtoMessage() {} func (x *GetBalanceRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[12] + mi := &file_ledger_v1_msg_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1213,7 +1555,7 @@ func (x *GetBalanceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBalanceRequest.ProtoReflect.Descriptor instead. func (*GetBalanceRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{12} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{17} } func (x *GetBalanceRequest) GetAccount() *AccountReference { @@ -1242,7 +1584,7 @@ type GetBalanceResponse struct { func (x *GetBalanceResponse) Reset() { *x = GetBalanceResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[13] + mi := &file_ledger_v1_msg_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1254,7 +1596,7 @@ func (x *GetBalanceResponse) String() string { func (*GetBalanceResponse) ProtoMessage() {} func (x *GetBalanceResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[13] + mi := &file_ledger_v1_msg_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1267,7 +1609,7 @@ func (x *GetBalanceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetBalanceResponse.ProtoReflect.Descriptor instead. func (*GetBalanceResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{13} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{18} } func (x *GetBalanceResponse) GetAccount() *AccountReference { @@ -1301,7 +1643,7 @@ type ReplayJournalsRequest struct { func (x *ReplayJournalsRequest) Reset() { *x = ReplayJournalsRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[14] + mi := &file_ledger_v1_msg_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1313,7 +1655,7 @@ func (x *ReplayJournalsRequest) String() string { func (*ReplayJournalsRequest) ProtoMessage() {} func (x *ReplayJournalsRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[14] + mi := &file_ledger_v1_msg_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1326,7 +1668,7 @@ func (x *ReplayJournalsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplayJournalsRequest.ProtoReflect.Descriptor instead. func (*ReplayJournalsRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{14} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{19} } func (x *ReplayJournalsRequest) GetJournals() []*AppendJournalRequest { @@ -1347,7 +1689,7 @@ type ReplayJournalResult struct { func (x *ReplayJournalResult) Reset() { *x = ReplayJournalResult{} - mi := &file_ledger_v1_msg_proto_msgTypes[15] + mi := &file_ledger_v1_msg_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1359,7 +1701,7 @@ func (x *ReplayJournalResult) String() string { func (*ReplayJournalResult) ProtoMessage() {} func (x *ReplayJournalResult) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[15] + mi := &file_ledger_v1_msg_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1372,7 +1714,7 @@ func (x *ReplayJournalResult) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplayJournalResult.ProtoReflect.Descriptor instead. func (*ReplayJournalResult) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{15} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{20} } func (x *ReplayJournalResult) GetIdempotencyKey() string { @@ -1405,7 +1747,7 @@ type ReplayJournalsResponse struct { func (x *ReplayJournalsResponse) Reset() { *x = ReplayJournalsResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[16] + mi := &file_ledger_v1_msg_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1417,7 +1759,7 @@ func (x *ReplayJournalsResponse) String() string { func (*ReplayJournalsResponse) ProtoMessage() {} func (x *ReplayJournalsResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[16] + mi := &file_ledger_v1_msg_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1430,7 +1772,7 @@ func (x *ReplayJournalsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReplayJournalsResponse.ProtoReflect.Descriptor instead. func (*ReplayJournalsResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{16} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{21} } func (x *ReplayJournalsResponse) GetResults() []*ReplayJournalResult { @@ -1456,7 +1798,7 @@ type HealthResponse struct { func (x *HealthResponse) Reset() { *x = HealthResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[17] + mi := &file_ledger_v1_msg_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1468,7 +1810,7 @@ func (x *HealthResponse) String() string { func (*HealthResponse) ProtoMessage() {} func (x *HealthResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[17] + mi := &file_ledger_v1_msg_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1481,7 +1823,7 @@ func (x *HealthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HealthResponse.ProtoReflect.Descriptor instead. func (*HealthResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{17} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{22} } func (x *HealthResponse) GetServing() bool { @@ -1554,7 +1896,7 @@ type EnqueueSettlementRequest struct { func (x *EnqueueSettlementRequest) Reset() { *x = EnqueueSettlementRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[18] + mi := &file_ledger_v1_msg_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1566,7 +1908,7 @@ func (x *EnqueueSettlementRequest) String() string { func (*EnqueueSettlementRequest) ProtoMessage() {} func (x *EnqueueSettlementRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[18] + mi := &file_ledger_v1_msg_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1579,7 +1921,7 @@ func (x *EnqueueSettlementRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use EnqueueSettlementRequest.ProtoReflect.Descriptor instead. func (*EnqueueSettlementRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{18} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{23} } func (x *EnqueueSettlementRequest) GetSourceService() string { @@ -1628,7 +1970,7 @@ type EnqueueSettlementResponse struct { func (x *EnqueueSettlementResponse) Reset() { *x = EnqueueSettlementResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[19] + mi := &file_ledger_v1_msg_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1640,7 +1982,7 @@ func (x *EnqueueSettlementResponse) String() string { func (*EnqueueSettlementResponse) ProtoMessage() {} func (x *EnqueueSettlementResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[19] + mi := &file_ledger_v1_msg_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1653,7 +1995,7 @@ func (x *EnqueueSettlementResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use EnqueueSettlementResponse.ProtoReflect.Descriptor instead. func (*EnqueueSettlementResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{19} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{24} } func (x *EnqueueSettlementResponse) GetSettlementId() string { @@ -1690,7 +2032,7 @@ type GetSettlementRequest struct { func (x *GetSettlementRequest) Reset() { *x = GetSettlementRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[20] + mi := &file_ledger_v1_msg_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1702,7 +2044,7 @@ func (x *GetSettlementRequest) String() string { func (*GetSettlementRequest) ProtoMessage() {} func (x *GetSettlementRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[20] + mi := &file_ledger_v1_msg_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1715,7 +2057,7 @@ func (x *GetSettlementRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetSettlementRequest.ProtoReflect.Descriptor instead. func (*GetSettlementRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{20} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{25} } func (x *GetSettlementRequest) GetLookup() isGetSettlementRequest_Lookup { @@ -1776,7 +2118,7 @@ type Settlement struct { func (x *Settlement) Reset() { *x = Settlement{} - mi := &file_ledger_v1_msg_proto_msgTypes[21] + mi := &file_ledger_v1_msg_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1788,7 +2130,7 @@ func (x *Settlement) String() string { func (*Settlement) ProtoMessage() {} func (x *Settlement) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[21] + mi := &file_ledger_v1_msg_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1801,7 +2143,7 @@ func (x *Settlement) ProtoReflect() protoreflect.Message { // Deprecated: Use Settlement.ProtoReflect.Descriptor instead. func (*Settlement) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{21} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{26} } func (x *Settlement) GetSettlementId() string { @@ -1878,7 +2220,7 @@ type RetrySettlementRequest struct { func (x *RetrySettlementRequest) Reset() { *x = RetrySettlementRequest{} - mi := &file_ledger_v1_msg_proto_msgTypes[22] + mi := &file_ledger_v1_msg_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1890,7 +2232,7 @@ func (x *RetrySettlementRequest) String() string { func (*RetrySettlementRequest) ProtoMessage() {} func (x *RetrySettlementRequest) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[22] + mi := &file_ledger_v1_msg_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1903,7 +2245,7 @@ func (x *RetrySettlementRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RetrySettlementRequest.ProtoReflect.Descriptor instead. func (*RetrySettlementRequest) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{22} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{27} } func (x *RetrySettlementRequest) GetSettlementId() string { @@ -1936,7 +2278,7 @@ type RetrySettlementResponse struct { func (x *RetrySettlementResponse) Reset() { *x = RetrySettlementResponse{} - mi := &file_ledger_v1_msg_proto_msgTypes[23] + mi := &file_ledger_v1_msg_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1948,7 +2290,7 @@ func (x *RetrySettlementResponse) String() string { func (*RetrySettlementResponse) ProtoMessage() {} func (x *RetrySettlementResponse) ProtoReflect() protoreflect.Message { - mi := &file_ledger_v1_msg_proto_msgTypes[23] + mi := &file_ledger_v1_msg_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1961,7 +2303,7 @@ func (x *RetrySettlementResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RetrySettlementResponse.ProtoReflect.Descriptor instead. func (*RetrySettlementResponse) Descriptor() ([]byte, []int) { - return file_ledger_v1_msg_proto_rawDescGZIP(), []int{23} + return file_ledger_v1_msg_proto_rawDescGZIP(), []int{28} } func (x *RetrySettlementResponse) GetSettlement() *Settlement { @@ -1975,7 +2317,36 @@ var File_ledger_v1_msg_proto protoreflect.FileDescriptor const file_ledger_v1_msg_proto_rawDesc = "" + "\n" + - "\x13ledger/v1/msg.proto\x12\tledger.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa5\x01\n" + + "\x13ledger/v1/msg.proto\x12\tledger.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xb5\x01\n" + + "\x15OperatingModeResponse\x12\x12\n" + + "\x04mode\x18\x01 \x01(\tR\x04mode\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x03R\brevision\x12\x19\n" + + "\bactor_id\x18\x03 \x01(\tR\aactorId\x12\x16\n" + + "\x06reason\x18\x04 \x01(\tR\x06reason\x129\n" + + "\n" + + "changed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tchangedAt\"\xc8\x01\n" + + "\x17SetOperatingModeRequest\x12\x12\n" + + "\x04mode\x18\x01 \x01(\tR\x04mode\x12+\n" + + "\x11expected_revision\x18\x02 \x01(\x03R\x10expectedRevision\x12\x19\n" + + "\bactor_id\x18\x03 \x01(\tR\aactorId\x12\x16\n" + + "\x06reason\x18\x04 \x01(\tR\x06reason\x129\n" + + "\x18reconciliation_confirmed\x18\x05 \x01(\bR\x17reconciliationConfirmed\"\x7f\n" + + "\x16GetUserBalancesRequest\x12\x1d\n" + + "\n" + + "owner_type\x18\x01 \x01(\tR\townerType\x12\x19\n" + + "\bowner_id\x18\x02 \x01(\tR\aownerId\x12\x1e\n" + + "\basset_id\x18\x03 \x01(\x03H\x00R\aassetId\x88\x01\x01B\v\n" + + "\t_asset_id\"y\n" + + "\x10UserAssetBalance\x12\x19\n" + + "\basset_id\x18\x01 \x01(\x03R\aassetId\x12\x1c\n" + + "\tavailable\x18\x02 \x01(\tR\tavailable\x12\x16\n" + + "\x06frozen\x18\x03 \x01(\tR\x06frozen\x12\x14\n" + + "\x05total\x18\x04 \x01(\tR\x05total\"\x8c\x01\n" + + "\x17GetUserBalancesResponse\x12\x1d\n" + + "\n" + + "owner_type\x18\x01 \x01(\tR\townerType\x12\x19\n" + + "\bowner_id\x18\x02 \x01(\tR\aownerId\x127\n" + + "\bbalances\x18\x03 \x03(\v2\x1b.ledger.v1.UserAssetBalanceR\bbalances\"\xa5\x01\n" + "\x10AccountReference\x12<\n" + "\raccount_class\x18\x01 \x01(\x0e2\x17.ledger.v1.AccountClassR\faccountClass\x12\x1d\n" + "\n" + @@ -2188,76 +2559,83 @@ func file_ledger_v1_msg_proto_rawDescGZIP() []byte { } var file_ledger_v1_msg_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_ledger_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 32) var file_ledger_v1_msg_proto_goTypes = []any{ (AccountClass)(0), // 0: ledger.v1.AccountClass (TransactionState)(0), // 1: ledger.v1.TransactionState - (*AccountReference)(nil), // 2: ledger.v1.AccountReference - (*JournalEntry)(nil), // 3: ledger.v1.JournalEntry - (*BlockchainReference)(nil), // 4: ledger.v1.BlockchainReference - (*Journal)(nil), // 5: ledger.v1.Journal - (*AppendJournalRequest)(nil), // 6: ledger.v1.AppendJournalRequest - (*AppendJournalResponse)(nil), // 7: ledger.v1.AppendJournalResponse - (*AppendTransactionEventRequest)(nil), // 8: ledger.v1.AppendTransactionEventRequest - (*TransactionEvent)(nil), // 9: ledger.v1.TransactionEvent - (*AppendTransactionEventResponse)(nil), // 10: ledger.v1.AppendTransactionEventResponse - (*GetJournalRequest)(nil), // 11: ledger.v1.GetJournalRequest - (*ListEntriesRequest)(nil), // 12: ledger.v1.ListEntriesRequest - (*ListEntriesResponse)(nil), // 13: ledger.v1.ListEntriesResponse - (*GetBalanceRequest)(nil), // 14: ledger.v1.GetBalanceRequest - (*GetBalanceResponse)(nil), // 15: ledger.v1.GetBalanceResponse - (*ReplayJournalsRequest)(nil), // 16: ledger.v1.ReplayJournalsRequest - (*ReplayJournalResult)(nil), // 17: ledger.v1.ReplayJournalResult - (*ReplayJournalsResponse)(nil), // 18: ledger.v1.ReplayJournalsResponse - (*HealthResponse)(nil), // 19: ledger.v1.HealthResponse - (*EnqueueSettlementRequest)(nil), // 20: ledger.v1.EnqueueSettlementRequest - (*EnqueueSettlementResponse)(nil), // 21: ledger.v1.EnqueueSettlementResponse - (*GetSettlementRequest)(nil), // 22: ledger.v1.GetSettlementRequest - (*Settlement)(nil), // 23: ledger.v1.Settlement - (*RetrySettlementRequest)(nil), // 24: ledger.v1.RetrySettlementRequest - (*RetrySettlementResponse)(nil), // 25: ledger.v1.RetrySettlementResponse - nil, // 26: ledger.v1.Journal.MetadataEntry - nil, // 27: ledger.v1.AppendJournalRequest.MetadataEntry - nil, // 28: ledger.v1.AppendTransactionEventRequest.MetadataEntry - (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp + (*OperatingModeResponse)(nil), // 2: ledger.v1.OperatingModeResponse + (*SetOperatingModeRequest)(nil), // 3: ledger.v1.SetOperatingModeRequest + (*GetUserBalancesRequest)(nil), // 4: ledger.v1.GetUserBalancesRequest + (*UserAssetBalance)(nil), // 5: ledger.v1.UserAssetBalance + (*GetUserBalancesResponse)(nil), // 6: ledger.v1.GetUserBalancesResponse + (*AccountReference)(nil), // 7: ledger.v1.AccountReference + (*JournalEntry)(nil), // 8: ledger.v1.JournalEntry + (*BlockchainReference)(nil), // 9: ledger.v1.BlockchainReference + (*Journal)(nil), // 10: ledger.v1.Journal + (*AppendJournalRequest)(nil), // 11: ledger.v1.AppendJournalRequest + (*AppendJournalResponse)(nil), // 12: ledger.v1.AppendJournalResponse + (*AppendTransactionEventRequest)(nil), // 13: ledger.v1.AppendTransactionEventRequest + (*TransactionEvent)(nil), // 14: ledger.v1.TransactionEvent + (*AppendTransactionEventResponse)(nil), // 15: ledger.v1.AppendTransactionEventResponse + (*GetJournalRequest)(nil), // 16: ledger.v1.GetJournalRequest + (*ListEntriesRequest)(nil), // 17: ledger.v1.ListEntriesRequest + (*ListEntriesResponse)(nil), // 18: ledger.v1.ListEntriesResponse + (*GetBalanceRequest)(nil), // 19: ledger.v1.GetBalanceRequest + (*GetBalanceResponse)(nil), // 20: ledger.v1.GetBalanceResponse + (*ReplayJournalsRequest)(nil), // 21: ledger.v1.ReplayJournalsRequest + (*ReplayJournalResult)(nil), // 22: ledger.v1.ReplayJournalResult + (*ReplayJournalsResponse)(nil), // 23: ledger.v1.ReplayJournalsResponse + (*HealthResponse)(nil), // 24: ledger.v1.HealthResponse + (*EnqueueSettlementRequest)(nil), // 25: ledger.v1.EnqueueSettlementRequest + (*EnqueueSettlementResponse)(nil), // 26: ledger.v1.EnqueueSettlementResponse + (*GetSettlementRequest)(nil), // 27: ledger.v1.GetSettlementRequest + (*Settlement)(nil), // 28: ledger.v1.Settlement + (*RetrySettlementRequest)(nil), // 29: ledger.v1.RetrySettlementRequest + (*RetrySettlementResponse)(nil), // 30: ledger.v1.RetrySettlementResponse + nil, // 31: ledger.v1.Journal.MetadataEntry + nil, // 32: ledger.v1.AppendJournalRequest.MetadataEntry + nil, // 33: ledger.v1.AppendTransactionEventRequest.MetadataEntry + (*timestamppb.Timestamp)(nil), // 34: google.protobuf.Timestamp } var file_ledger_v1_msg_proto_depIdxs = []int32{ - 0, // 0: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass - 2, // 1: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference - 3, // 2: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry - 29, // 3: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp - 29, // 4: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp - 4, // 5: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference - 26, // 6: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry - 3, // 7: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry - 29, // 8: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp - 4, // 9: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 27, // 10: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry - 5, // 11: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal - 1, // 12: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState - 29, // 13: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp - 4, // 14: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference - 28, // 15: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry - 8, // 16: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest - 29, // 17: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp - 9, // 18: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent - 2, // 19: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference - 29, // 20: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp - 29, // 21: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp - 5, // 22: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal - 2, // 23: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference - 29, // 24: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp - 2, // 25: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference - 29, // 26: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp - 6, // 27: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest - 5, // 28: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal - 17, // 29: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult - 23, // 30: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement - 31, // [31:31] is the sub-list for method output_type - 31, // [31:31] is the sub-list for method input_type - 31, // [31:31] is the sub-list for extension type_name - 31, // [31:31] is the sub-list for extension extendee - 0, // [0:31] is the sub-list for field type_name + 34, // 0: ledger.v1.OperatingModeResponse.changed_at:type_name -> google.protobuf.Timestamp + 5, // 1: ledger.v1.GetUserBalancesResponse.balances:type_name -> ledger.v1.UserAssetBalance + 0, // 2: ledger.v1.AccountReference.account_class:type_name -> ledger.v1.AccountClass + 7, // 3: ledger.v1.JournalEntry.account:type_name -> ledger.v1.AccountReference + 8, // 4: ledger.v1.Journal.entries:type_name -> ledger.v1.JournalEntry + 34, // 5: ledger.v1.Journal.occurred_at:type_name -> google.protobuf.Timestamp + 34, // 6: ledger.v1.Journal.recorded_at:type_name -> google.protobuf.Timestamp + 9, // 7: ledger.v1.Journal.blockchain:type_name -> ledger.v1.BlockchainReference + 31, // 8: ledger.v1.Journal.metadata:type_name -> ledger.v1.Journal.MetadataEntry + 8, // 9: ledger.v1.AppendJournalRequest.entries:type_name -> ledger.v1.JournalEntry + 34, // 10: ledger.v1.AppendJournalRequest.occurred_at:type_name -> google.protobuf.Timestamp + 9, // 11: ledger.v1.AppendJournalRequest.blockchain:type_name -> ledger.v1.BlockchainReference + 32, // 12: ledger.v1.AppendJournalRequest.metadata:type_name -> ledger.v1.AppendJournalRequest.MetadataEntry + 10, // 13: ledger.v1.AppendJournalResponse.journal:type_name -> ledger.v1.Journal + 1, // 14: ledger.v1.AppendTransactionEventRequest.state:type_name -> ledger.v1.TransactionState + 34, // 15: ledger.v1.AppendTransactionEventRequest.occurred_at:type_name -> google.protobuf.Timestamp + 9, // 16: ledger.v1.AppendTransactionEventRequest.blockchain:type_name -> ledger.v1.BlockchainReference + 33, // 17: ledger.v1.AppendTransactionEventRequest.metadata:type_name -> ledger.v1.AppendTransactionEventRequest.MetadataEntry + 13, // 18: ledger.v1.TransactionEvent.event:type_name -> ledger.v1.AppendTransactionEventRequest + 34, // 19: ledger.v1.TransactionEvent.recorded_at:type_name -> google.protobuf.Timestamp + 14, // 20: ledger.v1.AppendTransactionEventResponse.event:type_name -> ledger.v1.TransactionEvent + 7, // 21: ledger.v1.ListEntriesRequest.account:type_name -> ledger.v1.AccountReference + 34, // 22: ledger.v1.ListEntriesRequest.recorded_from:type_name -> google.protobuf.Timestamp + 34, // 23: ledger.v1.ListEntriesRequest.recorded_to:type_name -> google.protobuf.Timestamp + 10, // 24: ledger.v1.ListEntriesResponse.journals:type_name -> ledger.v1.Journal + 7, // 25: ledger.v1.GetBalanceRequest.account:type_name -> ledger.v1.AccountReference + 34, // 26: ledger.v1.GetBalanceRequest.as_of:type_name -> google.protobuf.Timestamp + 7, // 27: ledger.v1.GetBalanceResponse.account:type_name -> ledger.v1.AccountReference + 34, // 28: ledger.v1.GetBalanceResponse.as_of:type_name -> google.protobuf.Timestamp + 11, // 29: ledger.v1.ReplayJournalsRequest.journals:type_name -> ledger.v1.AppendJournalRequest + 10, // 30: ledger.v1.ReplayJournalResult.journal:type_name -> ledger.v1.Journal + 22, // 31: ledger.v1.ReplayJournalsResponse.results:type_name -> ledger.v1.ReplayJournalResult + 28, // 32: ledger.v1.RetrySettlementResponse.settlement:type_name -> ledger.v1.Settlement + 33, // [33:33] is the sub-list for method output_type + 33, // [33:33] is the sub-list for method input_type + 33, // [33:33] is the sub-list for extension type_name + 33, // [33:33] is the sub-list for extension extendee + 0, // [0:33] is the sub-list for field type_name } func init() { file_ledger_v1_msg_proto_init() } @@ -2265,14 +2643,15 @@ func file_ledger_v1_msg_proto_init() { if File_ledger_v1_msg_proto != nil { return } - file_ledger_v1_msg_proto_msgTypes[3].OneofWrappers = []any{} - file_ledger_v1_msg_proto_msgTypes[4].OneofWrappers = []any{} - file_ledger_v1_msg_proto_msgTypes[9].OneofWrappers = []any{ + file_ledger_v1_msg_proto_msgTypes[2].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[8].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[9].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[14].OneofWrappers = []any{ (*GetJournalRequest_JournalId)(nil), (*GetJournalRequest_IdempotencyKey)(nil), } - file_ledger_v1_msg_proto_msgTypes[10].OneofWrappers = []any{} - file_ledger_v1_msg_proto_msgTypes[20].OneofWrappers = []any{ + file_ledger_v1_msg_proto_msgTypes[15].OneofWrappers = []any{} + file_ledger_v1_msg_proto_msgTypes[25].OneofWrappers = []any{ (*GetSettlementRequest_SettlementId)(nil), (*GetSettlementRequest_IdempotencyKey)(nil), } @@ -2282,7 +2661,7 @@ func file_ledger_v1_msg_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_msg_proto_rawDesc), len(file_ledger_v1_msg_proto_rawDesc)), NumEnums: 2, - NumMessages: 27, + NumMessages: 32, NumExtensions: 0, NumServices: 0, }, diff --git a/gen/ledger/v1/srv.pb.go b/gen/ledger/v1/srv.pb.go index ed563a9..3d21b10 100644 --- a/gen/ledger/v1/srv.pb.go +++ b/gen/ledger/v1/srv.pb.go @@ -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 diff --git a/gen/ledger/v1/srv_grpc.pb.go b/gen/ledger/v1/srv_grpc.pb.go index 13290b2..5a26f1a 100644 --- a/gen/ledger/v1/srv_grpc.pb.go +++ b/gen/ledger/v1/srv_grpc.pb.go @@ -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, diff --git a/infrastructure/postgres/authority_integration_test.go b/infrastructure/postgres/authority_integration_test.go new file mode 100644 index 0000000..14de95b --- /dev/null +++ b/infrastructure/postgres/authority_integration_test.go @@ -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") + } +} diff --git a/infrastructure/postgres/authority_repository.go b/infrastructure/postgres/authority_repository.go new file mode 100644 index 0000000..30f9bfd --- /dev/null +++ b/infrastructure/postgres/authority_repository.go @@ -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 +} diff --git a/infrastructure/postgres/journal_repository.go b/infrastructure/postgres/journal_repository.go index 72c593b..42b3f2a 100644 --- a/infrastructure/postgres/journal_repository.go +++ b/infrastructure/postgres/journal_repository.go @@ -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 { diff --git a/infrastructure/postgres/migrations/000006_gl_authority.up.sql b/infrastructure/postgres/migrations/000006_gl_authority.up.sql new file mode 100644 index 0000000..8f87348 --- /dev/null +++ b/infrastructure/postgres/migrations/000006_gl_authority.up.sql @@ -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(); diff --git a/infrastructure/postgres/settlement_repository.go b/infrastructure/postgres/settlement_repository.go index 236cbe1..0657dc6 100644 --- a/infrastructure/postgres/settlement_repository.go +++ b/infrastructure/postgres/settlement_repository.go @@ -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) } diff --git a/interface/grpc/authority.go b/interface/grpc/authority.go new file mode 100644 index 0000000..1e49055 --- /dev/null +++ b/interface/grpc/authority.go @@ -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 +} diff --git a/interface/grpc/authority_test.go b/interface/grpc/authority_test.go new file mode 100644 index 0000000..add862d --- /dev/null +++ b/interface/grpc/authority_test.go @@ -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) + } + }) + } +} diff --git a/interface/grpc/ledger.go b/interface/grpc/ledger.go index acff151..e6c457f 100644 --- a/interface/grpc/ledger.go +++ b/interface/grpc/ledger.go @@ -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): diff --git a/interface/grpc/settlement.go b/interface/grpc/settlement.go index 6411ca2..b15346a 100644 --- a/interface/grpc/settlement.go +++ b/interface/grpc/settlement.go @@ -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 {