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