This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user