26 Commits

Author SHA256 Message Date
nfel d33ec51201 feat: dismiss reviewed settlement failures
CI/CD Stage / stage (push) Failing after 37s
2026-10-07 00:32:20 +03:30
nfel 88e227f80c feat(gl): add standalone ledger authority mode
CI/CD Stage / stage (push) Failing after 1m12s
2026-10-06 22:40:07 +03:30
nfel 01d13dbbf2 feat: update settlement workflow
CI/CD Stage / stage (push) Failing after 37s
2026-09-22 16:26:16 +03:30
nfel 4ae5bb615b feat: add Kuknos submission observability
CI/CD Stage / stage (push) Failing after 36s
2026-09-22 16:01:23 +03:30
nfel 837fe5bc5f fix(ci): support SHA-256 repository checkout
CI/CD Stage / stage (push) Failing after 1m20s
2026-09-22 12:41:12 +03:30
nfel 495a5884df Merge feat/refactor-v1 into stage
CI/CD Stage / stage (push) Failing after 31s
2026-09-22 11:45:49 +03:30
nfel 810448b6e7 chore: update project changes 2026-09-21 15:33:47 +03:30
nfel f7366a1985 Add OpenTelemetry tracing and log correlation 2026-09-17 14:56:57 +03:30
nfel 3445ead897 Move Docker deployment files to deployment directory 2026-09-17 13:31:42 +03:30
Navid 12c478cf3a feat: add durable Kuknos settlement coordination 2026-09-15 14:00:55 +03:30
nfel d5c9b338a4 feat(gl): compare external settlement evidence 2026-08-29 16:32:40 +03:30
nfel b14c5e2696 feat(gl): detect duplicate transaction versions 2026-08-29 15:56:20 +03:30
nfel 3f5fd860bf feat(gl): add read-only ledger reconciliation scan 2026-08-29 15:03:17 +03:30
nfel 9b6c438cc7 feat(gl): add structured grpc observability 2026-08-29 12:59:48 +03:30
Navid dedb9281eb chore: ignore macOS metadata 2026-08-29 10:02:13 +03:30
Navid 83720ba9d1 feat: expose append-only ledger health 2026-08-29 09:57:58 +03:30
nfel 32e7b6b749 feat: add persistent dashboard dark mode
CI/CD Dev / dev (push) Failing after 44s
2026-08-15 00:53:58 +03:30
nfel 675def5fe6 ci(gl): add Gitea delivery workflows 2026-08-15 00:50:25 +03:30
nfel a54070cfad test: add ledger load test commands 2026-08-15 00:48:56 +03:30
nfel 5b4cb5a2a3 feat: add localized ledger explorer dashboard 2026-08-15 00:47:56 +03:30
nfel ce5f8b4a84 build(gl): require Go 1.26 2026-08-14 23:15:07 +03:30
nfel 8ba5d6cef8 fix(gl): avoid internal wallet port collision 2026-08-14 19:15:36 +03:30
nfel 76905ab451 feat(gl): expose ledger application and grpc operations 2026-08-14 19:11:29 +03:30
nfel 0b3eaa0c3d feat(gl): add immutable postgres ledger storage 2026-08-14 19:00:45 +03:30
nfel b1e0b01598 feat(gl): scaffold general ledger grpc service 2026-08-14 18:52:30 +03:30
nfel 1863de1f3b docs(gl): define ledger architecture and invariants 2026-08-14 18:45:02 +03:30
110 changed files with 18101 additions and 1 deletions
+29
View File
@@ -0,0 +1,29 @@
#:schema https://json.schemastore.org/any.json
root = "."
tmp_dir = "tmp"
[build]
cmd = "go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go build -o ./tmp/gl ./cmd/gl && go build -o ./tmp/dashboard ./cmd/dashboard"
entrypoint = ["bash", "./scripts/air-run.sh"]
include_ext = ["go", "templ", "toml"]
exclude_dir = ["tmp", "vendor", ".git"]
exclude_regex = ["_test\\.go$", "_templ\\.go$"]
exclude_unchanged = true
delay = 300
stop_on_error = true
send_interrupt = true
kill_delay = 1000
[log]
time = true
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true
[proxy]
enabled = false
+39
View File
@@ -0,0 +1,39 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = ["-conf", "./dashboard.cfg.toml"]
bin = "./tmp/dashboard"
cmd = "go build -o ./tmp/dashboard ./cmd/dashboard"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"]
exclude_file = []
exclude_regex = ["_test.go", ".pb.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "toml"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = true
stop_on_error = true
[log]
main_only = false
time = true
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = true
keep_scroll = true
+62
View File
@@ -0,0 +1,62 @@
name: Deploy over SSH
description: SSH into server and update compose services
inputs:
ssh_key:
description: Private SSH key content
required: true
user:
description: SSH username
required: true
host:
description: SSH hostname or IP
required: true
compose_file:
description: Path to compose file on remote server
required: true
compose_project_name:
description: Compose project name on remote server
required: true
services:
description: Space-separated compose services
required: true
registry:
description: OCI registry hostname
required: false
default: oci.reg.darano.ir
registry_username:
description: OCI registry username
required: false
default: admin
registry_password:
description: OCI registry password
required: true
runs:
using: composite
steps:
- name: Deploy over SSH
shell: bash
env:
SSH_KEY: ${{ inputs.ssh_key }}
REMOTE_USER: ${{ inputs.user }}
REMOTE_HOST: ${{ inputs.host }}
COMPOSE_FILE: ${{ inputs.compose_file }}
COMPOSE_PROJECT_NAME: ${{ inputs.compose_project_name }}
SERVICES: ${{ inputs.services }}
REGISTRY: ${{ inputs.registry }}
REGISTRY_USERNAME: ${{ inputs.registry_username }}
REGISTRY_PASSWORD: ${{ inputs.registry_password }}
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s' "$REGISTRY_PASSWORD" | ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
"$REMOTE_USER@$REMOTE_HOST" \
"docker login '$REGISTRY' -u '$REGISTRY_USERNAME' --password-stdin && \
export COMPOSE_PROJECT_NAME='$COMPOSE_PROJECT_NAME' && \
docker compose -f '$COMPOSE_FILE' up $SERVICES -d --pull=always; \
rc=\$?; \
docker logout '$REGISTRY'; \
exit \$rc"
@@ -0,0 +1,31 @@
name: Docker Build
description: Build image and load it into the local Docker daemon
inputs:
image:
description: Full image name without a tag
required: true
branch:
description: Branch tag
required: true
commit_sha:
description: Commit SHA tag
required: true
runs:
using: composite
steps:
- name: Build
shell: bash
env:
IMAGE: ${{ inputs.image }}
BRANCH: ${{ inputs.branch }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
docker buildx build \
-f deployment/Dockerfile \
--build-arg "GO_PROXY=https://go.reg.darano.ir" \
--load \
-t "$IMAGE:$BRANCH" \
-t "$IMAGE:$COMMIT_SHA" \
.
+25
View File
@@ -0,0 +1,25 @@
name: Docker Login
description: Login to OCI registry
inputs:
registry:
description: Registry hostname
required: false
default: oci.reg.darano.ir
username:
description: Registry username
required: false
default: admin
password:
description: Registry password
required: true
runs:
using: composite
steps:
- name: Login to registry
shell: bash
env:
REG_USER: ${{ inputs.username }}
REG_PASS: ${{ inputs.password }}
REGISTRY: ${{ inputs.registry }}
run: |
echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
+38
View File
@@ -0,0 +1,38 @@
name: Notify Telegram
description: Send CI/CD status message via Bale bot
inputs:
bot_token:
description: Bale bot token
required: true
chat_id:
description: Target chat ID
required: true
status:
description: Job status
required: true
repository:
description: Repository name
required: true
sha:
description: Commit SHA
required: true
runs:
using: composite
steps:
- name: Send notification
shell: bash
env:
BOT_TOKEN: ${{ inputs.bot_token }}
CHAT_ID: ${{ inputs.chat_id }}
STATUS: ${{ inputs.status }}
REPO: ${{ inputs.repository }}
SHA: ${{ inputs.sha }}
run: |
if [ "$STATUS" = "success" ]; then
MSG="✅ CI/CD passed: ${REPO}@${SHA}"
else
MSG="❌ CI/CD failed: ${REPO}@${SHA}"
fi
curl -s -X POST "https://tapi.bale.ai/bot${BOT_TOKEN}/sendMessage" \
-d chat_id="${CHAT_ID}" \
-d text="$MSG"
+49
View File
@@ -0,0 +1,49 @@
---
name: CI/CD Dev
on:
push:
branches:
- dev
jobs:
dev:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@v6.0.3
with:
token: ${{ gitea.token }}
path: ./
submodules: recursive
- name: Login to registry
uses: ./.gitea/actions/docker-login
with:
password: ${{ secrets.REG_PASS }}
- name: Build
uses: ./.gitea/actions/docker-build-push
with:
image: oci.reg.darano.ir/gl/app
branch: dev
commit_sha: ${{ gitea.sha }}
- name: Push
run: |
docker push oci.reg.darano.ir/gl/app:dev
docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }}
- name: Deploy
uses: ./.gitea/actions/deploy-ssh
with:
ssh_key: ${{ secrets.DEV_SERVER_KEY }}
user: ${{ secrets.DEV_SERVER_USER }}
host: ${{ secrets.DEV_SERVER_HOST }}
compose_file: /services/srv-dev/compose.yml
compose_project_name: srv-dev
services: gl
registry_password: ${{ secrets.REG_PASS }}
- name: Notify Telegram
if: always()
uses: ./.gitea/actions/notify-telegram
with:
bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
status: ${{ job.status }}
repository: ${{ gitea.repository }}
sha: ${{ gitea.sha }}
+49
View File
@@ -0,0 +1,49 @@
---
name: CI/CD Stage
on:
push:
branches:
- stage
jobs:
stage:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@v6.0.3
with:
token: ${{ gitea.token }}
path: ./
submodules: recursive
- name: Login to registry
uses: ./.gitea/actions/docker-login
with:
password: ${{ secrets.REG_PASS }}
- name: Build
uses: ./.gitea/actions/docker-build-push
with:
image: oci.reg.darano.ir/gl/app
branch: stage
commit_sha: ${{ gitea.sha }}
- name: Push
run: |
docker push oci.reg.darano.ir/gl/app:stage
docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }}
- name: Deploy
uses: ./.gitea/actions/deploy-ssh
with:
ssh_key: ${{ secrets.STAGE_SERVER_KEY }}
user: ${{ secrets.STAGE_SERVER_USER }}
host: ${{ secrets.STAGE_SERVER_HOST }}
compose_file: /services/srv-stage/compose.yml
compose_project_name: srv-stage
services: gl
registry_password: ${{ secrets.REG_PASS }}
- name: Notify Telegram
if: always()
uses: ./.gitea/actions/notify-telegram
with:
bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
status: ${{ job.status }}
repository: ${{ gitea.repository }}
sha: ${{ gitea.sha }}
+49
View File
@@ -0,0 +1,49 @@
---
name: CI/CD
on:
push:
branches:
- main
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@v6.0.3
with:
token: ${{ gitea.token }}
path: ./
submodules: recursive
- name: Login to registry
uses: ./.gitea/actions/docker-login
with:
password: ${{ secrets.REG_PASS }}
- name: Build
uses: ./.gitea/actions/docker-build-push
with:
image: oci.reg.darano.ir/gl/app
branch: main
commit_sha: ${{ gitea.sha }}
- name: Push
run: |
docker push oci.reg.darano.ir/gl/app:main
docker push oci.reg.darano.ir/gl/app:${{ gitea.sha }}
- name: Deploy
uses: ./.gitea/actions/deploy-ssh
with:
ssh_key: ${{ secrets.PROD_SERVER_KEY }}
user: ${{ secrets.PROD_SERVER_USER }}
host: ${{ secrets.PROD_SERVER_HOST }}
compose_file: /services/srv-main/compose.yml
compose_project_name: srv-main
services: gl
registry_password: ${{ secrets.REG_PASS }}
- name: Notify Telegram
if: always()
uses: ./.gitea/actions/notify-telegram
with:
bot_token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
chat_id: ${{ secrets.TELEGRAM_CHAT_ID }}
status: ${{ job.status }}
repository: ${{ gitea.repository }}
sha: ${{ gitea.sha }}
+7 -1
View File
@@ -22,9 +22,15 @@
go.work
go.work.sum
# Air live-reload build artifacts
/tmp/
# env file
.env
# macOS metadata
.DS_Store
# ---> JupyterNotebooks
# gitignore template for Jupyter Notebooks
# website: http://jupyter.org/
@@ -240,4 +246,4 @@ cython_debug/
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
.tmp-go-cache
+39
View File
@@ -0,0 +1,39 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = ["-conf", "./gl.cfg.toml"]
bin = "./tmp/gl"
cmd = "go build -o ./tmp/gl ./cmd/gl"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "gen"]
exclude_file = []
exclude_regex = ["_test.go", ".pb.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "toml"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = true
stop_on_error = true
[log]
main_only = false
time = true
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = true
keep_scroll = true
+205
View File
@@ -0,0 +1,205 @@
# General Ledger service design
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
`GL` is Darano's primary financial source of truth. It records every wallet
value movement as an append-only journal. Kuknos, through its Stellar-compatible
interface, is the secondary settlement and verification source of truth.
GL owns its PostgreSQL database and exposes an internal gRPC API. Wallet does
not write GL tables directly, and GL does not write wallet tables. In normal
operation a transaction requires both GL and Kuknos. If GL is unhealthy, all
value-changing transaction admission and processing halt until it recovers. If
Kuknos is unavailable, an authorized operator may explicitly enable GL-only
operation through an AdminPanel toggle or configuration. Kuknos must never be
disabled automatically.
## Non-negotiable invariants
1. Every monetary journal is double entry. Signed entries sum to zero for each
asset in a journal.
2. Journals and entries are append-only. Corrections append a full reversal and
a replacement; update and delete operations are not exposed.
3. Amounts cross the API as canonical decimal strings and are stored as
`numeric(38,18)`. Floating-point values are rejected at the GL boundary.
4. A source idempotency key identifies one immutable payload. Replaying the
same key and payload succeeds with the original result; reusing the key for
different content fails.
5. Account, asset, source transaction, correlation, actor, occurrence time, and
recording time are retained on every applicable record.
6. A journal commits atomically with all its entries or not at all.
7. In normal Wallet ingestion, a financial effect is journaled only when Wallet has committed that effect.
Pending and failed lifecycle events may be retained as audit events but do
not create monetary entries. For standalone GL-only execution, the GL journal
commit itself is the financial effect; any Wallet copy is a projection.
8. Derived balances are rebuildable solely by ordering and summing immutable
entries. Cached balances are disposable projections, never source data.
## Data model
### `ledger_accounts`
A stable account is scoped to an asset and optional owner. Initial account
classes are `USER_AVAILABLE`, `USER_FROZEN`, `EXTERNAL_BLOCKCHAIN`,
`TREASURY`, `MARKET_CLEARING`, `IPG_CLEARING`, and `COMMISSION_REVENUE`.
The natural identity `(class, owner_type, owner_id, asset_id)` is unique.
### `journals`
Each journal contains a generated UUID, source service, unique idempotency key,
source transaction ID and tracking code, effect kind and version, optional
reversal target, occurred/recorded timestamps, correlation and actor IDs,
blockchain network/hash/sequence references, and JSON metadata. A canonical
payload hash detects conflicting reuse of an idempotency key.
### `entries`
Each entry contains its journal ID, line number, ledger account, asset ID,
signed decimal amount, and optional description. `(journal_id, line_number)` is
unique. Positive means credit and negative means debit. Zero entries are
invalid. Database constraints and the application transaction jointly enforce
precision, immutability, account/asset agreement, and per-asset balance.
### `transaction_events`
Lifecycle observations such as `CREATED`, `PENDING_TRX`, `PENDING_ADMIN`,
`FAILED`, and `SUCCESSFUL` are append-only events keyed idempotently by source
transaction and event version. They preserve error and blockchain metadata but
are separate from monetary journals.
## Wallet-to-ledger mapping
Every row below balances independently per asset. Where one business operation
moves two assets, the entries belong to one correlated journal containing two
balanced asset groups.
| Wallet effect | Debit | Credit |
|---|---|---|
| Internal transfer | sender `USER_AVAILABLE` | recipient `USER_AVAILABLE` |
| External deposit | `EXTERNAL_BLOCKCHAIN` | recipient `USER_AVAILABLE` |
| External withdrawal | sender `USER_AVAILABLE` | `EXTERNAL_BLOCKCHAIN` |
| Freeze/lock | user `USER_AVAILABLE` | user `USER_FROZEN` |
| Release/unlock | user `USER_FROZEN` | user `USER_AVAILABLE` |
| IRT/IPG deposit | `IPG_CLEARING` | user `USER_AVAILABLE` |
| IRT withdrawal | user `USER_AVAILABLE` | `IPG_CLEARING` |
| Commission | payer or originating clearing account | `COMMISSION_REVENUE` |
| Buy/ICO | buyer IRT; treasury asset inventory | `TREASURY` IRT; buyer asset |
| Sell/redeem | seller asset; `TREASURY` IRT | treasury asset inventory; seller IRT |
| Market trade | buyer quote and seller base | seller quote and buyer base |
Trustline creation is non-monetary and is recorded only as a transaction event.
The adapter must derive accounts from stable IDs, not display names or public
keys. Before integration, each existing Wallet call site must confirm its exact
counterparty and fee legs; missing information is a validation failure, not an
implicit suspense posting.
## Delivery and ordering
Wallet owns a durable `ledger_outbox` table. The wallet state change, wallet
transaction update, and outbox insert occur in the same PostgreSQL transaction.
A dispatcher delivers events to GL with at-least-once semantics. It retries with
backoff and never drops a record; poison records are quarantined and alerted but
remain replayable.
The idempotency key format is
`wallet:<transaction-id>:<effect-kind>:v<event-version>`. A transaction hash is
metadata, not an identity, because it can be absent or replaced. GL serializes
posts that affect the same accounts, records both source and receipt ordering,
and returns the existing journal for identical duplicates. Together, the
transactional outbox and GL idempotency provide effectively-once posting.
Wallet must not synchronously dual-write its database and GL. GL/network
unavailability therefore cannot lose an already committed wallet effect and
does not hold a wallet database transaction open.
## Operating modes and failure semantics
GL now exposes its own persisted `NORMAL` / `GL_ONLY` operating state through
`GetOperatingMode` and the authenticated `SetOperatingMode` RPC. This is
distinct from Wallet's existing `KUKNOS_DISABLED` configuration, which still
makes Wallet read-only until its callers are integrated with this API.
In `GL_ONLY`, `PostInternalJournal` executes balanced internal effects
synchronously. PostgreSQL locks user accounts and checks available/frozen
balances in the same commit; conflicting spends cannot both succeed. All
journal append paths enforce the GL-only funds rules; ordinary ingestion rejects
new effects in this mode, so internal execution cannot be bypassed. Blockchain
references
and external blockchain account movements are forbidden, new settlement
requests are rejected, and the worker stops claiming existing settlements.
The mode survives restarts and changes have immutable actor/reason/revision
audits and OpenTelemetry spans. Switching drains posting transactions and
rejects in-flight settlement claims or negative user balances. Returning to
`NORMAL` requires explicit operator reconciliation confirmation.
The ledger must be complete before switching: restore/import opening balances
and drain committed Wallet outbox effects and in-flight Wallet processing.
GL-only mode keeps ledger reads/writes available while reporting settlement as
paused; existing normal-mode Wallet clients therefore fail closed rather than
continuing blockchain-dependent processing. Internal executions must not also
be delivered through that outbox as new monetary effects. User authorization,
asset eligibility, fees, and business counterparty mapping remain admission
responsibilities. Actual external payment settlement cannot occur internally.
See [the authority API](deployment/AUTHORITY_API.md) and
[standalone operation](deployment/STANDALONE.md).
The broader Wallet availability policy remains:
- `NORMAL`: GL and Kuknos must both be healthy. A transaction is successful
only after its required GL journal and Kuknos settlement evidence exist.
- `KUKNOS_DISABLED`: explicitly enabled and disabled by an authorized operator
through AdminPanel or configuration. GL remains mandatory and authoritative;
eligible transactions may proceed without Kuknos. Every mode transition must
be immutable, attributable, time-bounded where configured, and emitted via
OpenTelemetry. This mode is not implemented yet.
- `RECONCILE`: transaction processing is paused or restricted while tooling
compares GL and Kuknos and appends approved reversals/corrections. Existing
ledger records are never edited or deleted.
GL failure is always fail-closed. Public readiness reports a critical state and
the incident and recovery are emitted through OpenTelemetry. Kuknos failure is
also fail-closed unless `KUKNOS_DISABLED` has been explicitly authorized.
GL rejects unbalanced journals, invalid precision, unknown account/asset
combinations, duplicate line numbers, missing source identity, conflicting
idempotency payloads, and invalid reversals. Identical retries are successful.
If Wallet cannot write its outbox in the local transaction, that wallet
transaction rolls back. If dispatch fails, Wallet records retry state without
changing the committed financial result.
## Internal service boundary
The first `ledger.v1.GeneralLedgerService` contract provides:
- `AppendJournal` for one atomic, idempotent balanced journal;
- `AppendTransactionEvent` for non-monetary lifecycle history;
- `GetJournal` and `ListEntries` for audit and replay inspection;
- `GetBalance` for a rebuildable account/asset projection;
- `GetUserBalances` for available, frozen, and total balances per user asset;
- `GetOperatingMode` / `SetOperatingMode` for attributable operator cutover;
- `PostInternalJournal` for atomic internal execution in `GL_ONLY`;
- `Health` for readiness and database health.
Wallet depends on its own `Ledger` port. Its gRPC implementation translates
wallet domain values to this contract; Wallet never imports GL persistence
types. Deadlines, retry classification, authentication, and correlation metadata
are adapter concerns.
## Implementation sequence
1. Add backward-compatible `ledger/v1` protobuf messages and service methods.
2. Scaffold GL with domain, application, infrastructure, and interface layers.
3. Add explicit SQL migrations and a transactional PostgreSQL repository.
4. Implement invariant validation, idempotency, queries, and reversals.
5. Add Wallet's ledger port, gRPC adapter, transactional outbox, and dispatcher.
6. Map each Wallet transaction path and add outage/replay/reconciliation tests.
## Initial non-goals
- Disabling Kuknos automatically on health-check failure.
- Editing or deleting posted journals.
- Storing binary floats or using Wallet's mutable transaction table as GL.
- Sharing a database schema between Wallet and GL.
- Migrating UI, documentation-site, or DevOps repositories.
+20
View File
@@ -0,0 +1,20 @@
.PHONY: build generate gl-load-test gl-redistribution-load-test load-test test
generate:
go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate
buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1
build: generate
go build ./...
test:
go test ./...
load-test:
go run ./cmd/dashboard-loadtest $(LOADTEST_ARGS)
gl-load-test:
go run ./cmd/gl-loadtest $(GL_LOADTEST_ARGS)
gl-redistribution-load-test:
go run ./cmd/gl-redistribution-loadtest $(GL_REDISTRIBUTION_LOADTEST_ARGS)
+78
View File
@@ -1,2 +1,80 @@
# GL
Darano's independent, append-only general-ledger service. It preserves every
committed wallet value movement so financial state can be reconstructed when a
blockchain or provider is unavailable.
The implementation contract and invariants are defined in [DESIGN.md](DESIGN.md).
For an independent PostgreSQL + ledger API + dashboard deployment with no
blockchain network access, see [Standalone General Ledger](deployment/STANDALONE.md).
The [authority API](deployment/AUTHORITY_API.md) provides an authenticated,
persisted GL-only toggle, internal journal execution, and per-user balances.
Generate protobufs, test, and build with:
```bash
make generate
make test
make build
```
Run the ledger API and dashboard as separate processes:
```bash
go run ./cmd/gl -conf ./gl.cfg.toml
go run ./cmd/dashboard -conf ./dashboard.cfg.toml
```
For live reload, install [Air](https://github.com/air-verse/air) and run `air`
from the repository root. The checked-in `.air.toml` rebuilds and restarts both
processes together.
The read-only network explorer is served on `http://localhost:8080` by default:
The header includes English/Persian localization and a persistent light/dark
theme toggle that initially follows the operating-system preference.
- `/` shows network totals and recent committed journals;
- `/assets` ranks the top positive user holders for recently active assets;
- `/holders` combines a user's available and frozen balance and activity for one asset;
- `/transactions` shows paginated recent transactions, filters them by user/wallet and effect type, and finds journals by exact blockchain transaction hash or internal journal ID;
- `/accounts` rebuilds a ledger account balance and activity history.
The gRPC service owns schema migrations. The dashboard is read-only, uses its
own `dashboard.cfg.toml`, and does not run migrations.
AdminPanel settlement review supports `IgnoreSettlement` through the authenticated
GL admin connection. Operators with `ledgerops.retry_kuknossettlement` can review an
unlocked `MANUAL_REVIEW` or `RETRYABLE` settlement and record a reason to close it.
GL atomically sets `IGNORED`, records the actor/reason/status transition in
`kuknos_settlement_operator_audits`, and resolves linked open reconciliation issues.
The original error and signed payload remain available as evidence. Ignored
settlements are terminal, cannot be retried, and leave the attention queue.
Dismissal does not confirm a payment or reverse funds; IRT withdrawal reservations
still require financial reconciliation.
Deploy the updated GL service first so its startup applies migration
`000007_ignore_settlement.up.sql` and exposes the RPC, then deploy Wallet and
AdminPanel. An older GL service rejects the command; AdminPanel preserves the
review form and shows the error rather than reporting a successful dismissal.
Run the dashboard load test against a started dashboard with:
```bash
make load-test LOADTEST_ARGS='-duration 30s -concurrency 50'
```
Use `-paths` to exercise known explorer records as well as the overview, for
example `-paths '/,/transactions?q=KNOWN_HASH,/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9'`.
Run the fixed 10-person, 10,000,000-transfer GL conservation scenario with:
```bash
make gl-redistribution-load-test
Asset number: 7
```
It starts with one person holding 10,000, uses random transfer values while
protecting a minimum of 45 per person, and verifies that the final ten balances
still sum to exactly 10,000.
+282
View File
@@ -0,0 +1,282 @@
// Package explorer implements the read-only queries used by GL's web explorer.
package explorer
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"gl/domain/ledger"
)
const (
recentJournalLimit = 12
recentSettlementLimit = 12
transactionPageSize = 20
topAssetLimit = 5
topHoldersPerAsset = 5
)
type Stats struct {
JournalCount int64
EntryCount int64
AccountCount int64
LastRecordedAt time.Time
}
type SettlementStats struct {
Pending int64
Retryable int64
Confirmed int64
ManualReview int64
}
type Settlement struct {
ID string
SourceService string
SourceTransactionID string
Status ledger.SettlementStatus
Network string
TransactionHash string
Attempts int
LastError string
UpdatedAt time.Time
}
type Holder struct {
Rank int64
OwnerType string
OwnerID string
AssetID int64
Balance ledger.Amount
}
type HolderReference struct {
OwnerType string
OwnerID string
AssetID int64
}
type Repository interface {
Stats(context.Context) (Stats, error)
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
GetByTransactionHash(context.Context, string) ([]ledger.Journal, error)
GetByID(context.Context, string) (ledger.Journal, error)
Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error)
TopHolders(context.Context, int, int) ([]Holder, error)
SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error)
}
type Service struct {
repository Repository
}
type Dashboard struct {
Stats Stats
Journals []ledger.Journal
SettlementStats SettlementStats
Settlements []Settlement
}
type Assets struct {
TopHolders []Holder
}
type TransactionListing struct {
Journals []ledger.Journal
Filter TransactionFilter
HasPrevious bool
HasNext bool
}
type TransactionFilter struct {
Page int
Wallet string
EffectKind string
}
type Account struct {
Reference ledger.AccountReference
Balance ledger.Amount
Journals []ledger.Journal
}
type HolderAccount struct {
Reference HolderReference
Balance ledger.Amount
Journals []ledger.Journal
}
func NewService(repository Repository) *Service {
return &Service{repository: repository}
}
func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) {
stats, err := s.repository.Stats(ctx)
if err != nil {
return Dashboard{}, fmt.Errorf("read explorer stats: %w", err)
}
journals, err := s.repository.List(ctx, ledger.JournalFilter{Limit: recentJournalLimit})
if err != nil {
return Dashboard{}, fmt.Errorf("read recent journals: %w", err)
}
settlementStats, settlements, err := s.repository.SettlementDashboard(ctx, recentSettlementLimit)
if err != nil {
return Dashboard{}, fmt.Errorf("read settlement dashboard: %w", err)
}
return Dashboard{Stats: stats, Journals: journals, SettlementStats: settlementStats, Settlements: settlements}, nil
}
func (s *Service) Assets(ctx context.Context) (Assets, error) {
holders, err := s.repository.TopHolders(ctx, topAssetLimit, topHoldersPerAsset)
if err != nil {
return Assets{}, fmt.Errorf("read top asset holders: %w", err)
}
return Assets{TopHolders: holders}, nil
}
func (s *Service) Transactions(ctx context.Context, filter TransactionFilter) (TransactionListing, error) {
filter.Wallet = strings.TrimSpace(filter.Wallet)
filter.EffectKind = strings.TrimSpace(filter.EffectKind)
if filter.Page < 1 {
filter.Page = 1
}
if filter.Page > 1_000_000 {
return TransactionListing{}, fmt.Errorf("transaction page is too large")
}
if len(filter.Wallet) > 256 || len(filter.EffectKind) > 128 {
return TransactionListing{}, fmt.Errorf("transaction filter is too long")
}
repositoryFilter := ledger.JournalFilter{
Limit: transactionPageSize + 1,
Offset: (filter.Page - 1) * transactionPageSize,
}
if filter.Wallet != "" {
repositoryFilter.OwnerID = &filter.Wallet
}
if filter.EffectKind != "" {
repositoryFilter.EffectKind = &filter.EffectKind
}
journals, err := s.repository.List(ctx, repositoryFilter)
if err != nil {
return TransactionListing{}, fmt.Errorf("read transaction page: %w", err)
}
listing := TransactionListing{Journals: journals, Filter: filter, HasPrevious: filter.Page > 1}
if len(listing.Journals) > transactionPageSize {
listing.HasNext = true
listing.Journals = listing.Journals[:transactionPageSize]
}
return listing, nil
}
func (s *Service) Transaction(ctx context.Context, reference string) ([]ledger.Journal, error) {
reference = strings.TrimSpace(reference)
if reference == "" {
return nil, fmt.Errorf("transaction reference is required")
}
if len(reference) > 256 {
return nil, fmt.Errorf("transaction reference is too long")
}
journals, err := s.repository.GetByTransactionHash(ctx, reference)
if err != nil {
return nil, fmt.Errorf("read transaction: %w", err)
}
if len(journals) != 0 {
return journals, nil
}
if !isJournalID(reference) {
return nil, ledger.ErrNotFound
}
journal, err := s.repository.GetByID(ctx, reference)
if err != nil {
if errors.Is(err, ledger.ErrNotFound) {
return nil, ledger.ErrNotFound
}
return nil, fmt.Errorf("read internal transaction: %w", err)
}
return []ledger.Journal{journal}, nil
}
func isJournalID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
switch index {
case 8, 13, 18, 23:
if character != '-' {
return false
}
default:
if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) {
return false
}
}
}
return true
}
func (s *Service) Account(ctx context.Context, reference ledger.AccountReference) (Account, error) {
if err := reference.Validate(); err != nil {
return Account{}, fmt.Errorf("invalid account: %w", err)
}
balance, err := s.repository.Balance(ctx, reference, time.Time{})
if err != nil {
return Account{}, fmt.Errorf("read account balance: %w", err)
}
journals, err := s.repository.List(ctx, ledger.JournalFilter{
Account: &reference,
AssetID: &reference.AssetID,
Limit: 50,
})
if err != nil {
return Account{}, fmt.Errorf("read account journals: %w", err)
}
return Account{Reference: reference, Balance: balance, Journals: journals}, nil
}
func (s *Service) Holder(ctx context.Context, reference HolderReference) (HolderAccount, error) {
if strings.TrimSpace(reference.OwnerType) == "" || strings.TrimSpace(reference.OwnerID) == "" || reference.AssetID <= 0 {
return HolderAccount{}, fmt.Errorf("invalid holder reference")
}
result := HolderAccount{Reference: reference}
seen := make(map[string]struct{})
for _, class := range []ledger.AccountClass{ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen} {
account := ledger.AccountReference{Class: class, OwnerType: reference.OwnerType, OwnerID: reference.OwnerID, AssetID: reference.AssetID}
balance, err := s.repository.Balance(ctx, account, time.Time{})
if err != nil {
return HolderAccount{}, fmt.Errorf("read holder balance: %w", err)
}
result.Balance = result.Balance.Add(balance)
journals, err := s.repository.List(ctx, ledger.JournalFilter{Account: &account, AssetID: &reference.AssetID, Limit: 50})
if err != nil {
return HolderAccount{}, fmt.Errorf("read holder journals: %w", err)
}
for _, journal := range journals {
if _, exists := seen[journal.ID]; exists {
continue
}
seen[journal.ID] = struct{}{}
result.Journals = append(result.Journals, journal)
}
}
sort.Slice(result.Journals, func(left, right int) bool {
if result.Journals[left].RecordedAt.Equal(result.Journals[right].RecordedAt) {
return result.Journals[left].ID > result.Journals[right].ID
}
return result.Journals[left].RecordedAt.After(result.Journals[right].RecordedAt)
})
if len(result.Journals) > 50 {
result.Journals = result.Journals[:50]
}
return result, nil
}
func IsNotFound(err error) bool {
return errors.Is(err, ledger.ErrNotFound)
}
+150
View File
@@ -0,0 +1,150 @@
package explorer
import (
"context"
"fmt"
"testing"
"time"
"gl/domain/ledger"
)
type repositoryStub struct {
stats Stats
journals []ledger.Journal
transactions []ledger.Journal
journal ledger.Journal
journalErr error
holders []Holder
balance ledger.Amount
settlementStats SettlementStats
settlements []Settlement
lastFilter *ledger.JournalFilter
}
func (r repositoryStub) Stats(context.Context) (Stats, error) { return r.stats, nil }
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
if r.lastFilter != nil {
*r.lastFilter = filter
}
return r.journals, nil
}
func (r repositoryStub) GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) {
return r.transactions, nil
}
func (r repositoryStub) GetByID(context.Context, string) (ledger.Journal, error) {
return r.journal, r.journalErr
}
func (r repositoryStub) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) {
return r.balance, nil
}
func (r repositoryStub) TopHolders(context.Context, int, int) ([]Holder, error) {
return r.holders, nil
}
func (r repositoryStub) SettlementDashboard(context.Context, int) (SettlementStats, []Settlement, error) {
return r.settlementStats, r.settlements, nil
}
func TestDashboardIncludesBlockchainSettlements(t *testing.T) {
service := NewService(repositoryStub{
settlementStats: SettlementStats{Confirmed: 7, Pending: 2},
settlements: []Settlement{{ID: "settlement-1", Status: ledger.SettlementConfirmed, TransactionHash: "chain-hash"}},
})
result, err := service.Dashboard(context.Background())
if err != nil {
t.Fatal(err)
}
if result.SettlementStats.Confirmed != 7 || len(result.Settlements) != 1 || result.Settlements[0].TransactionHash != "chain-hash" {
t.Fatalf("unexpected settlement dashboard: %+v", result)
}
}
func TestAssetsIncludesTopHolders(t *testing.T) {
balance, err := ledger.ParseAmount("125.5")
if err != nil {
t.Fatal(err)
}
service := NewService(repositoryStub{holders: []Holder{{Rank: 1, OwnerType: "user", OwnerID: "42", AssetID: 7, Balance: balance}}})
result, err := service.Assets(context.Background())
if err != nil {
t.Fatal(err)
}
if len(result.TopHolders) != 1 || result.TopHolders[0].Balance.String() != "125.5" {
t.Fatalf("unexpected top holders: %+v", result.TopHolders)
}
}
func TestTransactionsPaginatesWithLookahead(t *testing.T) {
journals := make([]ledger.Journal, transactionPageSize+1)
for index := range journals {
journals[index].ID = fmt.Sprintf("journal-%d", index)
}
var repositoryFilter ledger.JournalFilter
service := NewService(repositoryStub{journals: journals, lastFilter: &repositoryFilter})
result, err := service.Transactions(context.Background(), TransactionFilter{Page: 2, Wallet: " wallet-42 ", EffectKind: " transfer "})
if err != nil {
t.Fatal(err)
}
if result.Filter.Page != 2 || result.Filter.Wallet != "wallet-42" || result.Filter.EffectKind != "transfer" || !result.HasPrevious || !result.HasNext || len(result.Journals) != transactionPageSize {
t.Fatalf("unexpected transaction page: %+v", result)
}
if repositoryFilter.OwnerID == nil || *repositoryFilter.OwnerID != "wallet-42" || repositoryFilter.EffectKind == nil || *repositoryFilter.EffectKind != "transfer" || repositoryFilter.Offset != transactionPageSize {
t.Fatalf("unexpected repository filter: %+v", repositoryFilter)
}
}
func TestHolderCombinesAvailableAndFrozenBalances(t *testing.T) {
balance, err := ledger.ParseAmount("10.25")
if err != nil {
t.Fatal(err)
}
service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}})
result, err := service.Holder(context.Background(), HolderReference{OwnerType: "user", OwnerID: "42", AssetID: 7})
if err != nil {
t.Fatal(err)
}
if result.Balance.String() != "20.5" || len(result.Journals) != 1 {
t.Fatalf("unexpected aggregate holder account: %+v", result)
}
}
func TestTransactionRequiresAResult(t *testing.T) {
service := NewService(repositoryStub{})
if _, err := service.Transaction(context.Background(), "hash"); !IsNotFound(err) {
t.Fatalf("expected not found, got %v", err)
}
}
func TestTransactionFallsBackToInternalJournalID(t *testing.T) {
const journalID = "5c1e31b0-0000-4000-8000-0000084accc8"
service := NewService(repositoryStub{journal: ledger.Journal{ID: journalID}})
result, err := service.Transaction(context.Background(), journalID)
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].ID != journalID {
t.Fatalf("unexpected internal transaction result: %+v", result)
}
}
func TestAccountReturnsBalanceAndHistory(t *testing.T) {
balance, err := ledger.ParseAmount("12.5")
if err != nil {
t.Fatal(err)
}
reference := ledger.AccountReference{
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "42", AssetID: 7,
}
service := NewService(repositoryStub{balance: balance, journals: []ledger.Journal{{ID: "journal-1"}}})
result, err := service.Account(context.Background(), reference)
if err != nil {
t.Fatal(err)
}
if result.Balance.String() != "12.5" || len(result.Journals) != 1 {
t.Fatalf("unexpected account result: %+v", result)
}
}
+64
View File
@@ -0,0 +1,64 @@
// Package health provides the service readiness use case.
package health
import (
"context"
"gl/domain/ledger"
)
type Database interface {
Ping(context.Context) error
}
type SettlementMonitor interface {
Stats(context.Context) (ledger.SettlementStats, error)
}
type SettlementAuthority interface {
SettlementAllowed(context.Context) (bool, error)
}
type Status struct {
Serving bool
DatabaseReady bool
SettlementEnabled bool
SettlementReady bool
SettlementStats ledger.SettlementStats
}
type Service struct {
database Database
settlement SettlementMonitor
settlementEnabled bool
}
func NewService(database Database, settlement ...SettlementMonitor) *Service {
service := &Service{database: database}
if len(settlement) > 0 && settlement[0] != nil {
service.settlement = settlement[0]
service.settlementEnabled = true
}
return service
}
func (s *Service) Check(ctx context.Context) Status {
status := Status{}
if s.database != nil {
status.DatabaseReady = s.database.Ping(ctx) == nil
}
status.Serving = status.DatabaseReady
status.SettlementEnabled = s.settlementEnabled
if status.DatabaseReady && s.settlementEnabled {
stats, err := s.settlement.Stats(ctx)
if err == nil {
status.SettlementStats = stats
status.SettlementReady = true
if authority, ok := s.settlement.(SettlementAuthority); ok {
allowed, authorityErr := authority.SettlementAllowed(ctx)
status.SettlementReady = authorityErr == nil && allowed
}
}
}
return status
}
+75
View File
@@ -0,0 +1,75 @@
package health
import (
"context"
"errors"
"testing"
"gl/domain/ledger"
)
type databaseStub struct{ err error }
func (s databaseStub) Ping(context.Context) error { return s.err }
type settlementStub struct {
stats ledger.SettlementStats
err error
}
func (s settlementStub) Stats(context.Context) (ledger.SettlementStats, error) {
return s.stats, s.err
}
func TestCheckReportsDatabaseReadiness(t *testing.T) {
for _, tc := range []struct {
name string
db Database
ready bool
}{
{name: "not connected", db: nil, ready: false},
{name: "ready", db: databaseStub{}, ready: true},
{name: "unavailable", db: databaseStub{err: errors.New("down")}, ready: false},
} {
t.Run(tc.name, func(t *testing.T) {
got := NewService(tc.db).Check(context.Background())
if got.Serving != tc.ready || got.DatabaseReady != tc.ready {
t.Fatalf("unexpected status: %+v", got)
}
})
}
}
func TestCheckReportsSettlementBacklog(t *testing.T) {
monitor := settlementStub{stats: ledger.SettlementStats{Pending: 2, Retryable: 3, OldestPendingSeconds: 60}}
got := NewService(databaseStub{}, monitor).Check(context.Background())
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || !got.SettlementReady || got.SettlementStats != monitor.stats {
t.Fatalf("unexpected status: %+v", got)
}
}
func TestCheckExposesManualReviewWithoutStoppingSettlementQueue(t *testing.T) {
got := NewService(databaseStub{}, settlementStub{stats: ledger.SettlementStats{ManualReview: 1}}).Check(context.Background())
if !got.Serving || !got.SettlementReady || got.SettlementStats.ManualReview != 1 {
t.Fatalf("unexpected status: %+v", got)
}
}
func TestCheckReportsSettlementQueryFailure(t *testing.T) {
got := NewService(databaseStub{}, settlementStub{err: errors.New("query failed")}).Check(context.Background())
if !got.Serving || got.SettlementReady {
t.Fatalf("unexpected status: %+v", got)
}
}
type pausedSettlementStub struct{ settlementStub }
func (pausedSettlementStub) SettlementAllowed(context.Context) (bool, error) { return false, nil }
func TestGLOnlyKeepsLedgerServingWhileNormalSettlementClientsFailClosed(t *testing.T) {
monitor := pausedSettlementStub{settlementStub{stats: ledger.SettlementStats{Pending: 3}}}
got := NewService(databaseStub{}, monitor).Check(context.Background())
if !got.Serving || !got.DatabaseReady || !got.SettlementEnabled || got.SettlementReady || got.SettlementStats.Pending != 3 {
t.Fatalf("unexpected GL-only readiness: %+v", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ledger
import (
"context"
"strings"
domain "gl/domain/ledger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
type AuthorityRepository interface {
OperatingMode(context.Context) (domain.OperatingState, error)
SetOperatingMode(context.Context, domain.OperatingMode, int64, string, string, bool) (domain.OperatingState, error)
UserBalances(context.Context, string, string, *int64) ([]domain.UserAssetBalance, error)
}
func (s *Service) OperatingMode(ctx context.Context) (domain.OperatingState, error) {
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
return repository.OperatingMode(ctx)
}
func (s *Service) SetOperatingMode(ctx context.Context, mode domain.OperatingMode, revision int64, actor, reason string, reconciled bool) (domain.OperatingState, error) {
if (mode != domain.ModeNormal && mode != domain.ModeGLOnly) || revision < 0 || strings.TrimSpace(actor) == "" || strings.TrimSpace(reason) == "" {
return domain.OperatingState{}, invalid("mode, nonnegative revision, actor, and reason are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return domain.OperatingState{}, domain.ErrModeRequired
}
ctx, span := otel.Tracer("gl/ledger").Start(ctx, "ledger.set_operating_mode")
defer span.End()
span.SetAttributes(attribute.String("ledger.mode", string(mode)), attribute.Int64("ledger.expected_revision", revision), attribute.String("ledger.actor_id", actor))
state, err := repository.SetOperatingMode(ctx, mode, revision, actor, reason, reconciled)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "mode change failed")
return domain.OperatingState{}, err
}
if state.Revision != revision {
span.AddEvent("ledger.operating_mode_changed")
}
span.SetAttributes(attribute.Int64("ledger.revision", state.Revision))
return state, nil
}
func (s *Service) UserBalances(ctx context.Context, ownerType, ownerID string, assetID *int64) ([]domain.UserAssetBalance, error) {
if strings.TrimSpace(ownerType) == "" || strings.TrimSpace(ownerID) == "" || (assetID != nil && *assetID <= 0) {
return nil, invalid("owner type, owner id, and a positive optional asset id are required")
}
repository, ok := s.repository.(AuthorityRepository)
if !ok {
return nil, domain.ErrNotFound
}
return repository.UserBalances(ctx, ownerType, ownerID, assetID)
}
// PostInternalJournal executes exact balanced entries without network settlement.
// PostgreSQL rechecks mode and funds under locks in the same journal transaction.
func (s *Service) PostInternalJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if strings.TrimSpace(command.ActorID) == "" {
return AppendJournalResult{}, invalid("actor id is required for internal execution")
}
if command.Blockchain != (domain.BlockchainReference{}) {
return AppendJournalResult{}, invalid("internal execution cannot claim blockchain settlement")
}
for _, entry := range command.Entries {
if entry.Account.Class == domain.AccountClassExternalBlockchain {
return AppendJournalResult{}, invalid("external blockchain movements cannot execute internally")
}
}
command.Metadata = cloneMetadata(command.Metadata)
command.Metadata[domain.InternalExecutionMetadata] = string(domain.ModeGLOnly)
return s.appendJournal(ctx, command)
}
+65
View File
@@ -0,0 +1,65 @@
package ledger
import (
"context"
"errors"
domain "gl/domain/ledger"
"testing"
)
func TestInternalPostingRejectsExternalEvidenceAndMissingActor(t *testing.T) {
for _, tc := range []struct {
name string
change func(*AppendJournalCommand)
}{
{"missing actor", func(c *AppendJournalCommand) { c.ActorID = "" }},
{"blockchain hash", func(c *AppendJournalCommand) { c.Blockchain.TransactionHash = "hash" }},
{"external movement", func(c *AppendJournalCommand) { c.Entries[0].Account.Class = domain.AccountClassExternalBlockchain }},
} {
t.Run(tc.name, func(t *testing.T) {
repository := newRepositoryStub()
command := validAppendCommand()
command.ActorID = "operator"
tc.change(&command)
_, err := NewService(repository, fixedID("test")).PostInternalJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) || repository.appendCalls != 0 {
t.Fatalf("invalid internal execution reached repository: %v", err)
}
})
}
}
func TestInternalExecutionMarksJournalWithoutMutatingCallerMetadata(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("test"))
command := validAppendCommand()
command.ActorID = "operator"
command.Metadata = map[string]string{"correlation": "value"}
result, err := service.PostInternalJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if result.Journal.Metadata[domain.InternalExecutionMetadata] != "GL_ONLY" {
t.Fatal("internal provenance missing")
}
if _, ok := command.Metadata[domain.InternalExecutionMetadata]; ok {
t.Fatal("caller metadata mutated")
}
command.Metadata[domain.InternalExecutionMetadata] = "GL_ONLY"
_, err = service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("ordinary API accepted internal marker: %v", err)
}
}
func TestAuthorityValidatesInputs(t *testing.T) {
service := NewService(newRepositoryStub(), nil)
for _, owner := range []string{"", " "} {
if _, err := service.UserBalances(context.Background(), "user", owner, nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid owner: %v", err)
}
}
if _, err := service.SetOperatingMode(context.Background(), "AUTO", 0, "operator", "outage", false); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("invalid mode: %v", err)
}
}
+391
View File
@@ -0,0 +1,391 @@
// Package ledger implements GL's transport-independent use cases.
package ledger
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"time"
domain "gl/domain/ledger"
)
var ErrInvalidArgument = errors.New("invalid ledger request")
type Repository interface {
Append(context.Context, domain.Journal) (domain.AppendResult, error)
AppendEvent(context.Context, domain.TransactionEvent) (domain.TransactionEvent, bool, error)
GetByID(context.Context, string) (domain.Journal, error)
GetByIdempotencyKey(context.Context, string) (domain.Journal, error)
List(context.Context, domain.JournalFilter) ([]domain.Journal, error)
Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error)
}
type IDGenerator func() (string, error)
type Service struct {
repository Repository
newID IDGenerator
}
func NewService(repository Repository, newID IDGenerator) *Service {
if newID == nil {
newID = newUUID
}
return &Service{repository: repository, newID: newID}
}
type EntryCommand struct {
LineNumber uint32
Account domain.AccountReference
Amount string
Description string
}
type AppendJournalCommand struct {
SourceService string
IdempotencyKey string
SourceTransactionID string
TrackingCode string
EffectKind string
EventVersion uint32
Entries []EntryCommand
ReversalOfJournalID string
OccurredAt time.Time
CorrelationID string
ActorID string
Blockchain domain.BlockchainReference
Metadata map[string]string
}
type AppendJournalResult struct {
Journal domain.Journal
AlreadyExisted bool
}
func (s *Service) AppendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
if _, reserved := command.Metadata[domain.InternalExecutionMetadata]; reserved {
return AppendJournalResult{}, invalid("gl_execution is reserved for internal execution")
}
return s.appendJournal(ctx, command)
}
func (s *Service) appendJournal(ctx context.Context, command AppendJournalCommand) (AppendJournalResult, error) {
journalID, err := s.newID()
if err != nil {
return AppendJournalResult{}, fmt.Errorf("generate journal id: %w", err)
}
journal := domain.Journal{
ID: journalID,
SourceService: command.SourceService,
IdempotencyKey: command.IdempotencyKey,
SourceTransactionID: command.SourceTransactionID,
TrackingCode: command.TrackingCode,
EffectKind: command.EffectKind,
EventVersion: command.EventVersion,
ReversalOfJournalID: command.ReversalOfJournalID,
OccurredAt: command.OccurredAt,
CorrelationID: command.CorrelationID,
ActorID: command.ActorID,
Blockchain: command.Blockchain,
Metadata: cloneMetadata(command.Metadata),
Entries: make([]domain.Entry, 0, len(command.Entries)),
}
for _, input := range command.Entries {
amount, parseErr := domain.ParseAmount(input.Amount)
if parseErr != nil {
return AppendJournalResult{}, invalid("entry %d amount: %v", input.LineNumber, parseErr)
}
journal.Entries = append(journal.Entries, domain.Entry{
LineNumber: input.LineNumber,
Account: input.Account,
Amount: amount,
Description: input.Description,
})
}
sort.Slice(journal.Entries, func(i, j int) bool {
return journal.Entries[i].LineNumber < journal.Entries[j].LineNumber
})
journal.PayloadHash, err = journalHash(journal)
if err != nil {
return AppendJournalResult{}, fmt.Errorf("hash journal: %w", err)
}
if err := journal.Validate(); err != nil {
return AppendJournalResult{}, invalid("%v", err)
}
if journal.ReversalOfJournalID != "" {
original, getErr := s.repository.GetByID(ctx, journal.ReversalOfJournalID)
if getErr != nil {
return AppendJournalResult{}, fmt.Errorf("read reversal target: %w", getErr)
}
if err := validateFullReversal(original, journal); err != nil {
return AppendJournalResult{}, invalid("%v", err)
}
}
appendResult, err := s.repository.Append(ctx, journal)
if err != nil {
return AppendJournalResult{}, err
}
stored, err := s.repository.GetByID(ctx, appendResult.JournalID)
if err != nil {
return AppendJournalResult{}, fmt.Errorf("read appended journal: %w", err)
}
return AppendJournalResult{Journal: stored, AlreadyExisted: appendResult.AlreadyExists}, nil
}
type AppendEventCommand struct {
SourceService string
IdempotencyKey string
SourceTransactionID string
TrackingCode string
EventVersion uint32
State domain.TransactionState
ErrorCode string
ErrorMessage string
OccurredAt time.Time
CorrelationID string
ActorID string
Blockchain domain.BlockchainReference
Metadata map[string]string
}
func (s *Service) AppendEvent(ctx context.Context, command AppendEventCommand) (domain.TransactionEvent, bool, error) {
eventID, err := s.newID()
if err != nil {
return domain.TransactionEvent{}, false, fmt.Errorf("generate event id: %w", err)
}
event := domain.TransactionEvent{
ID: eventID,
SourceService: command.SourceService,
IdempotencyKey: command.IdempotencyKey,
SourceTransactionID: command.SourceTransactionID,
TrackingCode: command.TrackingCode,
EventVersion: command.EventVersion,
State: command.State,
ErrorCode: command.ErrorCode,
ErrorMessage: command.ErrorMessage,
OccurredAt: command.OccurredAt,
CorrelationID: command.CorrelationID,
ActorID: command.ActorID,
Blockchain: command.Blockchain,
Metadata: cloneMetadata(command.Metadata),
}
event.PayloadHash, err = eventHash(event)
if err != nil {
return domain.TransactionEvent{}, false, fmt.Errorf("hash transaction event: %w", err)
}
if err := event.Validate(); err != nil {
return domain.TransactionEvent{}, false, invalid("%v", err)
}
return s.repository.AppendEvent(ctx, event)
}
func (s *Service) GetJournal(ctx context.Context, journalID, idempotencyKey string) (domain.Journal, error) {
if (journalID == "") == (idempotencyKey == "") {
return domain.Journal{}, invalid("exactly one journal lookup is required")
}
if journalID != "" {
return s.repository.GetByID(ctx, journalID)
}
return s.repository.GetByIdempotencyKey(ctx, idempotencyKey)
}
type ListCommand struct {
Account *domain.AccountReference
AssetID *int64
RecordedFrom *time.Time
RecordedTo *time.Time
PageSize uint32
PageToken string
}
func (s *Service) List(ctx context.Context, command ListCommand) ([]domain.Journal, string, error) {
if command.Account != nil {
if err := command.Account.Validate(); err != nil {
return nil, "", invalid("account: %v", err)
}
}
if command.AssetID != nil && *command.AssetID <= 0 {
return nil, "", invalid("asset id must be positive")
}
pageSize := int(command.PageSize)
if pageSize == 0 {
pageSize = 50
}
if pageSize > 200 {
return nil, "", invalid("page size must not exceed 200")
}
offset, err := decodePageToken(command.PageToken)
if err != nil {
return nil, "", invalid("page token: %v", err)
}
journals, err := s.repository.List(ctx, domain.JournalFilter{
Account: command.Account,
AssetID: command.AssetID,
RecordedFrom: command.RecordedFrom,
RecordedTo: command.RecordedTo,
Limit: pageSize + 1,
Offset: offset,
})
if err != nil {
return nil, "", err
}
next := ""
if len(journals) > pageSize {
journals = journals[:pageSize]
next = encodePageToken(offset + pageSize)
}
return journals, next, nil
}
func (s *Service) Balance(ctx context.Context, account domain.AccountReference, asOf time.Time) (domain.Amount, error) {
if err := account.Validate(); err != nil {
return domain.Amount{}, invalid("account: %v", err)
}
return s.repository.Balance(ctx, account, asOf)
}
func (s *Service) Replay(ctx context.Context, commands []AppendJournalCommand) ([]AppendJournalResult, error) {
if len(commands) == 0 || len(commands) > 200 {
return nil, invalid("replay batch must contain between 1 and 200 journals")
}
results := make([]AppendJournalResult, 0, len(commands))
for _, command := range commands {
result, err := s.AppendJournal(ctx, command)
if err != nil {
return nil, err
}
results = append(results, result)
}
return results, nil
}
func validateFullReversal(original, reversal domain.Journal) error {
if len(original.Entries) != len(reversal.Entries) {
return fmt.Errorf("reversal must contain every original entry")
}
expected := make(map[string]int, len(original.Entries))
for _, entry := range original.Entries {
expected[entryKey(entry.Account, entry.Amount.Negate())]++
}
for _, entry := range reversal.Entries {
key := entryKey(entry.Account, entry.Amount)
if expected[key] == 0 {
return fmt.Errorf("reversal entries must exactly negate the original journal")
}
expected[key]--
}
return nil
}
func entryKey(account domain.AccountReference, amount domain.Amount) string {
return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s", account.Class, account.OwnerType, account.OwnerID, account.AssetID, amount.String())
}
func journalHash(journal domain.Journal) (string, error) {
entries := make([]map[string]any, 0, len(journal.Entries))
for _, entry := range journal.Entries {
entries = append(entries, map[string]any{
"line_number": entry.LineNumber,
"class": entry.Account.Class,
"owner_type": entry.Account.OwnerType,
"owner_id": entry.Account.OwnerID,
"asset_id": entry.Account.AssetID,
"amount": entry.Amount.String(),
"description": entry.Description,
})
}
return hashPayload(map[string]any{
"source_service": journal.SourceService,
"idempotency_key": journal.IdempotencyKey,
"source_transaction_id": journal.SourceTransactionID,
"tracking_code": journal.TrackingCode,
"effect_kind": journal.EffectKind,
"event_version": journal.EventVersion,
"entries": entries,
"reversal_of": journal.ReversalOfJournalID,
"occurred_at": journal.OccurredAt.UTC().Format(time.RFC3339Nano),
"correlation_id": journal.CorrelationID,
"actor_id": journal.ActorID,
"blockchain": journal.Blockchain,
"metadata": journal.Metadata,
})
}
func eventHash(event domain.TransactionEvent) (string, error) {
return hashPayload(map[string]any{
"source_service": event.SourceService,
"idempotency_key": event.IdempotencyKey,
"source_transaction_id": event.SourceTransactionID,
"tracking_code": event.TrackingCode,
"event_version": event.EventVersion,
"state": event.State,
"error_code": event.ErrorCode,
"error_message": event.ErrorMessage,
"occurred_at": event.OccurredAt.UTC().Format(time.RFC3339Nano),
"correlation_id": event.CorrelationID,
"actor_id": event.ActorID,
"blockchain": event.Blockchain,
"metadata": event.Metadata,
})
}
func hashPayload(payload any) (string, error) {
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
hash := sha256.Sum256(encoded)
return hex.EncodeToString(hash[:]), nil
}
func newUUID() (string, error) {
value := make([]byte, 16)
if _, err := rand.Read(value); err != nil {
return "", err
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(value)
return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil
}
func cloneMetadata(metadata map[string]string) map[string]string {
clone := make(map[string]string, len(metadata))
for key, value := range metadata {
clone[key] = value
}
return clone
}
func invalid(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidArgument, fmt.Sprintf(format, args...))
}
func encodePageToken(offset int) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset)))
}
func decodePageToken(token string) (int, error) {
if token == "" {
return 0, nil
}
decoded, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return 0, err
}
offset, err := strconv.Atoi(string(decoded))
if err != nil || offset < 0 {
return 0, fmt.Errorf("invalid offset")
}
return offset, nil
}
+223
View File
@@ -0,0 +1,223 @@
package ledger
import (
"context"
"errors"
"strings"
"testing"
"time"
domain "gl/domain/ledger"
)
type repositoryStub struct {
journals map[string]domain.Journal
byKey map[string]string
events map[string]domain.TransactionEvent
appendCalls int
list []domain.Journal
balance domain.Amount
lastAttempt domain.Journal
}
func newRepositoryStub() *repositoryStub {
return &repositoryStub{
journals: make(map[string]domain.Journal),
byKey: make(map[string]string),
events: make(map[string]domain.TransactionEvent),
}
}
func (r *repositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) {
r.appendCalls++
r.lastAttempt = journal
if existingID, ok := r.byKey[journal.IdempotencyKey]; ok {
return domain.AppendResult{JournalID: existingID, AlreadyExists: true}, nil
}
journal.RecordedAt = time.Unix(2, 0).UTC()
r.journals[journal.ID] = journal
r.byKey[journal.IdempotencyKey] = journal.ID
return domain.AppendResult{JournalID: journal.ID}, nil
}
func (r *repositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) {
if existing, ok := r.events[event.IdempotencyKey]; ok {
return existing, true, nil
}
event.RecordedAt = time.Unix(2, 0).UTC()
r.events[event.IdempotencyKey] = event
return event, false, nil
}
func (r *repositoryStub) GetByID(_ context.Context, id string) (domain.Journal, error) {
journal, ok := r.journals[id]
if !ok {
return domain.Journal{}, errors.New("not found")
}
return journal, nil
}
func (r *repositoryStub) GetByIdempotencyKey(_ context.Context, key string) (domain.Journal, error) {
id, ok := r.byKey[key]
if !ok {
return domain.Journal{}, errors.New("not found")
}
return r.journals[id], nil
}
func (r *repositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) {
return append([]domain.Journal(nil), r.list...), nil
}
func (r *repositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) {
return r.balance, nil
}
func TestAppendJournalIsBalancedCanonicalAndIdempotent(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := validAppendCommand()
first, err := service.AppendJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
command.Entries[0], command.Entries[1] = command.Entries[1], command.Entries[0]
second, err := service.AppendJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if first.AlreadyExisted || !second.AlreadyExisted {
t.Fatalf("unexpected idempotency results: first=%+v second=%+v", first, second)
}
if first.Journal.PayloadHash == "" || first.Journal.PayloadHash != second.Journal.PayloadHash {
t.Fatalf("unexpected payload hashes: %q %q", first.Journal.PayloadHash, second.Journal.PayloadHash)
}
if repository.lastAttempt.PayloadHash != first.Journal.PayloadHash {
t.Fatal("entry order changed the canonical payload hash")
}
if got := first.Journal.Entries[0].Amount.String(); got != "-10.25" {
t.Fatalf("amount was not canonical: %s", got)
}
}
func TestAppendJournalRejectsInvalidAmountBeforeRepository(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := validAppendCommand()
command.Entries[0].Amount = "NaN"
_, err := service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected invalid argument, got %v", err)
}
if repository.appendCalls != 0 {
t.Fatal("invalid journal reached repository")
}
}
func TestAppendJournalRequiresExactFullReversal(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("22222222-2222-4222-8222-222222222222"))
originalCommand := validAppendCommand()
original, err := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")).AppendJournal(context.Background(), originalCommand)
if err != nil {
t.Fatal(err)
}
reversal := validAppendCommand()
reversal.IdempotencyKey = "wallet:1:internal-transfer-reversal:v1"
reversal.EffectKind = "internal-transfer-reversal"
reversal.ReversalOfJournalID = original.Journal.ID
reversal.Entries[0].Amount = "10.25"
reversal.Entries[1].Amount = "-10.25"
if _, err := service.AppendJournal(context.Background(), reversal); err != nil {
t.Fatalf("valid reversal failed: %v", err)
}
reversal.IdempotencyKey = "wallet:1:bad-reversal:v1"
reversal.Entries[0].Account.OwnerID = "different-user"
if _, err := service.AppendJournal(context.Background(), reversal); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected invalid reversal, got %v", err)
}
}
func TestAppendEventIsIdempotent(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := AppendEventCommand{
SourceService: "wallet",
IdempotencyKey: "wallet:1:status:v1",
SourceTransactionID: "1",
EventVersion: 1,
State: domain.TransactionStateCreated,
OccurredAt: time.Unix(1, 0).UTC(),
}
first, existed, err := service.AppendEvent(context.Background(), command)
if err != nil || existed {
t.Fatalf("first append: existed=%v err=%v", existed, err)
}
second, existed, err := service.AppendEvent(context.Background(), command)
if err != nil || !existed || first.PayloadHash != second.PayloadHash {
t.Fatalf("second append: existed=%v err=%v first=%+v second=%+v", existed, err, first, second)
}
}
func TestListUsesOpaquePageToken(t *testing.T) {
repository := newRepositoryStub()
repository.list = make([]domain.Journal, 3)
service := NewService(repository, nil)
journals, next, err := service.List(context.Background(), ListCommand{PageSize: 2})
if err != nil {
t.Fatal(err)
}
if len(journals) != 2 || next == "" {
t.Fatalf("unexpected page: len=%d next=%q", len(journals), next)
}
if _, err := decodePageToken(next); err != nil {
t.Fatalf("invalid generated page token: %v", err)
}
}
func TestNewUUIDProducesRFC4122Shape(t *testing.T) {
id, err := newUUID()
if err != nil {
t.Fatal(err)
}
if len(id) != 36 || id[14] != '4' || !strings.Contains("89ab", string(id[19])) {
t.Fatalf("unexpected UUID: %q", id)
}
}
func validAppendCommand() AppendJournalCommand {
return AppendJournalCommand{
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
Entries: []EntryCommand{
{
LineNumber: 1,
Account: domain.AccountReference{
Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5,
},
Amount: "-10.2500",
},
{
LineNumber: 2,
Account: domain.AccountReference{
Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5,
},
Amount: "10.25",
},
},
}
}
func fixedID(id string) IDGenerator {
return func() (string, error) { return id, nil }
}
+85
View File
@@ -0,0 +1,85 @@
package reconciliation
import (
"context"
"fmt"
"gl/domain/ledger"
)
// Evidence is a normalized record supplied by Wallet, Kuknos, or another
// authoritative settlement source. Adapters remain outside the GL domain.
type Evidence struct {
SourceService string
SourceTransactionID string
EventVersion uint32
BlockchainNetwork string
TransactionHash string
}
type EvidenceReport struct {
Compared int
Missing []Evidence
Duplicates []Evidence
Mismatched []EvidenceMismatch
}
type EvidenceMismatch struct {
Evidence Evidence
JournalID string
Field string
Expected string
Actual string
}
// CompareEvidence compares normalized external evidence with immutable GL
// journals. It is read-only and safe to run repeatedly after outages.
func CompareEvidence(ctx context.Context, repository Repository, evidence []Evidence, pageSize int) (EvidenceReport, error) {
if repository == nil {
return EvidenceReport{}, fmt.Errorf("reconciliation repository is required")
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 100
}
journals := make(map[string]ledger.Journal)
for offset := 0; ; offset += pageSize {
page, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
if err != nil {
return EvidenceReport{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
}
for _, journal := range page {
journals[evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)] = journal
}
if len(page) < pageSize {
break
}
}
report := EvidenceReport{Missing: make([]Evidence, 0), Duplicates: make([]Evidence, 0), Mismatched: make([]EvidenceMismatch, 0)}
seen := make(map[string]struct{}, len(evidence))
for _, item := range evidence {
report.Compared++
key := evidenceKey(item.SourceService, item.SourceTransactionID, item.EventVersion)
if _, exists := seen[key]; exists {
report.Duplicates = append(report.Duplicates, item)
continue
}
seen[key] = struct{}{}
journal, exists := journals[key]
if !exists {
report.Missing = append(report.Missing, item)
continue
}
if item.BlockchainNetwork != "" && item.BlockchainNetwork != journal.Blockchain.Network {
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "blockchain_network", Expected: item.BlockchainNetwork, Actual: journal.Blockchain.Network})
}
if item.TransactionHash != "" && item.TransactionHash != journal.Blockchain.TransactionHash {
report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "transaction_hash", Expected: item.TransactionHash, Actual: journal.Blockchain.TransactionHash})
}
}
return report, nil
}
func evidenceKey(sourceService, sourceTransactionID string, eventVersion uint32) string {
return sourceService + "\x00" + sourceTransactionID + "\x00" + fmt.Sprint(eventVersion)
}
@@ -0,0 +1,39 @@
package reconciliation
import (
"context"
"testing"
"gl/domain/ledger"
)
func TestCompareEvidenceReportsMissingDuplicateAndBlockchainMismatch(t *testing.T) {
journal := validJournal()
journal.Blockchain = ledger.BlockchainReference{Network: "kuknos", TransactionHash: "hash-1"}
matching := Evidence{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "hash-1"}
evidence := []Evidence{
matching,
matching,
{SourceService: "wallet", SourceTransactionID: "missing", EventVersion: 1},
{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "wrong"},
}
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, evidence, 10)
if err != nil {
t.Fatal(err)
}
if report.Compared != 4 || len(report.Missing) != 1 || len(report.Duplicates) != 2 || len(report.Mismatched) != 0 {
t.Fatalf("unexpected report: %+v", report)
}
}
func TestCompareEvidenceReportsHashMismatch(t *testing.T) {
journal := validJournal()
journal.Blockchain.TransactionHash = "actual"
report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, []Evidence{{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, TransactionHash: "expected"}}, 10)
if err != nil {
t.Fatal(err)
}
if len(report.Mismatched) != 1 || report.Mismatched[0].Field != "transaction_hash" {
t.Fatalf("unexpected report: %+v", report)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package reconciliation provides read-only integrity checks for the GL.
package reconciliation
import (
"context"
"fmt"
"gl/domain/ledger"
)
type Repository interface {
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
}
type Report struct {
Scanned int
Valid int
Invalid []Issue
}
type Issue struct {
JournalID string
Error string
}
// Run scans all posted journals without mutating the ledger.
func Run(ctx context.Context, repository Repository, pageSize int) (Report, error) {
if repository == nil {
return Report{}, fmt.Errorf("reconciliation repository is required")
}
if pageSize <= 0 || pageSize > 200 {
pageSize = 100
}
report := Report{Invalid: make([]Issue, 0)}
seen := make(map[string]string)
for offset := 0; ; offset += pageSize {
journals, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
if err != nil {
return Report{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
}
for _, journal := range journals {
report.Scanned++
if err := journal.Validate(); err != nil {
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()})
continue
}
key := evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)
if previous, exists := seen[key]; exists {
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)})
continue
}
seen[key] = journal.ID
report.Valid++
}
if len(journals) < pageSize {
break
}
}
return report, nil
}
@@ -0,0 +1,56 @@
package reconciliation
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
type repositoryStub struct {
pages [][]ledger.Journal
}
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
index := filter.Offset / filter.Limit
if index >= len(r.pages) {
return nil, nil
}
return r.pages[index], nil
}
func validJournal() ledger.Journal {
return ledger.Journal{ID: "j1", SourceService: "wallet", IdempotencyKey: "k1", SourceTransactionID: "t1", EffectKind: "transfer", EventVersion: 1, OccurredAt: ledgerTestTime(), PayloadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Entries: []ledger.Entry{{LineNumber: 1, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("-1")}, {LineNumber: 2, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("1")}}}
}
func ledgerTestTime() (t time.Time) { return time.Unix(1, 0).UTC() }
func mustAmount(value string) ledger.Amount { amount, _ := ledger.ParseAmount(value); return amount }
func TestRunScansAndReportsInvalidJournals(t *testing.T) {
valid := validJournal()
invalid := valid
invalid.Entries = append([]ledger.Entry(nil), valid.Entries...)
invalid.ID = "bad"
invalid.Entries[1].Amount = mustAmount("2")
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{valid}, {invalid}}}, 1)
if err != nil {
t.Fatal(err)
}
if report.Scanned != 2 || report.Valid != 1 || len(report.Invalid) != 1 || report.Invalid[0].JournalID != "bad" {
t.Fatalf("unexpected report: %+v", report)
}
}
func TestRunReportsDuplicateTransactionVersions(t *testing.T) {
first := validJournal()
second := validJournal()
second.ID = "j2"
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{first, second}}}, 10)
if err != nil {
t.Fatal(err)
}
if report.Valid != 1 || len(report.Invalid) != 1 {
t.Fatalf("unexpected report: %+v", report)
}
}
+72
View File
@@ -0,0 +1,72 @@
package settlement
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
"time"
"gl/domain/ledger"
)
type EnqueueRepository interface {
Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error)
Get(context.Context, string, string) (ledger.Settlement, error)
RetryByOperator(context.Context, string, string, string, time.Time) (ledger.Settlement, error)
IgnoreByOperator(context.Context, string, string, string) (ledger.Settlement, error)
}
func (s *Service) IgnoreByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
settlementID, actorID, reason = strings.TrimSpace(settlementID), strings.TrimSpace(actorID), strings.TrimSpace(reason)
if settlementID == "" || actorID == "" || reason == "" || len([]rune(reason)) > 500 {
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and a reason of at most 500 characters are required")
}
return s.repository.IgnoreByOperator(ctx, settlementID, actorID, reason)
}
func (s *Service) RetryByOperator(ctx context.Context, settlementID, actorID, reason string) (ledger.Settlement, error) {
if settlementID == "" || actorID == "" || reason == "" {
return ledger.Settlement{}, fmt.Errorf("settlement id, actor id, and reason are required")
}
now := time.Now().UTC()
if s.now != nil {
now = s.now().UTC()
}
return s.repository.RetryByOperator(ctx, settlementID, actorID, reason, now)
}
type Service struct {
repository EnqueueRepository
now func() time.Time
}
func NewService(repository EnqueueRepository) *Service { return &Service{repository: repository} }
func (s *Service) Enqueue(ctx context.Context, sourceService, sourceTxID, idempotencyKey, network, xdr string) (ledger.Settlement, bool, error) {
if sourceService == "" || sourceTxID == "" || idempotencyKey == "" || network == "" || xdr == "" {
return ledger.Settlement{}, false, fmt.Errorf("settlement identity, network, and signed transaction are required")
}
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
return ledger.Settlement{}, false, fmt.Errorf("generate settlement id: %w", err)
}
now := time.Now().UTC()
if s.now != nil {
now = s.now().UTC()
}
encoded := hex.EncodeToString(id)
record := ledger.Settlement{ID: encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], SourceService: sourceService, SourceTxID: sourceTxID, IdempotencyKey: idempotencyKey, Status: ledger.SettlementPending, Network: network, SignedTransactionXDR: xdr, AvailableAt: now}
if err := record.Validate(); err != nil {
return ledger.Settlement{}, false, err
}
return s.repository.Enqueue(ctx, record)
}
func (s *Service) Get(ctx context.Context, settlementID, idempotencyKey string) (ledger.Settlement, error) {
if settlementID == "" && idempotencyKey == "" {
return ledger.Settlement{}, fmt.Errorf("settlement id or idempotency key is required")
}
return s.repository.Get(ctx, settlementID, idempotencyKey)
}
+65
View File
@@ -0,0 +1,65 @@
package settlement
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
type serviceRepository struct {
retriedID, actor, reason string
}
func (*serviceRepository) Enqueue(context.Context, ledger.Settlement) (ledger.Settlement, bool, error) {
return ledger.Settlement{}, false, nil
}
func (*serviceRepository) Get(context.Context, string, string) (ledger.Settlement, error) {
return ledger.Settlement{}, nil
}
func (r *serviceRepository) RetryByOperator(_ context.Context, id, actor, reason string, _ time.Time) (ledger.Settlement, error) {
r.retriedID, r.actor, r.reason = id, actor, reason
return ledger.Settlement{ID: id, Status: ledger.SettlementRetryable}, nil
}
func (r *serviceRepository) IgnoreByOperator(_ context.Context, id, actor, reason string) (ledger.Settlement, error) {
r.retriedID, r.actor, r.reason = id, actor, reason
return ledger.Settlement{ID: id, Status: ledger.SettlementIgnored}, nil
}
func TestIgnoreByOperatorRequiresReviewedAuditIdentity(t *testing.T) {
repository := &serviceRepository{}
service := NewService(repository)
for _, reason := range []string{"", " "} {
if _, err := service.IgnoreByOperator(context.Background(), "id", "actor", reason); err == nil {
t.Fatal("expected blank review reason to be rejected")
}
}
if repository.retriedID != "" {
t.Fatal("invalid dismissal reached repository")
}
got, err := service.IgnoreByOperator(context.Background(), "id", "actor-7", " cannot complete ")
if err != nil || got.Status != ledger.SettlementIgnored || repository.actor != "actor-7" || repository.reason != "cannot complete" {
t.Fatalf("unexpected dismissal: %+v repo=%+v err=%v", got, repository, err)
}
}
func TestRetryByOperatorRequiresCompleteAuditIdentity(t *testing.T) {
service := NewService(&serviceRepository{})
if _, err := service.RetryByOperator(context.Background(), "id", "actor", ""); err == nil {
t.Fatal("expected missing reason to be rejected")
}
}
func TestRetryByOperatorPassesActorAndReasonToRepository(t *testing.T) {
repository := &serviceRepository{}
service := NewService(repository)
got, err := service.RetryByOperator(context.Background(), "id", "actor-7", "Horizon outage resolved")
if err != nil {
t.Fatal(err)
}
if got.Status != ledger.SettlementRetryable || repository.retriedID != "id" || repository.actor != "actor-7" || repository.reason != "Horizon outage resolved" {
t.Fatalf("unexpected retry: %+v repo=%+v", got, repository)
}
}
+64
View File
@@ -0,0 +1,64 @@
package settlement
import (
"context"
"errors"
"fmt"
"time"
"gl/domain/ledger"
)
type Repository interface {
ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error)
Save(context.Context, ledger.Settlement, string) error
}
type Submitter interface {
Submit(context.Context, ledger.Settlement) (string, error)
}
type Worker struct {
Repository Repository
Submitter Submitter
WorkerID string
MaxAttempts int
Now func() time.Time
}
func (w Worker) RunOnce(ctx context.Context, limit int) error {
if w.Repository == nil || w.Submitter == nil || w.WorkerID == "" || limit <= 0 {
return fmt.Errorf("settlement worker is not configured")
}
now := time.Now().UTC()
if w.Now != nil {
now = w.Now().UTC()
}
records, err := w.Repository.ClaimDue(ctx, w.WorkerID, limit, now)
if err != nil {
return err
}
for _, record := range records {
record.Attempts++
hash, submitErr := w.Submitter.Submit(ctx, record)
if submitErr != nil {
var permanent ledger.PermanentSubmissionError
if errors.As(submitErr, &permanent) {
record.Status = ledger.SettlementManualReview
record.LastError = submitErr.Error()
} else {
record = record.Retry(now, w.MaxAttempts, submitErr)
}
} else {
// Horizon returns success only after the transaction is accepted into
// a ledger, so a successful response is already confirmation.
record.Status = ledger.SettlementConfirmed
record.TransactionHash = hash
record.LastError = ""
}
if err := w.Repository.Save(ctx, record, w.WorkerID); err != nil {
return err
}
}
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package settlement
import (
"context"
"errors"
"testing"
"time"
"gl/domain/ledger"
)
type memoryRepository struct{ record ledger.Settlement }
func (m *memoryRepository) ClaimDue(context.Context, string, int, time.Time) ([]ledger.Settlement, error) {
return []ledger.Settlement{m.record}, nil
}
func (m *memoryRepository) Save(_ context.Context, record ledger.Settlement, _ string) error {
m.record = record
return nil
}
type submitter struct {
hash string
err error
}
func (s submitter) Submit(context.Context, ledger.Settlement) (string, error) { return s.hash, s.err }
func TestWorkerPersistsSubmission(t *testing.T) {
repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}}
worker := Worker{Repository: repository, Submitter: submitter{hash: "hash-42"}, WorkerID: "worker-1", Now: func() time.Time { return time.Unix(2, 0) }}
if err := worker.RunOnce(context.Background(), 1); err != nil {
t.Fatal(err)
}
if repository.record.Status != ledger.SettlementConfirmed || repository.record.TransactionHash != "hash-42" || repository.record.Attempts != 1 {
t.Fatalf("unexpected settlement: %+v", repository.record)
}
}
func TestWorkerSchedulesRetryAfterFirstFailedAttempt(t *testing.T) {
repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementPending, AvailableAt: time.Unix(1, 0)}}
worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }}
if err := worker.RunOnce(context.Background(), 1); err != nil {
t.Fatal(err)
}
if repository.record.Status != ledger.SettlementRetryable || repository.record.Attempts != 1 || !repository.record.AvailableAt.Equal(time.Unix(3, 0)) {
t.Fatalf("unexpected settlement: %+v", repository.record)
}
}
func TestWorkerMovesExhaustedSubmissionToManualReview(t *testing.T) {
repository := &memoryRepository{record: ledger.Settlement{ID: "s1", SourceService: "wallet", SourceTxID: "42", IdempotencyKey: "kuknos:42", Status: ledger.SettlementRetryable, Attempts: 2, AvailableAt: time.Unix(1, 0)}}
worker := Worker{Repository: repository, Submitter: submitter{err: errors.New("timeout")}, WorkerID: "worker-1", MaxAttempts: 3, Now: func() time.Time { return time.Unix(2, 0) }}
if err := worker.RunOnce(context.Background(), 1); err != nil {
t.Fatal(err)
}
if repository.record.Status != ledger.SettlementManualReview {
t.Fatalf("status=%s", repository.record.Status)
}
}
+16
View File
@@ -0,0 +1,16 @@
version: v2
clean: true
managed:
enabled: true
override:
- file_option: go_package_prefix
value: gl/gen
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt:
- paths=source_relative
- require_unimplemented_servers=false
+165
View File
@@ -0,0 +1,165 @@
// Command dashboard-loadtest runs a concurrent read-only HTTP load test against
// a deployed GL dashboard.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
type result struct {
Duration time.Duration
Requests int64
Errors int64
Non2xx int64
Latency []time.Duration
}
func main() {
baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL")
paths := flag.String("paths", "/", "comma-separated request paths")
concurrency := flag.Int("concurrency", 20, "number of concurrent workers")
duration := flag.Duration("duration", 15*time.Second, "test duration")
requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request")
flag.Parse()
if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 {
fmt.Fprintln(os.Stderr, "concurrency and durations must be positive")
os.Exit(2)
}
targets, err := targets(*baseURL, *paths)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = *concurrency
transport.MaxIdleConnsPerHost = *concurrency
client := &http.Client{Transport: transport, Timeout: *requestTimeout}
defer transport.CloseIdleConnections()
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(signalCtx, *duration)
defer cancel()
fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL)
started := time.Now()
result := run(ctx, client, targets, *concurrency)
result.Duration = time.Since(started)
printResult(result)
if result.Errors > 0 || result.Non2xx > 0 {
os.Exit(1)
}
}
func targets(baseURL, pathList string) ([]string, error) {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return nil, fmt.Errorf("invalid dashboard URL %q", baseURL)
}
paths := strings.Split(pathList, ",")
result := make([]string, 0, len(paths))
for _, value := range paths {
value = strings.TrimSpace(value)
if value == "" {
continue
}
reference, parseErr := url.Parse(value)
if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") {
return nil, fmt.Errorf("invalid request path %q", value)
}
result = append(result, base.ResolveReference(reference).String())
}
if len(result) == 0 {
return nil, errors.New("at least one request path is required")
}
return result, nil
}
func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result {
var (
requests int64
failures int64
non2xx int64
sequence uint64
latency = make([]time.Duration, 0, concurrency*100)
lock sync.Mutex
workers sync.WaitGroup
)
workers.Add(concurrency)
for range concurrency {
go func() {
defer workers.Done()
for ctx.Err() == nil {
target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))]
request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil)
if err != nil {
atomic.AddInt64(&failures, 1)
continue
}
started := time.Now()
response, err := client.Do(request)
elapsed := time.Since(started)
if err != nil {
atomic.AddInt64(&requests, 1)
atomic.AddInt64(&failures, 1)
continue
}
_, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20))
closeErr := response.Body.Close()
atomic.AddInt64(&requests, 1)
if response.StatusCode < 200 || response.StatusCode >= 300 {
atomic.AddInt64(&non2xx, 1)
}
if copyErr != nil || closeErr != nil {
atomic.AddInt64(&failures, 1)
}
lock.Lock()
latency = append(latency, elapsed)
lock.Unlock()
}
}()
}
workers.Wait()
return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency}
}
func printResult(value result) {
seconds := value.Duration.Seconds()
requestsPerSecond := float64(value.Requests)
if seconds > 0 {
requestsPerSecond /= seconds
}
fmt.Printf("requests: %d\n", value.Requests)
fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond)
fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99))
fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx)
}
func percentile(values []time.Duration, percent int) time.Duration {
if len(values) == 0 {
return 0
}
ordered := append([]time.Duration(nil), values...)
sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] })
index := (len(ordered)*percent + 99) / 100
if index < 1 {
index = 1
}
return ordered[index-1]
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"context"
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) {
server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte("ok"))
}))
defer server.Close()
requestTargets, err := targets(server.URL, "/,/transactions")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
result := run(ctx, server.Client(), requestTargets, 4)
if result.Requests == 0 || result.Errors != 0 || result.Non2xx != 0 {
t.Fatalf("unexpected load result: %+v", result)
}
if len(result.Latency) != int(result.Requests) {
t.Fatalf("expected one latency per request, got %d for %d", len(result.Latency), result.Requests)
}
}
func TestTargetsRejectsExternalAndRelativePaths(t *testing.T) {
for _, paths := range []string{"relative", "https://example.com/"} {
if _, err := targets("http://localhost:8080", paths); err == nil {
t.Fatalf("expected %q to be rejected", paths)
}
}
}
func TestPercentileUsesNearestRank(t *testing.T) {
values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond}
if got := percentile(values, 95); got != 4*time.Millisecond {
t.Fatalf("unexpected p95: %s", got)
}
}
func TestRunDrainsInflightRequestAfterDuration(t *testing.T) {
var canceled atomic.Bool
server := newIPv4TestServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
time.Sleep(20 * time.Millisecond)
if request.Context().Err() != nil {
canceled.Store(true)
}
response.WriteHeader(http.StatusOK)
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
result := run(ctx, server.Client(), []string{server.URL}, 1)
if result.Requests != 1 || result.Errors != 0 || canceled.Load() {
t.Fatalf("in-flight request was not drained cleanly: %+v", result)
}
}
func newIPv4TestServer(handler http.Handler) *httptest.Server {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
panic(err)
}
server := httptest.NewUnstartedServer(handler)
server.Listener = listener
server.Start()
return server
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"gl/application/explorer"
"gl/infrastructure/config"
"gl/infrastructure/observability"
"gl/infrastructure/postgres"
webadapter "gl/interface/web"
)
func main() {
observability.Configure("gl-dashboard", slog.LevelInfo)
configPath := flag.String("conf", "./dashboard.cfg.toml", "path to the dashboard TOML configuration file")
flag.Parse()
cfg, err := config.LoadDashboard(*configPath)
if err != nil {
slog.Error("load dashboard configuration", "error", err)
os.Exit(1)
}
shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl-dashboard", cfg.Environment)
defer shutdownTelemetry(context.Background())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := postgres.Open(ctx, cfg.Database)
if err != nil {
slog.Error("open GL database", "error", err)
os.Exit(1)
}
defer database.Close()
if err := database.Ping(ctx); err != nil {
slog.Error("connect to GL database", "error", err)
os.Exit(1)
}
repository := postgres.NewJournalRepository(database)
handler := webadapter.NewHandler(explorer.NewService(repository))
serverConfig := webadapter.ServerConfig{
Host: cfg.HTTP.Host,
Port: cfg.HTTP.Port,
ReadHeaderTimeout: cfg.HTTP.ReadHeaderTimeout,
ShutdownTimeout: cfg.HTTP.ShutdownTimeout,
}
slog.Info("starting GL explorer", "host", cfg.HTTP.Host, "port", cfg.HTTP.Port)
if err := webadapter.Run(ctx, serverConfig, handler); err != nil {
slog.Error("GL explorer stopped", "error", err)
os.Exit(1)
}
}
+22
View File
@@ -0,0 +1,22 @@
# GL load test
This command exercises the write and read paths of the gRPC ledger service.
Each simulated transaction:
1. appends a `CREATED` lifecycle event;
2. posts a balanced transfer between two generated user wallet accounts;
3. reads the committed journal;
4. rebuilds the receiving wallet balance;
5. appends a `SUCCESSFUL` lifecycle event.
Ledger accounts are created implicitly by `AppendJournal`; GL does not expose a
separate wallet-creation RPC.
Start GL, then run:
```bash
go run ./cmd/gl-loadtest -duration 30s -concurrency 20 -wallets 1000
```
Use a fresh `-run-id` for every dataset. The command generates one automatically
when the flag is omitted.
+323
View File
@@ -0,0 +1,323 @@
// Command gl-loadtest runs a stateful gRPC load test against the GL service.
// Wallet accounts are created implicitly by the first journal that references
// each generated owner ID, matching normal GL behavior.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
domain "gl/domain/ledger"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb"
)
type client interface {
AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error)
AppendTransactionEvent(context.Context, *ledgerv1.AppendTransactionEventRequest, ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error)
GetJournal(context.Context, *ledgerv1.GetJournalRequest, ...grpc.CallOption) (*ledgerv1.Journal, error)
GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error)
}
type config struct {
RunID string
Wallets int
AssetID int64
Amount string
RequestTimeout time.Duration
}
type sample struct {
Operation string
Latency time.Duration
Err error
}
type operationStats struct {
Requests int64
Errors int64
Latency []time.Duration
}
func main() {
target := flag.String("target", "127.0.0.1:8600", "GL gRPC target")
concurrency := flag.Int("concurrency", 20, "number of concurrent transaction workers")
duration := flag.Duration("duration", 30*time.Second, "load duration")
wallets := flag.Int("wallets", 1000, "number of simulated wallet accounts")
assetID := flag.Int64("asset-id", 1, "asset ID used by transfers")
amount := flag.String("amount", "1", "canonical decimal transfer amount")
requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each gRPC call")
runID := flag.String("run-id", "", "idempotency namespace; generated when empty")
flag.Parse()
if *concurrency < 1 || *duration <= 0 || *wallets < 2 || *assetID <= 0 || *requestTimeout <= 0 || *amount == "" {
fmt.Fprintln(os.Stderr, "concurrency and durations must be positive; wallets must be at least 2; asset-id and amount are required")
os.Exit(2)
}
parsedAmount, err := domain.ParseAmount(*amount)
if err != nil || parsedAmount.IsZero() || strings.HasPrefix(*amount, "-") {
fmt.Fprintf(os.Stderr, "amount must be a positive canonical ledger decimal: %v\n", err)
os.Exit(2)
}
if *runID == "" {
*runID = "gl-load-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36)
}
connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fmt.Fprintf(os.Stderr, "create gRPC client: %v\n", err)
os.Exit(2)
}
defer connection.Close()
ledger := ledgerv1.NewGeneralLedgerServiceClient(connection)
healthCtx, cancelHealth := context.WithTimeout(context.Background(), *requestTimeout)
health, err := ledger.Health(healthCtx, &basev1.Empty{})
cancelHealth()
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
fmt.Fprintf(os.Stderr, "GL is not ready at %s: response=%v error=%v\n", *target, health, err)
os.Exit(1)
}
testConfig := config{
RunID: *runID,
Wallets: *wallets,
AssetID: *assetID,
Amount: *amount,
RequestTimeout: *requestTimeout,
}
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(signalCtx, *duration)
defer cancel()
fmt.Printf("GL load test: target=%s workers=%d duration=%s wallets=%d asset=%d run=%s\n", *target, *concurrency, *duration, *wallets, *assetID, *runID)
started := time.Now()
samples, transactions := run(ctx, ledger, testConfig, *concurrency)
elapsed := time.Since(started)
failed := printSummary(samples, transactions, elapsed)
if failed {
os.Exit(1)
}
}
func run(ctx context.Context, ledger client, cfg config, concurrency int) ([]sample, uint64) {
var sequence atomic.Uint64
results := make(chan []sample, concurrency)
var workers sync.WaitGroup
workers.Add(concurrency)
for range concurrency {
go func() {
defer workers.Done()
workerSamples := make([]sample, 0, 256)
for ctx.Err() == nil {
transaction := sequence.Add(1)
workerSamples = append(workerSamples, runTransaction(ctx, ledger, cfg, transaction)...)
}
results <- workerSamples
}()
}
workers.Wait()
close(results)
all := make([]sample, 0)
for workerSamples := range results {
all = append(all, workerSamples...)
}
return all, sequence.Load()
}
func runTransaction(ctx context.Context, ledger client, cfg config, sequence uint64) []sample {
transactionID := fmt.Sprintf("%s-%d", cfg.RunID, sequence)
senderID := fmt.Sprintf("load-wallet-%d", sequence%uint64(cfg.Wallets))
receiverID := fmt.Sprintf("load-wallet-%d", (sequence+1)%uint64(cfg.Wallets))
correlationID := "load-correlation-" + transactionID
blockchainHash := transactionHash(transactionID)
occurredAt := timestamppb.Now()
created := &ledgerv1.AppendTransactionEventRequest{
SourceService: "gl-loadtest",
IdempotencyKey: "load:" + transactionID + ":event:v1",
SourceTransactionId: transactionID,
TrackingCode: "load-" + strconv.FormatUint(sequence, 10),
EventVersion: 1,
State: ledgerv1.TransactionState_TRANSACTION_STATE_CREATED,
OccurredAt: occurredAt,
CorrelationId: correlationID,
ActorId: senderID,
Metadata: map[string]string{"load_test": cfg.RunID},
}
samples := []sample{measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error {
_, err := ledger.AppendTransactionEvent(callCtx, created)
return err
})}
journalRequest := &ledgerv1.AppendJournalRequest{
SourceService: "gl-loadtest",
IdempotencyKey: "load:" + transactionID + ":transfer:v1",
SourceTransactionId: transactionID,
TrackingCode: created.TrackingCode,
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: occurredAt,
CorrelationId: correlationID,
ActorId: senderID,
Blockchain: &ledgerv1.BlockchainReference{
Network: "load-test",
TransactionHash: blockchainHash,
LedgerSequence: strconv.FormatUint(sequence, 10),
},
Metadata: map[string]string{"load_test": cfg.RunID},
Entries: []*ledgerv1.JournalEntry{
{
LineNumber: 1,
Account: walletAccount(senderID, cfg.AssetID),
Amount: "-" + cfg.Amount,
Description: "load-test transfer debit",
},
{
LineNumber: 2,
Account: walletAccount(receiverID, cfg.AssetID),
Amount: cfg.Amount,
Description: "load-test transfer credit",
},
},
}
var journalID string
samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_journal", func(callCtx context.Context) error {
response, err := ledger.AppendJournal(callCtx, journalRequest)
if err == nil && response.GetJournal() != nil {
journalID = response.GetJournal().GetJournalId()
}
return err
}))
if journalID != "" {
samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_journal", func(callCtx context.Context) error {
_, err := ledger.GetJournal(callCtx, &ledgerv1.GetJournalRequest{
Lookup: &ledgerv1.GetJournalRequest_JournalId{JournalId: journalID},
})
return err
}))
}
samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_balance", func(callCtx context.Context) error {
_, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: walletAccount(receiverID, cfg.AssetID)})
return err
}))
successful := &ledgerv1.AppendTransactionEventRequest{
SourceService: "gl-loadtest",
IdempotencyKey: "load:" + transactionID + ":event:v2",
SourceTransactionId: transactionID,
TrackingCode: created.TrackingCode,
EventVersion: 2,
State: ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL,
OccurredAt: timestamppb.Now(),
CorrelationId: correlationID,
ActorId: senderID,
Blockchain: journalRequest.Blockchain,
Metadata: map[string]string{"load_test": cfg.RunID},
}
samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error {
_, err := ledger.AppendTransactionEvent(callCtx, successful)
return err
}))
return samples
}
func walletAccount(ownerID string, assetID int64) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: ownerID,
AssetId: assetID,
}
}
func transactionHash(transactionID string) string {
value := sha256.Sum256([]byte(transactionID))
return hex.EncodeToString(value[:])
}
func measure(ctx context.Context, timeout time.Duration, operation string, call func(context.Context) error) sample {
callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
started := time.Now()
err := call(callCtx)
return sample{Operation: operation, Latency: time.Since(started), Err: err}
}
func printSummary(samples []sample, transactions uint64, elapsed time.Duration) bool {
stats := make(map[string]*operationStats)
var failed bool
for _, value := range samples {
operation := stats[value.Operation]
if operation == nil {
operation = &operationStats{}
stats[value.Operation] = operation
}
operation.Requests++
operation.Latency = append(operation.Latency, value.Latency)
if value.Err != nil {
operation.Errors++
failed = true
}
}
names := make([]string, 0, len(stats))
for name := range stats {
names = append(names, name)
}
sort.Strings(names)
fmt.Printf("transactions: %d (%.1f tx/s)\n", transactions, rate(float64(transactions), elapsed))
fmt.Printf("%-18s %10s %8s %10s %12s %12s %12s\n", "operation", "requests", "errors", "req/s", "p50", "p95", "p99")
for _, name := range names {
value := stats[name]
fmt.Printf("%-18s %10d %8d %10.1f %12s %12s %12s\n",
name,
value.Requests,
value.Errors,
rate(float64(value.Requests), elapsed),
percentile(value.Latency, 50),
percentile(value.Latency, 95),
percentile(value.Latency, 99),
)
}
return failed
}
func rate(count float64, elapsed time.Duration) float64 {
if elapsed <= 0 {
return 0
}
return count / elapsed.Seconds()
}
func percentile(values []time.Duration, percent int) time.Duration {
if len(values) == 0 {
return 0
}
ordered := append([]time.Duration(nil), values...)
sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] })
index := (len(ordered)*percent + 99) / 100
if index < 1 {
index = 1
}
return ordered[index-1]
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"context"
"testing"
"time"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
)
type clientStub struct {
journal *ledgerv1.AppendJournalRequest
events []*ledgerv1.AppendTransactionEventRequest
balance *ledgerv1.GetBalanceRequest
lookup *ledgerv1.GetJournalRequest
}
func (s *clientStub) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) {
s.journal = request
return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: "journal-1"}}, nil
}
func (s *clientStub) AppendTransactionEvent(_ context.Context, request *ledgerv1.AppendTransactionEventRequest, _ ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error) {
s.events = append(s.events, request)
return &ledgerv1.AppendTransactionEventResponse{}, nil
}
func (s *clientStub) GetJournal(_ context.Context, request *ledgerv1.GetJournalRequest, _ ...grpc.CallOption) (*ledgerv1.Journal, error) {
s.lookup = request
return &ledgerv1.Journal{JournalId: "journal-1"}, nil
}
func (s *clientStub) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) {
s.balance = request
return &ledgerv1.GetBalanceResponse{Balance: "1"}, nil
}
func TestRunTransactionCreatesWalletAccountsAndFullLifecycle(t *testing.T) {
ledger := &clientStub{}
samples := runTransaction(context.Background(), ledger, config{
RunID: "test-run", Wallets: 10, AssetID: 7, Amount: "2.5", RequestTimeout: time.Second,
}, 3)
if len(samples) != 5 {
t.Fatalf("expected five RPC measurements, got %d", len(samples))
}
if ledger.journal == nil || len(ledger.journal.Entries) != 2 {
t.Fatal("balanced journal was not appended")
}
debit, credit := ledger.journal.Entries[0], ledger.journal.Entries[1]
if debit.Amount != "-2.5" || credit.Amount != "2.5" {
t.Fatalf("unexpected journal amounts: %q and %q", debit.Amount, credit.Amount)
}
if debit.Account.OwnerId == credit.Account.OwnerId || debit.Account.AssetId != 7 || credit.Account.AssetId != 7 {
t.Fatalf("unexpected simulated wallets: debit=%+v credit=%+v", debit.Account, credit.Account)
}
if len(ledger.events) != 2 || ledger.events[0].State != ledgerv1.TransactionState_TRANSACTION_STATE_CREATED || ledger.events[1].State != ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL {
t.Fatalf("unexpected transaction lifecycle: %+v", ledger.events)
}
if ledger.lookup.GetJournalId() != "journal-1" || ledger.balance.Account.OwnerId != credit.Account.OwnerId {
t.Fatal("journal and balance reads did not follow the write")
}
}
func TestTransactionHashIsStableAndUnique(t *testing.T) {
first := transactionHash("run-1")
if len(first) != 64 || first != transactionHash("run-1") || first == transactionHash("run-2") {
t.Fatalf("unexpected transaction hashes: %q", first)
}
}
func TestPercentileUsesNearestRank(t *testing.T) {
values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond}
if got := percentile(values, 95); got != 4*time.Millisecond {
t.Fatalf("unexpected p95: %s", got)
}
}
+28
View File
@@ -0,0 +1,28 @@
# GL redistribution load test
This fixed-count scenario creates ten run-scoped user accounts for an operator-
selected asset. It verifies that all ten start at zero, funds only the first
person with 10,000, and then submits 10,000,000 user-to-user transfers.
The first nine transfers give every other person 45. The remaining transfers
use reproducible random senders, receivers, and positive integer amounts while
reserving a minimum balance of 45 for every person. The test finishes by reading
all ten balances from GL and fails unless every balance is at least 45 and their
sum is exactly 10,000.
Start GL, then run:
```bash
go run ./cmd/gl-redistribution-loadtest
Asset number: 7
```
For automation, pass the asset without the prompt:
```bash
go run ./cmd/gl-redistribution-loadtest -asset-id 7 -concurrency 32
```
The defaults are 10,000,000 transfers against `127.0.0.1:8600`. Use a fresh
`-run-id` for every dataset; one is generated automatically. `-transactions`
can be reduced for a smoke test, and `-seed` makes the random sequence repeatable.
+458
View File
@@ -0,0 +1,458 @@
// Command gl-redistribution-loadtest proves balance conservation while applying
// a fixed number of random transfers between ten isolated test users.
package main
import (
"context"
"flag"
"fmt"
"io"
mathrand "math/rand"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
personCount = 10
initialSupply = int64(10_000)
minimumBalance = int64(45)
defaultTransactions = uint64(10_000_000)
seedTransactions = uint64(personCount - 1)
)
type client interface {
Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error)
AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error)
GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error)
}
type config struct {
RunID string
AssetID int64
Transactions uint64
Concurrency int
RequestTimeout time.Duration
RandomSeed int64
}
type transfer struct {
Sender int
Receiver int
Amount int64
}
type balanceState struct {
mu sync.Mutex
balances [personCount]int64
}
type metrics struct {
attempted atomic.Uint64
succeeded atomic.Uint64
errors atomic.Uint64
latencyNanos atomic.Int64
maxNanos atomic.Int64
}
func main() {
os.Exit(runCLI(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
func runCLI(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("gl-redistribution-loadtest", flag.ContinueOnError)
flags.SetOutput(stderr)
target := flags.String("target", "127.0.0.1:8600", "GL gRPC target")
assetID := flags.Int64("asset-id", 0, "asset number; prompted when omitted")
transactions := flags.Uint64("transactions", defaultTransactions, "total user-to-user transfers")
concurrency := flags.Int("concurrency", 32, "number of concurrent transfer workers")
requestTimeout := flags.Duration("request-timeout", 10*time.Second, "timeout for each gRPC call")
runID := flags.String("run-id", "", "isolated idempotency namespace; generated when empty")
randomSeed := flags.Int64("seed", 0, "random seed; generated when zero")
if err := flags.Parse(args); err != nil {
return 2
}
if *transactions < seedTransactions || *concurrency < 1 || *requestTimeout <= 0 {
fmt.Fprintf(stderr, "transactions must be at least %d; concurrency and request-timeout must be positive\n", seedTransactions)
return 2
}
resolvedAssetID, err := resolveAssetID(*assetID, stdin, stdout)
if err != nil {
fmt.Fprintf(stderr, "asset number: %v\n", err)
return 2
}
if *runID == "" {
*runID = "gl-redistribution-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36)
}
if *randomSeed == 0 {
*randomSeed = time.Now().UnixNano()
}
connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fmt.Fprintf(stderr, "create gRPC client: %v\n", err)
return 2
}
defer connection.Close()
ledger := ledgerv1.NewGeneralLedgerServiceClient(connection)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg := config{
RunID: *runID,
AssetID: resolvedAssetID,
Transactions: *transactions,
Concurrency: *concurrency,
RequestTimeout: *requestTimeout,
RandomSeed: *randomSeed,
}
if err := execute(ctx, ledger, cfg, stdout); err != nil {
fmt.Fprintf(stderr, "redistribution load test failed: %v\n", err)
return 1
}
return 0
}
func resolveAssetID(value int64, input io.Reader, output io.Writer) (int64, error) {
if value > 0 {
return value, nil
}
if value < 0 {
return 0, fmt.Errorf("must be positive")
}
fmt.Fprint(output, "Asset number: ")
var text string
if _, err := fmt.Fscan(input, &text); err != nil {
return 0, fmt.Errorf("read input: %w", err)
}
assetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64)
if err != nil || assetID <= 0 {
return 0, fmt.Errorf("must be a positive integer")
}
return assetID, nil
}
func execute(ctx context.Context, ledger client, cfg config, output io.Writer) error {
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
health, err := ledger.Health(callCtx, &basev1.Empty{})
cancel()
if err != nil {
return fmt.Errorf("health check: %w", err)
}
if !health.GetServing() || !health.GetDatabaseReady() {
return fmt.Errorf("GL is not ready: %v", health)
}
people := personIDs(cfg.RunID)
startingBalances, err := readBalances(ctx, ledger, cfg, people)
if err != nil {
return fmt.Errorf("read unfunded balances: %w", err)
}
if err := verifyUnfunded(startingBalances); err != nil {
return err
}
if err := fundFirstPerson(ctx, ledger, cfg, people[0]); err != nil {
return fmt.Errorf("fund first person: %w", err)
}
initialBalances, err := readBalances(ctx, ledger, cfg, people)
if err != nil {
return fmt.Errorf("read initial balances: %w", err)
}
if err := verifyInitial(initialBalances); err != nil {
return err
}
fmt.Fprintf(output, "GL redistribution load test: target users=%d asset=%d transfers=%d workers=%d seed=%d run=%s\n",
personCount, cfg.AssetID, cfg.Transactions, cfg.Concurrency, cfg.RandomSeed, cfg.RunID)
fmt.Fprintln(output, "initial balances: [10000 0 0 0 0 0 0 0 0 0]")
stats := &metrics{}
started := time.Now()
balances := initialBalances
for person := 1; person < personCount; person++ {
value := transfer{Sender: 0, Receiver: person, Amount: minimumBalance}
startedCall := time.Now()
err := appendTransfer(ctx, ledger, cfg, uint64(person), people, value)
stats.observe(time.Since(startedCall), err)
if err != nil {
return fmt.Errorf("seed person %d: %w", person+1, err)
}
balances[0] -= minimumBalance
balances[person] += minimumBalance
}
state := &balanceState{balances: balances}
if err := runRandomTransfers(ctx, ledger, cfg, people, state, stats, output); err != nil {
printSummary(output, stats, time.Since(started))
return err
}
elapsed := time.Since(started)
printSummary(output, stats, elapsed)
finalBalances, err := readBalances(ctx, ledger, cfg, people)
if err != nil {
return fmt.Errorf("read final balances: %w", err)
}
if err := verifyFinal(finalBalances); err != nil {
return err
}
fmt.Fprintf(output, "final balances: %v\n", finalBalances)
fmt.Fprintf(output, "PASS: all %d people have at least %d; total=%d\n", personCount, minimumBalance, initialSupply)
return nil
}
func personIDs(runID string) [personCount]string {
var people [personCount]string
for index := range people {
people[index] = fmt.Sprintf("%s-person-%02d", runID, index+1)
}
return people
}
func readBalances(ctx context.Context, ledger client, cfg config, people [personCount]string) ([personCount]int64, error) {
var balances [personCount]int64
for index, person := range people {
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
response, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: personAccount(person, cfg.AssetID)})
cancel()
if err != nil {
return balances, fmt.Errorf("person %d: %w", index+1, err)
}
if response == nil {
return balances, fmt.Errorf("person %d: empty response", index+1)
}
balance, err := strconv.ParseInt(response.GetBalance(), 10, 64)
if err != nil {
return balances, fmt.Errorf("person %d: invalid integer balance %q", index+1, response.GetBalance())
}
balances[index] = balance
}
return balances, nil
}
func verifyUnfunded(balances [personCount]int64) error {
for index, balance := range balances {
if balance != 0 {
return fmt.Errorf("person %d must start unfunded; got %d (use a fresh run-id)", index+1, balance)
}
}
return nil
}
func verifyInitial(balances [personCount]int64) error {
if balances[0] != initialSupply {
return fmt.Errorf("person 1 initial balance: got %d, want %d", balances[0], initialSupply)
}
for index := 1; index < personCount; index++ {
if balances[index] != 0 {
return fmt.Errorf("person %d initial balance: got %d, want 0", index+1, balances[index])
}
}
return nil
}
func verifyFinal(balances [personCount]int64) error {
var total int64
for index, balance := range balances {
if balance < minimumBalance {
return fmt.Errorf("person %d final balance %d is below %d", index+1, balance, minimumBalance)
}
total += balance
}
if total != initialSupply {
return fmt.Errorf("final balance sum: got %d, want %d", total, initialSupply)
}
return nil
}
func fundFirstPerson(ctx context.Context, ledger client, cfg config, firstPerson string) error {
request := &ledgerv1.AppendJournalRequest{
SourceService: "gl-redistribution-loadtest",
IdempotencyKey: "redistribution:" + cfg.RunID + ":fund:v1",
SourceTransactionId: cfg.RunID + "-fund",
TrackingCode: cfg.RunID + "-fund",
EffectKind: "load-test-funding",
EventVersion: 1,
OccurredAt: timestamppb.Now(),
ActorId: cfg.RunID,
Metadata: map[string]string{"load_test": cfg.RunID, "phase": "funding"},
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: treasuryAccount(cfg.RunID, cfg.AssetID), Amount: "-10000", Description: "load-test source"},
{LineNumber: 2, Account: personAccount(firstPerson, cfg.AssetID), Amount: "10000", Description: "initial person balance"},
},
}
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
defer cancel()
_, err := ledger.AppendJournal(callCtx, request)
return err
}
func runRandomTransfers(ctx context.Context, ledger client, cfg config, people [personCount]string, state *balanceState, stats *metrics, output io.Writer) error {
if cfg.Transactions == seedTransactions {
return nil
}
workerCtx, cancel := context.WithCancel(ctx)
defer cancel()
var sequence atomic.Uint64
sequence.Store(seedTransactions)
var workers sync.WaitGroup
var firstErr error
var errorOnce sync.Once
workers.Add(cfg.Concurrency)
for worker := range cfg.Concurrency {
go func(worker int) {
defer workers.Done()
random := mathrand.New(mathrand.NewSource(cfg.RandomSeed + int64(worker+1)*7919))
for workerCtx.Err() == nil {
number := sequence.Add(1)
if number > cfg.Transactions {
return
}
value := state.reserve(random)
started := time.Now()
err := appendTransfer(workerCtx, ledger, cfg, number, people, value)
stats.observe(time.Since(started), err)
if err != nil {
state.rollback(value)
errorOnce.Do(func() {
firstErr = fmt.Errorf("transfer %d: %w", number, err)
cancel()
})
return
}
succeeded := stats.succeeded.Load()
if succeeded%1_000_000 == 0 {
fmt.Fprintf(output, "progress: %d/%d transfers\n", succeeded, cfg.Transactions)
}
}
}(worker)
}
workers.Wait()
if firstErr != nil {
return firstErr
}
if err := ctx.Err(); err != nil {
return err
}
if succeeded := stats.succeeded.Load(); succeeded != cfg.Transactions {
return fmt.Errorf("completed %d transfers, want %d", succeeded, cfg.Transactions)
}
return nil
}
func (s *balanceState) reserve(random *mathrand.Rand) transfer {
s.mu.Lock()
defer s.mu.Unlock()
eligible := make([]int, 0, personCount)
for index, balance := range s.balances {
if balance > minimumBalance {
eligible = append(eligible, index)
}
}
sender := eligible[random.Intn(len(eligible))]
receiver := random.Intn(personCount - 1)
if receiver >= sender {
receiver++
}
spendable := s.balances[sender] - minimumBalance
amount := random.Int63n(spendable) + 1
s.balances[sender] -= amount
s.balances[receiver] += amount
return transfer{Sender: sender, Receiver: receiver, Amount: amount}
}
func (s *balanceState) rollback(value transfer) {
s.mu.Lock()
defer s.mu.Unlock()
s.balances[value.Sender] += value.Amount
s.balances[value.Receiver] -= value.Amount
}
func appendTransfer(ctx context.Context, ledger client, cfg config, sequence uint64, people [personCount]string, value transfer) error {
number := strconv.FormatUint(sequence, 10)
amount := strconv.FormatInt(value.Amount, 10)
transactionID := cfg.RunID + "-transfer-" + number
request := &ledgerv1.AppendJournalRequest{
SourceService: "gl-redistribution-loadtest",
IdempotencyKey: "redistribution:" + cfg.RunID + ":transfer:" + number + ":v1",
SourceTransactionId: transactionID,
TrackingCode: transactionID,
EffectKind: "random-internal-transfer",
EventVersion: 1,
OccurredAt: timestamppb.Now(),
CorrelationId: cfg.RunID,
ActorId: people[value.Sender],
Metadata: map[string]string{"load_test": cfg.RunID, "sequence": number},
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: personAccount(people[value.Sender], cfg.AssetID), Amount: "-" + amount, Description: "random transfer debit"},
{LineNumber: 2, Account: personAccount(people[value.Receiver], cfg.AssetID), Amount: amount, Description: "random transfer credit"},
},
}
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
defer cancel()
_, err := ledger.AppendJournal(callCtx, request)
return err
}
func personAccount(ownerID string, assetID int64) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: ownerID,
AssetId: assetID,
}
}
func treasuryAccount(runID string, assetID int64) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY,
OwnerType: "load-test",
OwnerId: runID,
AssetId: assetID,
}
}
func (m *metrics) observe(latency time.Duration, err error) {
m.attempted.Add(1)
m.latencyNanos.Add(latency.Nanoseconds())
for {
current := m.maxNanos.Load()
if latency.Nanoseconds() <= current || m.maxNanos.CompareAndSwap(current, latency.Nanoseconds()) {
break
}
}
if err != nil {
m.errors.Add(1)
return
}
m.succeeded.Add(1)
}
func printSummary(output io.Writer, stats *metrics, elapsed time.Duration) {
attempted := stats.attempted.Load()
average := time.Duration(0)
if attempted > 0 {
average = time.Duration(stats.latencyNanos.Load() / int64(attempted))
}
rate := float64(0)
if elapsed > 0 {
rate = float64(stats.succeeded.Load()) / elapsed.Seconds()
}
fmt.Fprintf(output, "transfers: attempted=%d succeeded=%d errors=%d elapsed=%s rate=%.1f tx/s avg=%s max=%s\n",
attempted, stats.succeeded.Load(), stats.errors.Load(), elapsed.Round(time.Millisecond), rate, average, time.Duration(stats.maxNanos.Load()))
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"bytes"
"context"
"fmt"
mathrand "math/rand"
"strconv"
"strings"
"sync"
"testing"
"time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
)
type memoryClient struct {
mu sync.Mutex
balances map[string]int64
journals int
}
func newMemoryClient() *memoryClient {
return &memoryClient{balances: make(map[string]int64)}
}
func (c *memoryClient) Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error) {
return &ledgerv1.HealthResponse{Serving: true, DatabaseReady: true}, nil
}
func (c *memoryClient) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
for _, entry := range request.GetEntries() {
amount, err := strconv.ParseInt(entry.GetAmount(), 10, 64)
if err != nil {
return nil, err
}
c.balances[accountKey(entry.GetAccount())] += amount
}
c.journals++
return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: fmt.Sprintf("journal-%d", c.journals)}}, nil
}
func (c *memoryClient) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
return &ledgerv1.GetBalanceResponse{Account: request.GetAccount(), Balance: strconv.FormatInt(c.balances[accountKey(request.GetAccount())], 10)}, nil
}
func accountKey(account *ledgerv1.AccountReference) string {
return fmt.Sprintf("%d/%s/%s/%d", account.GetAccountClass(), account.GetOwnerType(), account.GetOwnerId(), account.GetAssetId())
}
func TestResolveAssetIDPromptsWhenMissing(t *testing.T) {
var output bytes.Buffer
assetID, err := resolveAssetID(0, strings.NewReader("42\n"), &output)
if err != nil {
t.Fatal(err)
}
if assetID != 42 || output.String() != "Asset number: " {
t.Fatalf("asset=%d prompt=%q", assetID, output.String())
}
}
func TestResolveAssetIDUsesFlagWithoutPrompt(t *testing.T) {
var output bytes.Buffer
assetID, err := resolveAssetID(7, strings.NewReader(""), &output)
if err != nil {
t.Fatal(err)
}
if assetID != 7 || output.Len() != 0 {
t.Fatalf("asset=%d prompt=%q", assetID, output.String())
}
}
func TestRandomReservationsPreserveSupplyAndMinimum(t *testing.T) {
state := &balanceState{balances: [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45}}
random := mathrand.New(mathrand.NewSource(1234))
distinctAmounts := make(map[int64]struct{})
for range 100_000 {
value := state.reserve(random)
distinctAmounts[value.Amount] = struct{}{}
if value.Sender == value.Receiver || value.Amount <= 0 {
t.Fatalf("invalid transfer: %+v", value)
}
}
var total int64
for index, balance := range state.balances {
if balance < minimumBalance {
t.Fatalf("person %d balance %d is below minimum", index+1, balance)
}
total += balance
}
if total != initialSupply {
t.Fatalf("total=%d, want %d", total, initialSupply)
}
if len(distinctAmounts) < 2 {
t.Fatalf("expected random values, got %v", distinctAmounts)
}
}
func TestVerifyInitialAndFinalBalances(t *testing.T) {
initial := [personCount]int64{10_000}
if err := verifyInitial(initial); err != nil {
t.Fatal(err)
}
final := [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45}
if err := verifyFinal(final); err != nil {
t.Fatal(err)
}
final[9] = 44
if err := verifyFinal(final); err == nil {
t.Fatal("expected minimum-balance failure")
}
}
func TestPersonIDsAreIsolatedByRun(t *testing.T) {
first := personIDs("run-a")
second := personIDs("run-b")
if first[0] == second[0] || first[0] == first[1] {
t.Fatalf("IDs are not isolated: %q %q %q", first[0], second[0], first[1])
}
}
func TestExecuteFundsRedistributesAndVerifiesConservation(t *testing.T) {
ledger := newMemoryClient()
var output bytes.Buffer
err := execute(context.Background(), ledger, config{
RunID: "test-run",
AssetID: 17,
Transactions: 1000,
Concurrency: 4,
RequestTimeout: time.Second,
RandomSeed: 99,
}, &output)
if err != nil {
t.Fatal(err)
}
if ledger.journals != 1001 {
t.Fatalf("journals=%d, want one funding journal and 1000 transfers", ledger.journals)
}
if !strings.Contains(output.String(), "PASS: all 10 people have at least 45; total=10000") {
t.Fatalf("missing successful verification in output:\n%s", output.String())
}
}
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"gl/application/health"
applicationledger "gl/application/ledger"
applicationsettlement "gl/application/settlement"
"gl/infrastructure/config"
"gl/infrastructure/kuknos"
"gl/infrastructure/observability"
"gl/infrastructure/postgres"
grpcadapter "gl/interface/grpc"
)
func main() {
observability.Configure("gl", slog.LevelInfo)
configPath := flag.String("conf", "./gl.cfg.toml", "path to the TOML configuration file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("load configuration", "error", err)
os.Exit(1)
}
shutdownTelemetry := observability.Setup(context.Background(), cfg.Telemetry, "gl", cfg.Environment)
defer shutdownTelemetry(context.Background())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := postgres.Open(ctx, cfg.Database)
if err != nil {
slog.Error("open GL database", "error", err)
os.Exit(1)
}
defer database.Close()
if err := postgres.Migrate(ctx, database); err != nil {
slog.Error("migrate GL database", "error", err)
os.Exit(1)
}
repository := postgres.NewJournalRepository(database)
settlementRepository := postgres.NewSettlementRepository(database)
settlementService := applicationsettlement.NewService(settlementRepository)
var healthService *health.Service
if cfg.Settlement.Enabled {
healthService = health.NewService(database, settlementRepository)
} else {
healthService = health.NewService(database)
}
handler := grpcadapter.NewHandler(healthService, applicationledger.NewService(repository, nil), settlementService).WithSettlementAdminToken(cfg.Settlement.AdminToken)
if cfg.Settlement.Enabled {
worker := applicationsettlement.Worker{Repository: settlementRepository, Submitter: kuknos.Submitter{Endpoint: cfg.Settlement.Endpoint}, WorkerID: cfg.Settlement.WorkerID, MaxAttempts: cfg.Settlement.MaxAttempts}
go runSettlementWorker(ctx, worker, cfg.Settlement.PollInterval, cfg.Settlement.BatchSize)
go runSettlementReconciliation(ctx, settlementRepository)
slog.Info("Kuknos settlement worker enabled", "endpoint", cfg.Settlement.Endpoint, "worker_id", cfg.Settlement.WorkerID)
}
slog.Info("starting GL gRPC service", "host", cfg.GRPC.Host, "port", cfg.GRPC.Port)
serverConfig := grpcadapter.ServerConfig{
Host: cfg.GRPC.Host,
Port: cfg.GRPC.Port,
ShutdownTimeout: cfg.GRPC.ShutdownTimeout,
}
if err := grpcadapter.Run(ctx, serverConfig, handler); err != nil {
slog.Error("GL service stopped", "error", err)
os.Exit(1)
}
}
func runSettlementReconciliation(ctx context.Context, repository *postgres.SettlementRepository) {
run := func() {
stats, err := repository.ReconcileConfirmationEvidence(ctx, 10*time.Minute)
if err != nil {
slog.ErrorContext(ctx, "settlement reconciliation failed", "error", err)
return
}
if stats.Detected > 0 || stats.Resolved > 0 || stats.Open > 0 {
slog.WarnContext(ctx, "settlement reconciliation completed", "detected", stats.Detected, "resolved", stats.Resolved, "open", stats.Open)
}
}
run()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}
func runSettlementWorker(ctx context.Context, worker applicationsettlement.Worker, interval time.Duration, batchSize int) {
if interval <= 0 {
interval = 5 * time.Second
}
if batchSize <= 0 {
batchSize = 20
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := worker.RunOnce(ctx, batchSize); err != nil {
slog.ErrorContext(ctx, "settlement worker cycle failed", "error", err)
}
}
}
}
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"context"
"flag"
"net"
"os"
"strconv"
"time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"gl/infrastructure/config"
"gl/infrastructure/postgres"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
configPath := flag.String("conf", "/app/config.toml", "path to the GL TOML configuration file")
grpcProbe := flag.Bool("grpc", false, "check the serving GL API instead of only its database")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
cfg, err := config.Load(*configPath)
if err != nil {
os.Exit(1)
}
if *grpcProbe {
host := cfg.GRPC.Host
if host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
connection, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(cfg.GRPC.Port)), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
os.Exit(1)
}
defer connection.Close()
health, err := ledgerv1.NewGeneralLedgerServiceClient(connection).Health(ctx, &basev1.Empty{})
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
os.Exit(1)
}
return
}
database, err := postgres.Open(ctx, cfg.Database)
if err != nil {
os.Exit(1)
}
defer database.Close()
if err := database.Ping(ctx); err != nil {
os.Exit(1)
}
}
+15
View File
@@ -0,0 +1,15 @@
environment = "local"
[http]
host = "0.0.0.0"
port = 8601
read-header-timeout = "5s"
shutdown-timeout = "10s"
[db]
host = "127.0.0.1"
port = 5432
name = "gl_db"
user = "postgres"
password = ""
ssl-mode = "disable"
+145
View File
@@ -0,0 +1,145 @@
# GL authority API
The `ledger.v1.GeneralLedgerService` gRPC service exposes four additional RPCs.
The contract is in `../proto/ledger/v1/{msg,srv}.proto` from the GL directory;
the generated Go client is in `gen/ledger/v1`.
## Operator toggle
In the existing AdminPanel, open **GL journals → GL operating mode**.
The checkbox, required reason, and recovery confirmation call GL directly.
The operator's ID comes from the authenticated session. Permission
`ledgerops.set_operating_mode` is required; superusers already have it.
Operators also need `ledgerops.view_generalledgerjournal` for journal navigation.
Register the permission using AdminPanel's usual Django `migrate` command
and grant it only to designated operators. Configure the existing
`GL_ADMIN_GRPC_ADDRESS` and `GL_ADMIN_GRPC_TOKEN` settings, with the token
matching GL's `DARANO_GL_ADMIN_TOKEN`. These are server-side settings.
The AdminPanel control changes GL's authority state. Existing Wallet flows
still need to call the internal posting API; changing Wallet's old
`kuknos-enabled` configuration alone continues to select read-only behavior.
## Future Wallet routing contract
Wallet remains the entry point in both modes. The public Wallet RPCs, request
and response schemas, authentication, and client UI stay unchanged; clients
must not call GL directly or choose the execution mode.
| GL mode | Transaction execution | User balance response |
| --- | --- | --- |
| `NORMAL` (default) | Existing Wallet admission → GL settlement queue → Stellar/Kuknos | Existing Wallet behavior |
| `GL_ONLY` | Wallet admission → GL `PostInternalJournal`, with no blockchain call | Wallet returns GL `GetUserBalances` values in its existing response format |
This routing is reserved for future activation. The GL backend and AdminPanel
control exist, but the Wallet routing above is not implemented yet. Keep
`NORMAL` selected for the current Wallet deployment. Enabling the control now
pauses settlement and causes existing Wallet financial writes to fail closed;
it does not automatically turn those endpoints into internal execution.
The future implementation must retain Wallet's authorization and business
validation, persist the execution choice and exact command for recovery, and
use the same transaction identity for retries. A GL commit establishes
financial success; subsequent Wallet history updates are recoverable
projections and must not produce a second financial journal through the normal
outbox. Reconciliation must preserve the original execution choice across
mode changes rather than reinterpret an older blockchain transaction as an
internal transfer. Cutover requires draining normal-mode work and checking
opening balances. Returning to normal requires reconciliation before external
settlement resumes.
Internal transfers, freezes, releases, trades, and their fees can execute
inside GL. An actual external blockchain payment cannot be reported as
externally settled while Stellar/Kuknos is unavailable.
`GetOperatingMode(base.v1.Empty)` returns `mode`, `revision`, `actor_id`,
`reason`, and `changed_at`.
`SetOperatingMode` requires exactly one `x-admin-token` metadata value matching
`DARANO_GL_ADMIN_TOKEN`. To enable internal authority, send:
```json
{
"mode": "GL_ONLY",
"expectedRevision": "0",
"actorId": "operator-17",
"reason": "Kuknos outage; opening balances and outbox checked"
}
```
Use the revision from the latest read, rather than always sending zero.
Mode persists across service restarts. A concurrent toggle returns `ABORTED`;
read the state before deciding whether another change is needed. A toggle
with in-flight settlements or negative user balances returns
`FAILED_PRECONDITION`. Returning to `NORMAL` requires
`reconciliationConfirmed: true` after operator reconciliation.
## Internal execution
`PostInternalJournal` accepts the existing `AppendJournalRequest` and requires
the administrative token, an actor ID, and `GL_ONLY` mode for new postings.
For a transfer, debit the sender's `USER_AVAILABLE` account and credit the
recipient's account by the same exact decimal amount. For a freeze, debit
available and credit frozen; for a release, reverse those legs. Multi-asset
trade and fee legs must balance independently per asset.
The API accepts exact balanced entries so it can represent all internal
business effects without depending on Stellar XDR, account sequences, or a
Horizon endpoint. It does not determine business fees, asset eligibility, or
whether the caller's user is allowed to move those funds; trusted application
admission must supply and authorize those legs. It forbids blockchain
references and `EXTERNAL_BLOCKCHAIN` accounts. The ordinary journal API
remains the normal-mode ingestion interface and rejects new effects in
`GL_ONLY`, while allowing identical retries of previously ingested journals.
Success means the journal and every entry committed atomically. Insufficient
available or frozen funds returns `FAILED_PRECONDITION`. Retry uncertain
outcomes with the exact same idempotency key and payload. Identical retries
return `already_existed=true`; conflicting payloads return `ALREADY_EXISTS`.
A concurrency abort returns `ABORTED`; retry that same request rather than
allocating a new transaction identity. Database errors never mean success.
The server reserves journal metadata key `gl_execution` and stores
`GL_ONLY` as immutable execution provenance. Callers do not set it.
## User balances
`GetUserBalances` reads GL entries without any blockchain call:
```json
{"ownerType": "user", "ownerId": "17", "assetId": "7"}
```
Example response:
```json
{
"ownerType": "user",
"ownerId": "17",
"balances": [
{"assetId": "7", "available": "34", "frozen": "6", "total": "40"}
]
}
```
Omit `assetId` to return all journaled assets for that owner. An unknown owner
has an empty list; a requested asset with no entries returns zero amounts.
Amounts are decimal strings. Available and frozen are read in one database
snapshot. The endpoint is available in either operating mode.
The API is an internal service: expose it through an authenticated application
gateway that binds the requested owner to the logged-in user. Do not expose
arbitrary owner lookups or the operator token directly to a browser.
## Verification
The opt-in PostgreSQL test creates and removes its own isolated schema:
```bash
GL_TEST_DATABASE_URL='<disposable PostgreSQL connection URL>' \
go test -race ./infrastructure/postgres -run TestAuthorityWithPostgres -v
```
It exercises mode authentication, revision conflicts, immutable audit records,
settlement suspension, concurrent overspending prevention, idempotent replay,
freezes/releases, internal RPC posting, and user balance reconstruction.
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1
FROM golang:1.26-bookworm AS builder
WORKDIR /src
ARG GO_PROXY=https://go.reg.darano.ir
ENV GOPROXY="$GO_PROXY"
ENV CGO_ENABLED=0
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,target=/root/.cache/go-build,sharing=locked \
go build -trimpath -ldflags="-s -w" -o /out/gl ./cmd/gl && \
go build -trimpath -ldflags="-s -w" -o /out/dashboard ./cmd/dashboard && \
go build -trimpath -ldflags="-s -w" -o /out/healthcheck ./cmd/healthcheck
FROM alpine:3.24.1
RUN apk add --no-cache ca-certificates \
&& addgroup -S darano \
&& adduser -S -G darano darano
WORKDIR /app
COPY --from=builder /out/gl /app/gl
COPY --from=builder /out/dashboard /app/dashboard
COPY --from=builder /out/healthcheck /app/healthcheck
COPY gl.cfg.toml /app/gl.cfg.toml
COPY dashboard.cfg.toml /app/dashboard.cfg.toml
USER darano
EXPOSE 8600 8601
ENTRYPOINT ["/app/gl"]
CMD ["-conf", "/app/gl.cfg.toml"]
+133
View File
@@ -0,0 +1,133 @@
# Standalone General Ledger
GL's journal API and explorer require only GL's own PostgreSQL database.
Stellar, Kuknos, Wallet, and an OpenTelemetry collector are not startup or
posting dependencies. Blockchain references on journals are optional.
## Run independently
From the GL directory, set two runtime secrets and launch:
```bash
export DARANO_GL_DB_PASSWORD='<database password>'
export DARANO_GL_ADMIN_TOKEN='<administrative retry token>'
docker compose -f deployment/compose.standalone.yaml up --build -d --wait
```
The gRPC API is on `127.0.0.1:8600`; the read-only dashboard is on
`http://127.0.0.1:8601`. Images must be built/downloaded before an outage.
The runtime network is internal and has no external network access.
Settlement submission and telemetry export are explicitly disabled. The
settlement queue remains persisted and queryable; disabling its worker does
not confirm queued blockchain transactions.
The named `ledger-data` volume retains journals across container restarts and
ordinary `docker compose down`. Do not use `down -v` on a ledger containing
financial records.
This launch creates a **new, empty ledger**. Before treating it as the source
of truth, restore the existing authoritative GL database, or import approved,
balanced opening journals and replay all subsequent committed movements.
Running a second empty GL does not provide failover for the first.
## What remains available during a blockchain outage
- Atomic, balanced, append-only journal posting with idempotent retries.
- Journal lookup, history, lifecycle events, and balances rebuilt from entries.
- Read-only exploration of the same database.
- Durable storage of settlement requests already carrying signed XDR.
GL readiness reflects its database. Settlement backlog or submission failures
do not prevent ledger reads or writes. Database loss or failed journal commits
must prevent a caller from declaring an unrecorded financial effect successful.
## Financial authority versus settlement
The committed journal is the source of book balances. A blockchain hash is
settlement evidence, not the journal identity. Do not modify a journal to add
later confirmation; retain it as separate settlement/lifecycle evidence.
The persisted `GL_ONLY` operating mode makes GL the execution authority.
`PostInternalJournal` commits balanced internal effects synchronously, checks
that affected available/frozen accounts do not become negative, and serializes
concurrent spending inside PostgreSQL. An identical retry returns the original
journal even after the mode changes. In `GL_ONLY`, new effects must use the
authenticated internal posting API; ordinary ingestion rejects new journals
while still accepting identical retries. PostgreSQL enforces the funds checks
and rejects external blockchain movements at sealing. Business admission still
owns user authorization, asset eligibility, fees, and exact counterparty legs.
GL starts in `NORMAL`; mode is stored in its database and survives restarts.
Disabling the settlement worker in TOML is separate from choosing `GL_ONLY`.
Read the current revision, then use the authenticated `SetOperatingMode` API
to change mode. Each change retains actor, reason, revision, and time in an
immutable audit table. Stale revisions fail. Enabling `GL_ONLY` requires no
in-flight settlement claims and no negative user account balances. Drain the
Wallet outbox, pause and drain in-flight Wallet processing, and confirm the
ledger includes every committed effect before switching.
The existing AdminPanel controls GL mode through its GL journals →
GL operating mode page. Configure `GL_ADMIN_GRPC_ADDRESS` and
`GL_ADMIN_GRPC_TOKEN` (matching GL's `DARANO_GL_ADMIN_TOKEN`), and grant
`ledgerops.set_operating_mode` to designated operators. AdminPanel never
updates GL tables directly; mode changes go through GL's API.
Wallet remains the public entry point in both modes: the future GL-only route
must preserve the existing Wallet endpoints, response formats, and UI. In
normal operation, admitted requests continue through GL settlement to
Stellar/Kuknos. With GL-only routing implemented and enabled, Wallet will
instead submit internal effects directly to GL and serve GL balances through
those same Wallet endpoints. See [the future Wallet routing contract](AUTHORITY_API.md#future-wallet-routing-contract).
That Wallet routing is not implemented yet; keep `NORMAL` selected for the
current deployment. The existing Wallet configuration toggle still selects read-only behavior.
Wallet consumers must route internal execution to `PostInternalJournal`;
the AdminPanel change does not replace Wallet's blockchain-dependent workflows.
GL keeps its own readiness healthy but reports settlement as paused, so existing
normal-mode Wallet clients fail closed until their internal routes are integrated.
A committed GL journal is the financial
success result; Wallet state becomes an idempotent projection of it. Do not
also send the same effect through the normal Wallet outbox under a different
key: that would duplicate the movement. Existing normal-mode committed Wallet
effects continue using their durable outbox.
Internal transfers, freezes, releases, trades, and clearing movements are
represented by balanced entries with no blockchain reference. An operation
that actually sends money outside the internal books cannot become externally
settled during an outage. External blockchain account entries and settlement
submission are rejected in `GL_ONLY`; store any future external intent
separately and keep its settlement pending.
After blockchain recovery, reconcile journals against external evidence before
resuming normal settlement. Returning to `NORMAL` requires an operator to set
`reconciliation_confirmed=true`; this is an explicit assertion, not an automated
proof. While `GL_ONLY` is enabled, the worker leaves the existing settlement
queue untouched. Mode changes reject in-flight claims so a toggle cannot race
an already claimed submission. The existing queue requires signed XDR; it does not
turn arbitrary journals into blockchain transactions. Sequence numbers,
transaction expiry, and exhausted retries may require operator review and
reconstruction. Never blindly resubmit all journals or mark them settled.
## Verify and preserve the ledger
The existing write/read load test can exercise a **disposable** standalone
database without any blockchain endpoint:
```bash
go run ./cmd/gl-loadtest -duration 5s -concurrency 1 -wallets 10
```
This writes synthetic journals and must not run against an authoritative
financial database. Restart GL and confirm the same journal IDs and balances
remain queryable.
Keep PostgreSQL backups and WAL recovery independent of the blockchain
services, and periodically verify a restore. For example, create a logical
backup without deleting or changing ledger records:
```bash
docker compose -f deployment/compose.standalone.yaml exec -T postgres \
pg_dump -U gl -d gl_db -Fc > gl.dump
```
A named volume provides persistence on one host, not disaster recovery.
+65
View File
@@ -0,0 +1,65 @@
name: darano-gl-standalone
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: gl_db
POSTGRES_USER: gl
POSTGRES_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
volumes:
- ledger-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U gl -d gl_db"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
networks: [ledger]
gl:
image: darano-gl:standalone
build:
context: ..
dockerfile: deployment/Dockerfile
args:
GO_PROXY: ${GO_PROXY:-https://proxy.golang.org}
command: ["-conf", "/app/standalone.toml"]
environment:
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
DARANO_GL_ADMIN_TOKEN: ${DARANO_GL_ADMIN_TOKEN:?Set DARANO_GL_ADMIN_TOKEN}
volumes:
- ./gl.standalone.toml:/app/standalone.toml:ro
ports: ["127.0.0.1:8600:8600"]
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "/app/healthcheck", "-conf", "/app/standalone.toml", "-grpc"]
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
networks: [ledger]
dashboard:
image: darano-gl:standalone
entrypoint: ["/app/dashboard"]
command: ["-conf", "/app/standalone.toml"]
environment:
DARANO_GL_DB_PASSWORD: ${DARANO_GL_DB_PASSWORD:?Set DARANO_GL_DB_PASSWORD}
volumes:
- ./dashboard.standalone.toml:/app/standalone.toml:ro
ports: ["127.0.0.1:8601:8601"]
depends_on:
gl:
condition: service_healthy
restart: unless-stopped
networks: [ledger]
volumes:
ledger-data:
networks:
ledger:
internal: true
+16
View File
@@ -0,0 +1,16 @@
environment = "production"
[http]
host = "0.0.0.0"
port = 8601
read-header-timeout = "5s"
shutdown-timeout = "10s"
[db]
host = "postgres"
port = 5432
name = "gl_db"
user = "gl"
[telemetry]
enabled = false
+18
View File
@@ -0,0 +1,18 @@
environment = "production"
[grpc]
host = "0.0.0.0"
port = 8600
timeout = "10s"
[db]
host = "postgres"
port = 5432
name = "gl_db"
user = "gl"
[settlement]
enabled = false
[telemetry]
enabled = false
+115
View File
@@ -0,0 +1,115 @@
// Package ledger contains GL's framework-independent financial model.
package ledger
import (
"fmt"
"math/big"
"strings"
"unicode"
)
const (
AmountPrecision = 38
AmountScale = 18
)
var amountFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(AmountScale), nil)
// Amount is an exact fixed-point decimal with PostgreSQL numeric(38,18)
// semantics. Its zero value is a valid zero amount.
type Amount struct {
units big.Int
}
func ParseAmount(value string) (Amount, error) {
if value == "" {
return Amount{}, fmt.Errorf("amount is required")
}
negative := value[0] == '-'
unsigned := value
if negative {
unsigned = value[1:]
}
if unsigned == "" || strings.HasPrefix(unsigned, "+") {
return Amount{}, fmt.Errorf("invalid amount %q", value)
}
parts := strings.Split(unsigned, ".")
if len(parts) > 2 || parts[0] == "" {
return Amount{}, fmt.Errorf("invalid amount %q", value)
}
for _, part := range parts {
if part == "" || strings.IndexFunc(part, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
return Amount{}, fmt.Errorf("invalid amount %q", value)
}
}
if len(parts[0]) > 1 && parts[0][0] == '0' {
return Amount{}, fmt.Errorf("amount must not contain leading zeroes")
}
if len(strings.TrimLeft(parts[0], "0")) > AmountPrecision-AmountScale {
return Amount{}, fmt.Errorf("amount exceeds integer precision %d", AmountPrecision-AmountScale)
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
if len(fraction) > AmountScale {
return Amount{}, fmt.Errorf("amount exceeds scale %d", AmountScale)
}
}
digits := strings.TrimLeft(parts[0]+fraction, "0")
if len(digits) > AmountPrecision {
return Amount{}, fmt.Errorf("amount exceeds precision %d", AmountPrecision)
}
whole := new(big.Int)
whole.SetString(parts[0], 10)
units := new(big.Int).Mul(whole, amountFactor)
if fraction != "" {
padded := fraction + strings.Repeat("0", AmountScale-len(fraction))
fractional := new(big.Int)
fractional.SetString(padded, 10)
units.Add(units, fractional)
}
if negative {
units.Neg(units)
}
return Amount{units: *units}, nil
}
func (a Amount) IsZero() bool {
return a.units.Sign() == 0
}
func (a Amount) Add(other Amount) Amount {
var result big.Int
result.Add(&a.units, &other.units)
return Amount{units: result}
}
func (a Amount) Negate() Amount {
var result big.Int
result.Neg(&a.units)
return Amount{units: result}
}
func (a Amount) String() string {
if a.units.Sign() == 0 {
return "0"
}
abs := new(big.Int).Abs(new(big.Int).Set(&a.units))
whole, fraction := new(big.Int), new(big.Int)
whole.QuoRem(abs, amountFactor, fraction)
value := whole.String()
if fraction.Sign() != 0 {
fractionText := fmt.Sprintf("%018s", fraction.String())
value += "." + strings.TrimRight(fractionText, "0")
}
if a.units.Sign() < 0 {
return "-" + value
}
return value
}
+38
View File
@@ -0,0 +1,38 @@
package ledger
import "testing"
func TestParseAmountCanonicalizesExactValues(t *testing.T) {
for input, expected := range map[string]string{
"0": "0",
"1": "1",
"1.2300": "1.23",
"-0.000000000000000001": "-0.000000000000000001",
"99999999999999999999": "99999999999999999999",
} {
amount, err := ParseAmount(input)
if err != nil {
t.Fatalf("ParseAmount(%q): %v", input, err)
}
if got := amount.String(); got != expected {
t.Fatalf("ParseAmount(%q) = %q, want %q", input, got, expected)
}
}
}
func TestParseAmountRejectsInvalidValues(t *testing.T) {
for _, input := range []string{"", "+1", "01", ".1", "1.", "1e2", "1.0000000000000000001", "123456789012345678901234567890123456789"} {
if _, err := ParseAmount(input); err == nil {
t.Fatalf("ParseAmount(%q) succeeded", input)
}
}
}
func TestAmountAdditionIsExact(t *testing.T) {
one, _ := ParseAmount("0.1")
two, _ := ParseAmount("0.2")
minusThree, _ := ParseAmount("-0.3")
if got := one.Add(two).Add(minusThree); !got.IsZero() {
t.Fatalf("expected exact zero, got %s", got.String())
}
}
+36
View File
@@ -0,0 +1,36 @@
package ledger
import (
"errors"
"time"
)
type OperatingMode string
const (
ModeNormal OperatingMode = "NORMAL"
ModeGLOnly OperatingMode = "GL_ONLY"
InternalExecutionMetadata = "gl_execution"
)
var (
ErrModeConflict = errors.New("operating mode revision changed")
ErrModeRequired = errors.New("operation is not permitted in the current ledger mode")
ErrInsufficientFunds = errors.New("insufficient ledger funds")
ErrSettlementInFlight = errors.New("settlement is in flight; drain the worker before switching")
ErrConcurrentPosting = errors.New("concurrent posting conflicted; retry with the same idempotency key")
)
type OperatingState struct {
Mode OperatingMode
Revision int64
ActorID string
Reason string
ChangedAt time.Time
}
type UserAssetBalance struct {
AssetID int64
Available Amount
Frozen Amount
}
+10
View File
@@ -0,0 +1,10 @@
package ledger
import "errors"
var (
ErrNotFound = errors.New("ledger record not found")
ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload")
ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal")
ErrAlreadyReversed = errors.New("journal already has a reversal")
)
+74
View File
@@ -0,0 +1,74 @@
package ledger
import (
"encoding/hex"
"fmt"
"strings"
"time"
)
type TransactionState string
const (
TransactionStateCreated TransactionState = "CREATED"
TransactionStatePendingTransaction TransactionState = "PENDING_TRANSACTION"
TransactionStatePendingAdmin TransactionState = "PENDING_ADMIN"
TransactionStateSuccessful TransactionState = "SUCCESSFUL"
TransactionStateFailed TransactionState = "FAILED"
TransactionStateSuspended TransactionState = "SUSPENDED"
)
func (s TransactionState) Valid() bool {
switch s {
case TransactionStateCreated,
TransactionStatePendingTransaction,
TransactionStatePendingAdmin,
TransactionStateSuccessful,
TransactionStateFailed,
TransactionStateSuspended:
return true
default:
return false
}
}
type TransactionEvent struct {
ID string
SourceService string
IdempotencyKey string
SourceTransactionID string
TrackingCode string
EventVersion uint32
State TransactionState
ErrorCode string
ErrorMessage string
OccurredAt time.Time
RecordedAt time.Time
CorrelationID string
ActorID string
Blockchain BlockchainReference
Metadata map[string]string
PayloadHash string
}
func (e TransactionEvent) Validate() error {
if e.ID == "" || e.SourceService == "" || e.IdempotencyKey == "" || e.SourceTransactionID == "" {
return fmt.Errorf("transaction event identity fields are required")
}
if e.EventVersion == 0 {
return fmt.Errorf("event version must be positive")
}
if !e.State.Valid() {
return fmt.Errorf("invalid transaction state %q", e.State)
}
if e.OccurredAt.IsZero() {
return fmt.Errorf("occurred time is required")
}
if len(e.PayloadHash) != 64 || e.PayloadHash != strings.ToLower(e.PayloadHash) {
return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string")
}
if _, err := hex.DecodeString(e.PayloadHash); err != nil {
return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string")
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package ledger
import (
"encoding/hex"
"fmt"
"strings"
"time"
)
type AccountClass string
const (
AccountClassUserAvailable AccountClass = "USER_AVAILABLE"
AccountClassUserFrozen AccountClass = "USER_FROZEN"
AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN"
AccountClassTreasury AccountClass = "TREASURY"
AccountClassMarketClearing AccountClass = "MARKET_CLEARING"
AccountClassIPGClearing AccountClass = "IPG_CLEARING"
AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE"
)
func (c AccountClass) Valid() bool {
switch c {
case AccountClassUserAvailable,
AccountClassUserFrozen,
AccountClassExternalBlockchain,
AccountClassTreasury,
AccountClassMarketClearing,
AccountClassIPGClearing,
AccountClassCommissionRevenue:
return true
default:
return false
}
}
type AccountReference struct {
Class AccountClass
OwnerType string
OwnerID string
AssetID int64
}
func (a AccountReference) Validate() error {
if !a.Class.Valid() {
return fmt.Errorf("invalid account class %q", a.Class)
}
if a.AssetID <= 0 {
return fmt.Errorf("asset id must be positive")
}
if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen {
if a.OwnerType == "" || a.OwnerID == "" {
return fmt.Errorf("user account requires owner type and id")
}
}
return nil
}
type Entry struct {
LineNumber uint32
Account AccountReference
Amount Amount
Description string
}
type BlockchainReference struct {
Network string
TransactionHash string
LedgerSequence string
}
type Journal struct {
ID string
SourceService string
IdempotencyKey string
SourceTransactionID string
TrackingCode string
EffectKind string
EventVersion uint32
Entries []Entry
ReversalOfJournalID string
OccurredAt time.Time
RecordedAt time.Time
CorrelationID string
ActorID string
Blockchain BlockchainReference
Metadata map[string]string
PayloadHash string
}
type AppendResult struct {
JournalID string
AlreadyExists bool
}
type JournalFilter struct {
Account *AccountReference
AssetID *int64
OwnerID *string
EffectKind *string
RecordedFrom *time.Time
RecordedTo *time.Time
Limit int
Offset int
}
func (j Journal) Validate() error {
if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" {
return fmt.Errorf("journal identity fields are required")
}
if j.EventVersion == 0 {
return fmt.Errorf("event version must be positive")
}
if j.OccurredAt.IsZero() {
return fmt.Errorf("occurred time is required")
}
if len(j.PayloadHash) != 64 {
return fmt.Errorf("payload hash must be a SHA-256 hex string")
}
if _, err := hex.DecodeString(j.PayloadHash); err != nil {
return fmt.Errorf("payload hash must be a SHA-256 hex string")
}
if j.PayloadHash != strings.ToLower(j.PayloadHash) {
return fmt.Errorf("payload hash must use lowercase hexadecimal")
}
if len(j.Entries) < 2 {
return fmt.Errorf("journal requires at least two entries")
}
lines := make(map[uint32]struct{}, len(j.Entries))
balances := make(map[int64]Amount)
for _, entry := range j.Entries {
if entry.LineNumber == 0 {
return fmt.Errorf("entry line number must be positive")
}
if _, exists := lines[entry.LineNumber]; exists {
return fmt.Errorf("duplicate entry line number %d", entry.LineNumber)
}
lines[entry.LineNumber] = struct{}{}
if err := entry.Account.Validate(); err != nil {
return fmt.Errorf("entry %d: %w", entry.LineNumber, err)
}
if entry.Amount.IsZero() {
return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber)
}
balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount)
}
for assetID, balance := range balances {
if !balance.IsZero() {
return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String())
}
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package ledger
import (
"strings"
"testing"
"time"
)
func TestJournalValidationBalancesEachAsset(t *testing.T) {
journal := validJournal(t)
if err := journal.Validate(); err != nil {
t.Fatal(err)
}
journal.Entries[1].Amount, _ = ParseAmount("9")
if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "unbalanced") {
t.Fatalf("expected unbalanced error, got %v", err)
}
}
func TestJournalValidationRejectsDuplicateLines(t *testing.T) {
journal := validJournal(t)
journal.Entries[1].LineNumber = journal.Entries[0].LineNumber
if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate line error, got %v", err)
}
}
func validJournal(t *testing.T) Journal {
t.Helper()
debit, err := ParseAmount("-10")
if err != nil {
t.Fatal(err)
}
credit := debit.Negate()
return Journal{
ID: "11111111-1111-4111-8111-111111111111",
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
PayloadHash: strings.Repeat("a", 64),
Entries: []Entry{
{LineNumber: 1, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5}, Amount: debit},
{LineNumber: 2, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5}, Amount: credit},
},
}
}
+73
View File
@@ -0,0 +1,73 @@
package ledger
import (
"fmt"
"time"
)
type PermanentSubmissionError struct{ Err error }
func (e PermanentSubmissionError) Error() string { return e.Err.Error() }
func (e PermanentSubmissionError) Unwrap() error { return e.Err }
type SettlementStatus string
const (
SettlementPending SettlementStatus = "PENDING"
SettlementSubmitted SettlementStatus = "SUBMITTED"
SettlementConfirmed SettlementStatus = "CONFIRMED"
SettlementRetryable SettlementStatus = "RETRYABLE"
SettlementManualReview SettlementStatus = "MANUAL_REVIEW"
SettlementIgnored SettlementStatus = "IGNORED"
)
type Settlement struct {
ID string
SourceService string
SourceTxID string
IdempotencyKey string
Status SettlementStatus
Network string
TransactionHash string
Attempts int
AvailableAt time.Time
LastError string
SignedTransactionXDR string
}
type SettlementStats struct {
Pending int64
Retryable int64
ManualReview int64
OldestPendingSeconds int64
}
func (s Settlement) Validate() error {
if s.ID == "" || s.SourceService == "" || s.SourceTxID == "" || s.IdempotencyKey == "" {
return fmt.Errorf("settlement identity fields are required")
}
if s.Status != SettlementPending && s.Status != SettlementSubmitted && s.Status != SettlementConfirmed && s.Status != SettlementRetryable && s.Status != SettlementManualReview && s.Status != SettlementIgnored {
return fmt.Errorf("invalid settlement status %q", s.Status)
}
if s.Attempts < 0 || s.AvailableAt.IsZero() {
return fmt.Errorf("invalid settlement retry state")
}
return nil
}
func (s Settlement) Retry(now time.Time, maxAttempts int, err error) Settlement {
s.LastError = err.Error()
s.Status = SettlementRetryable
if maxAttempts > 0 && s.Attempts >= maxAttempts {
s.Status = SettlementManualReview
}
delay := time.Second
for i := 1; i < s.Attempts && delay < 15*time.Minute; i++ {
delay *= 2
}
if delay > 15*time.Minute {
delay = 15 * time.Minute
}
s.AvailableAt = now.UTC().Add(delay)
return s
}
+368
View File
@@ -0,0 +1,368 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc (unknown)
// source: base/v1/msg.proto
package basev1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_base_v1_msg_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_base_v1_msg_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_base_v1_msg_proto_rawDescGZIP(), []int{0}
}
type StatusRes struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StatusRes) Reset() {
*x = StatusRes{}
mi := &file_base_v1_msg_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StatusRes) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StatusRes) ProtoMessage() {}
func (x *StatusRes) ProtoReflect() protoreflect.Message {
mi := &file_base_v1_msg_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StatusRes.ProtoReflect.Descriptor instead.
func (*StatusRes) Descriptor() ([]byte, []int) {
return file_base_v1_msg_proto_rawDescGZIP(), []int{1}
}
func (x *StatusRes) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
type IdRes struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *IdRes) Reset() {
*x = IdRes{}
mi := &file_base_v1_msg_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IdRes) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IdRes) ProtoMessage() {}
func (x *IdRes) ProtoReflect() protoreflect.Message {
mi := &file_base_v1_msg_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IdRes.ProtoReflect.Descriptor instead.
func (*IdRes) Descriptor() ([]byte, []int) {
return file_base_v1_msg_proto_rawDescGZIP(), []int{2}
}
func (x *IdRes) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
type IdReq struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *IdReq) Reset() {
*x = IdReq{}
mi := &file_base_v1_msg_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *IdReq) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*IdReq) ProtoMessage() {}
func (x *IdReq) ProtoReflect() protoreflect.Message {
mi := &file_base_v1_msg_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use IdReq.ProtoReflect.Descriptor instead.
func (*IdReq) Descriptor() ([]byte, []int) {
return file_base_v1_msg_proto_rawDescGZIP(), []int{3}
}
func (x *IdReq) GetId() int64 {
if x != nil {
return x.Id
}
return 0
}
type YesNoRes struct {
state protoimpl.MessageState `protogen:"open.v1"`
Yes bool `protobuf:"varint,1,opt,name=yes,proto3" json:"yes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *YesNoRes) Reset() {
*x = YesNoRes{}
mi := &file_base_v1_msg_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *YesNoRes) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*YesNoRes) ProtoMessage() {}
func (x *YesNoRes) ProtoReflect() protoreflect.Message {
mi := &file_base_v1_msg_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use YesNoRes.ProtoReflect.Descriptor instead.
func (*YesNoRes) Descriptor() ([]byte, []int) {
return file_base_v1_msg_proto_rawDescGZIP(), []int{4}
}
func (x *YesNoRes) GetYes() bool {
if x != nil {
return x.Yes
}
return false
}
type PaginationRespSample struct {
state protoimpl.MessageState `protogen:"open.v1"`
PageNo uint32 `protobuf:"varint,2,opt,name=page_no,json=pageNo,proto3" json:"page_no,omitempty"`
PageSize uint32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
TotalCount uint32 `protobuf:"varint,4,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PaginationRespSample) Reset() {
*x = PaginationRespSample{}
mi := &file_base_v1_msg_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PaginationRespSample) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PaginationRespSample) ProtoMessage() {}
func (x *PaginationRespSample) ProtoReflect() protoreflect.Message {
mi := &file_base_v1_msg_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PaginationRespSample.ProtoReflect.Descriptor instead.
func (*PaginationRespSample) Descriptor() ([]byte, []int) {
return file_base_v1_msg_proto_rawDescGZIP(), []int{5}
}
func (x *PaginationRespSample) GetPageNo() uint32 {
if x != nil {
return x.PageNo
}
return 0
}
func (x *PaginationRespSample) GetPageSize() uint32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *PaginationRespSample) GetTotalCount() uint32 {
if x != nil {
return x.TotalCount
}
return 0
}
var File_base_v1_msg_proto protoreflect.FileDescriptor
const file_base_v1_msg_proto_rawDesc = "" +
"\n" +
"\x11base/v1/msg.proto\x12\abase.v1\"\a\n" +
"\x05Empty\"%\n" +
"\tStatusRes\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\"\x17\n" +
"\x05IdRes\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\"\x17\n" +
"\x05IdReq\x12\x0e\n" +
"\x02id\x18\x01 \x01(\x03R\x02id\"\x1c\n" +
"\bYesNoRes\x12\x10\n" +
"\x03yes\x18\x01 \x01(\bR\x03yes\"m\n" +
"\x14PaginationRespSample\x12\x17\n" +
"\apage_no\x18\x02 \x01(\rR\x06pageNo\x12\x1b\n" +
"\tpage_size\x18\x03 \x01(\rR\bpageSize\x12\x1f\n" +
"\vtotal_count\x18\x04 \x01(\rR\n" +
"totalCountBk\n" +
"\vcom.base.v1B\bMsgProtoP\x01Z\x15gl/gen/base/v1;basev1\xa2\x02\x03BXX\xaa\x02\aBase.V1\xca\x02\aBase\\V1\xe2\x02\x13Base\\V1\\GPBMetadata\xea\x02\bBase::V1b\x06proto3"
var (
file_base_v1_msg_proto_rawDescOnce sync.Once
file_base_v1_msg_proto_rawDescData []byte
)
func file_base_v1_msg_proto_rawDescGZIP() []byte {
file_base_v1_msg_proto_rawDescOnce.Do(func() {
file_base_v1_msg_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_base_v1_msg_proto_rawDesc), len(file_base_v1_msg_proto_rawDesc)))
})
return file_base_v1_msg_proto_rawDescData
}
var file_base_v1_msg_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
var file_base_v1_msg_proto_goTypes = []any{
(*Empty)(nil), // 0: base.v1.Empty
(*StatusRes)(nil), // 1: base.v1.StatusRes
(*IdRes)(nil), // 2: base.v1.IdRes
(*IdReq)(nil), // 3: base.v1.IdReq
(*YesNoRes)(nil), // 4: base.v1.YesNoRes
(*PaginationRespSample)(nil), // 5: base.v1.PaginationRespSample
}
var file_base_v1_msg_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_base_v1_msg_proto_init() }
func file_base_v1_msg_proto_init() {
if File_base_v1_msg_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_base_v1_msg_proto_rawDesc), len(file_base_v1_msg_proto_rawDesc)),
NumEnums: 0,
NumMessages: 6,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_base_v1_msg_proto_goTypes,
DependencyIndexes: file_base_v1_msg_proto_depIdxs,
MessageInfos: file_base_v1_msg_proto_msgTypes,
}.Build()
File_base_v1_msg_proto = out.File
file_base_v1_msg_proto_goTypes = nil
file_base_v1_msg_proto_depIdxs = nil
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc (unknown)
// source: ledger/v1/srv.proto
package ledgerv1
import (
v1 "gl/gen/base/v1"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
var File_ledger_v1_srv_proto protoreflect.FileDescriptor
const file_ledger_v1_srv_proto_rawDesc = "" +
"\n" +
"\x13ledger/v1/srv.proto\x12\tledger.v1\x1a\x11base/v1/msg.proto\x1a\x13ledger/v1/msg.proto2\xf2\t\n" +
"\x14GeneralLedgerService\x12D\n" +
"\x10GetOperatingMode\x12\x0e.base.v1.Empty\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
"\x10SetOperatingMode\x12\".ledger.v1.SetOperatingModeRequest\x1a .ledger.v1.OperatingModeResponse\x12X\n" +
"\x13PostInternalJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12X\n" +
"\x0fGetUserBalances\x12!.ledger.v1.GetUserBalancesRequest\x1a\".ledger.v1.GetUserBalancesResponse\x123\n" +
"\x06Health\x12\x0e.base.v1.Empty\x1a\x19.ledger.v1.HealthResponse\x12R\n" +
"\rAppendJournal\x12\x1f.ledger.v1.AppendJournalRequest\x1a .ledger.v1.AppendJournalResponse\x12m\n" +
"\x16AppendTransactionEvent\x12(.ledger.v1.AppendTransactionEventRequest\x1a).ledger.v1.AppendTransactionEventResponse\x12>\n" +
"\n" +
"GetJournal\x12\x1c.ledger.v1.GetJournalRequest\x1a\x12.ledger.v1.Journal\x12L\n" +
"\vListEntries\x12\x1d.ledger.v1.ListEntriesRequest\x1a\x1e.ledger.v1.ListEntriesResponse\x12I\n" +
"\n" +
"GetBalance\x12\x1c.ledger.v1.GetBalanceRequest\x1a\x1d.ledger.v1.GetBalanceResponse\x12U\n" +
"\x0eReplayJournals\x12 .ledger.v1.ReplayJournalsRequest\x1a!.ledger.v1.ReplayJournalsResponse\x12^\n" +
"\x11EnqueueSettlement\x12#.ledger.v1.EnqueueSettlementRequest\x1a$.ledger.v1.EnqueueSettlementResponse\x12G\n" +
"\rGetSettlement\x12\x1f.ledger.v1.GetSettlementRequest\x1a\x15.ledger.v1.Settlement\x12X\n" +
"\x0fRetrySettlement\x12!.ledger.v1.RetrySettlementRequest\x1a\".ledger.v1.RetrySettlementResponse\x12[\n" +
"\x10IgnoreSettlement\x12\".ledger.v1.IgnoreSettlementRequest\x1a#.ledger.v1.IgnoreSettlementResponseBy\n" +
"\rcom.ledger.v1B\bSrvProtoP\x01Z\x19gl/gen/ledger/v1;ledgerv1\xa2\x02\x03LXX\xaa\x02\tLedger.V1\xca\x02\tLedger\\V1\xe2\x02\x15Ledger\\V1\\GPBMetadata\xea\x02\n" +
"Ledger::V1b\x06proto3"
var file_ledger_v1_srv_proto_goTypes = []any{
(*v1.Empty)(nil), // 0: base.v1.Empty
(*SetOperatingModeRequest)(nil), // 1: ledger.v1.SetOperatingModeRequest
(*AppendJournalRequest)(nil), // 2: ledger.v1.AppendJournalRequest
(*GetUserBalancesRequest)(nil), // 3: ledger.v1.GetUserBalancesRequest
(*AppendTransactionEventRequest)(nil), // 4: ledger.v1.AppendTransactionEventRequest
(*GetJournalRequest)(nil), // 5: ledger.v1.GetJournalRequest
(*ListEntriesRequest)(nil), // 6: ledger.v1.ListEntriesRequest
(*GetBalanceRequest)(nil), // 7: ledger.v1.GetBalanceRequest
(*ReplayJournalsRequest)(nil), // 8: ledger.v1.ReplayJournalsRequest
(*EnqueueSettlementRequest)(nil), // 9: ledger.v1.EnqueueSettlementRequest
(*GetSettlementRequest)(nil), // 10: ledger.v1.GetSettlementRequest
(*RetrySettlementRequest)(nil), // 11: ledger.v1.RetrySettlementRequest
(*IgnoreSettlementRequest)(nil), // 12: ledger.v1.IgnoreSettlementRequest
(*OperatingModeResponse)(nil), // 13: ledger.v1.OperatingModeResponse
(*AppendJournalResponse)(nil), // 14: ledger.v1.AppendJournalResponse
(*GetUserBalancesResponse)(nil), // 15: ledger.v1.GetUserBalancesResponse
(*HealthResponse)(nil), // 16: ledger.v1.HealthResponse
(*AppendTransactionEventResponse)(nil), // 17: ledger.v1.AppendTransactionEventResponse
(*Journal)(nil), // 18: ledger.v1.Journal
(*ListEntriesResponse)(nil), // 19: ledger.v1.ListEntriesResponse
(*GetBalanceResponse)(nil), // 20: ledger.v1.GetBalanceResponse
(*ReplayJournalsResponse)(nil), // 21: ledger.v1.ReplayJournalsResponse
(*EnqueueSettlementResponse)(nil), // 22: ledger.v1.EnqueueSettlementResponse
(*Settlement)(nil), // 23: ledger.v1.Settlement
(*RetrySettlementResponse)(nil), // 24: ledger.v1.RetrySettlementResponse
(*IgnoreSettlementResponse)(nil), // 25: ledger.v1.IgnoreSettlementResponse
}
var file_ledger_v1_srv_proto_depIdxs = []int32{
0, // 0: ledger.v1.GeneralLedgerService.GetOperatingMode:input_type -> base.v1.Empty
1, // 1: ledger.v1.GeneralLedgerService.SetOperatingMode:input_type -> ledger.v1.SetOperatingModeRequest
2, // 2: ledger.v1.GeneralLedgerService.PostInternalJournal:input_type -> ledger.v1.AppendJournalRequest
3, // 3: ledger.v1.GeneralLedgerService.GetUserBalances:input_type -> ledger.v1.GetUserBalancesRequest
0, // 4: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
2, // 5: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
4, // 6: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
5, // 7: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
6, // 8: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
7, // 9: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
8, // 10: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
9, // 11: ledger.v1.GeneralLedgerService.EnqueueSettlement:input_type -> ledger.v1.EnqueueSettlementRequest
10, // 12: ledger.v1.GeneralLedgerService.GetSettlement:input_type -> ledger.v1.GetSettlementRequest
11, // 13: ledger.v1.GeneralLedgerService.RetrySettlement:input_type -> ledger.v1.RetrySettlementRequest
12, // 14: ledger.v1.GeneralLedgerService.IgnoreSettlement:input_type -> ledger.v1.IgnoreSettlementRequest
13, // 15: ledger.v1.GeneralLedgerService.GetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
13, // 16: ledger.v1.GeneralLedgerService.SetOperatingMode:output_type -> ledger.v1.OperatingModeResponse
14, // 17: ledger.v1.GeneralLedgerService.PostInternalJournal:output_type -> ledger.v1.AppendJournalResponse
15, // 18: ledger.v1.GeneralLedgerService.GetUserBalances:output_type -> ledger.v1.GetUserBalancesResponse
16, // 19: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
14, // 20: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
17, // 21: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
18, // 22: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
19, // 23: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
20, // 24: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
21, // 25: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
22, // 26: ledger.v1.GeneralLedgerService.EnqueueSettlement:output_type -> ledger.v1.EnqueueSettlementResponse
23, // 27: ledger.v1.GeneralLedgerService.GetSettlement:output_type -> ledger.v1.Settlement
24, // 28: ledger.v1.GeneralLedgerService.RetrySettlement:output_type -> ledger.v1.RetrySettlementResponse
25, // 29: ledger.v1.GeneralLedgerService.IgnoreSettlement:output_type -> ledger.v1.IgnoreSettlementResponse
15, // [15:30] is the sub-list for method output_type
0, // [0:15] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_ledger_v1_srv_proto_init() }
func file_ledger_v1_srv_proto_init() {
if File_ledger_v1_srv_proto != nil {
return
}
file_ledger_v1_msg_proto_init()
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_ledger_v1_srv_proto_rawDesc), len(file_ledger_v1_srv_proto_rawDesc)),
NumEnums: 0,
NumMessages: 0,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_ledger_v1_srv_proto_goTypes,
DependencyIndexes: file_ledger_v1_srv_proto_depIdxs,
}.Build()
File_ledger_v1_srv_proto = out.File
file_ledger_v1_srv_proto_goTypes = nil
file_ledger_v1_srv_proto_depIdxs = nil
}
+662
View File
@@ -0,0 +1,662 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc (unknown)
// source: ledger/v1/srv.proto
package ledgerv1
import (
context "context"
v1 "gl/gen/base/v1"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GeneralLedgerService_GetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/GetOperatingMode"
GeneralLedgerService_SetOperatingMode_FullMethodName = "/ledger.v1.GeneralLedgerService/SetOperatingMode"
GeneralLedgerService_PostInternalJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/PostInternalJournal"
GeneralLedgerService_GetUserBalances_FullMethodName = "/ledger.v1.GeneralLedgerService/GetUserBalances"
GeneralLedgerService_Health_FullMethodName = "/ledger.v1.GeneralLedgerService/Health"
GeneralLedgerService_AppendJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendJournal"
GeneralLedgerService_AppendTransactionEvent_FullMethodName = "/ledger.v1.GeneralLedgerService/AppendTransactionEvent"
GeneralLedgerService_GetJournal_FullMethodName = "/ledger.v1.GeneralLedgerService/GetJournal"
GeneralLedgerService_ListEntries_FullMethodName = "/ledger.v1.GeneralLedgerService/ListEntries"
GeneralLedgerService_GetBalance_FullMethodName = "/ledger.v1.GeneralLedgerService/GetBalance"
GeneralLedgerService_ReplayJournals_FullMethodName = "/ledger.v1.GeneralLedgerService/ReplayJournals"
GeneralLedgerService_EnqueueSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/EnqueueSettlement"
GeneralLedgerService_GetSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/GetSettlement"
GeneralLedgerService_RetrySettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/RetrySettlement"
GeneralLedgerService_IgnoreSettlement_FullMethodName = "/ledger.v1.GeneralLedgerService/IgnoreSettlement"
)
// GeneralLedgerServiceClient is the client API for GeneralLedgerService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type GeneralLedgerServiceClient interface {
GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error)
// Operator-only; requires x-admin-token and an optimistic revision.
SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error)
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error)
Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error)
AppendJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error)
AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error)
GetJournal(ctx context.Context, in *GetJournalRequest, opts ...grpc.CallOption) (*Journal, error)
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error)
ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error)
EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error)
GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error)
// Restricted operator command. Requires the configured x-admin-token metadata.
RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error)
// Operator-reviewed, terminal dismissal. Does not confirm or reverse payment.
// Requires x-admin-token and a recorded reason; only unlocked failed settlements.
IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error)
}
type generalLedgerServiceClient struct {
cc grpc.ClientConnInterface
}
func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerServiceClient {
return &generalLedgerServiceClient{cc}
}
func (c *generalLedgerServiceClient) GetOperatingMode(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OperatingModeResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetOperatingMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) SetOperatingMode(ctx context.Context, in *SetOperatingModeRequest, opts ...grpc.CallOption) (*OperatingModeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(OperatingModeResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_SetOperatingMode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) PostInternalJournal(ctx context.Context, in *AppendJournalRequest, opts ...grpc.CallOption) (*AppendJournalResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AppendJournalResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_PostInternalJournal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetUserBalances(ctx context.Context, in *GetUserBalancesRequest, opts ...grpc.CallOption) (*GetUserBalancesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserBalancesResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetUserBalances_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) Health(ctx context.Context, in *v1.Empty, opts ...grpc.CallOption) (*HealthResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HealthResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_Health_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) AppendJournal(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_AppendJournal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) AppendTransactionEvent(ctx context.Context, in *AppendTransactionEventRequest, opts ...grpc.CallOption) (*AppendTransactionEventResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AppendTransactionEventResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_AppendTransactionEvent_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetJournal(ctx context.Context, in *GetJournalRequest, opts ...grpc.CallOption) (*Journal, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Journal)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetJournal_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListEntriesResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_ListEntries_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetBalance(ctx context.Context, in *GetBalanceRequest, opts ...grpc.CallOption) (*GetBalanceResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBalanceResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetBalance_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) ReplayJournals(ctx context.Context, in *ReplayJournalsRequest, opts ...grpc.CallOption) (*ReplayJournalsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReplayJournalsResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_ReplayJournals_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) EnqueueSettlement(ctx context.Context, in *EnqueueSettlementRequest, opts ...grpc.CallOption) (*EnqueueSettlementResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EnqueueSettlementResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_EnqueueSettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) GetSettlement(ctx context.Context, in *GetSettlementRequest, opts ...grpc.CallOption) (*Settlement, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Settlement)
err := c.cc.Invoke(ctx, GeneralLedgerService_GetSettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) RetrySettlement(ctx context.Context, in *RetrySettlementRequest, opts ...grpc.CallOption) (*RetrySettlementResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(RetrySettlementResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_RetrySettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *generalLedgerServiceClient) IgnoreSettlement(ctx context.Context, in *IgnoreSettlementRequest, opts ...grpc.CallOption) (*IgnoreSettlementResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(IgnoreSettlementResponse)
err := c.cc.Invoke(ctx, GeneralLedgerService_IgnoreSettlement_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GeneralLedgerServiceServer is the server API for GeneralLedgerService service.
// All implementations should embed UnimplementedGeneralLedgerServiceServer
// for forward compatibility.
type GeneralLedgerServiceServer interface {
GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error)
// Operator-only; requires x-admin-token and an optimistic revision.
SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error)
// Trusted internal execution; requires x-admin-token and GL_ONLY mode.
PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error)
Health(context.Context, *v1.Empty) (*HealthResponse, error)
AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error)
AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error)
GetJournal(context.Context, *GetJournalRequest) (*Journal, error)
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error)
ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error)
EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error)
GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error)
// Restricted operator command. Requires the configured x-admin-token metadata.
RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error)
// Operator-reviewed, terminal dismissal. Does not confirm or reverse payment.
// Requires x-admin-token and a recorded reason; only unlocked failed settlements.
IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error)
}
// UnimplementedGeneralLedgerServiceServer should be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGeneralLedgerServiceServer struct{}
func (UnimplementedGeneralLedgerServiceServer) GetOperatingMode(context.Context, *v1.Empty) (*OperatingModeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetOperatingMode not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) SetOperatingMode(context.Context, *SetOperatingModeRequest) (*OperatingModeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetOperatingMode not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) PostInternalJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method PostInternalJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetUserBalances(context.Context, *GetUserBalancesRequest) (*GetUserBalancesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUserBalances not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Health not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AppendJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AppendTransactionEvent not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetJournal not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetBalance not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReplayJournals not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) EnqueueSettlement(context.Context, *EnqueueSettlementRequest) (*EnqueueSettlementResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method EnqueueSettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) GetSettlement(context.Context, *GetSettlementRequest) (*Settlement, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetSettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) RetrySettlement(context.Context, *RetrySettlementRequest) (*RetrySettlementResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RetrySettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) IgnoreSettlement(context.Context, *IgnoreSettlementRequest) (*IgnoreSettlementResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method IgnoreSettlement not implemented")
}
func (UnimplementedGeneralLedgerServiceServer) testEmbeddedByValue() {}
// UnsafeGeneralLedgerServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GeneralLedgerServiceServer will
// result in compilation errors.
type UnsafeGeneralLedgerServiceServer interface {
mustEmbedUnimplementedGeneralLedgerServiceServer()
}
func RegisterGeneralLedgerServiceServer(s grpc.ServiceRegistrar, srv GeneralLedgerServiceServer) {
// If the following call pancis, it indicates UnimplementedGeneralLedgerServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GeneralLedgerService_ServiceDesc, srv)
}
func _GeneralLedgerService_GetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(v1.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetOperatingMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetOperatingMode(ctx, req.(*v1.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_SetOperatingMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetOperatingModeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_SetOperatingMode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).SetOperatingMode(ctx, req.(*SetOperatingModeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_PostInternalJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AppendJournalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_PostInternalJournal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).PostInternalJournal(ctx, req.(*AppendJournalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetUserBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserBalancesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetUserBalances_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetUserBalances(ctx, req.(*GetUserBalancesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_Health_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(v1.Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).Health(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_Health_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).Health(ctx, req.(*v1.Empty))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_AppendJournal_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).AppendJournal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_AppendJournal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).AppendJournal(ctx, req.(*AppendJournalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_AppendTransactionEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AppendTransactionEventRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).AppendTransactionEvent(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_AppendTransactionEvent_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).AppendTransactionEvent(ctx, req.(*AppendTransactionEventRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetJournal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetJournalRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetJournal(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetJournal_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetJournal(ctx, req.(*GetJournalRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListEntriesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).ListEntries(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_ListEntries_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).ListEntries(ctx, req.(*ListEntriesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetBalance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBalanceRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetBalance(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetBalance_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetBalance(ctx, req.(*GetBalanceRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_ReplayJournals_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReplayJournalsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).ReplayJournals(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_ReplayJournals_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).ReplayJournals(ctx, req.(*ReplayJournalsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_EnqueueSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(EnqueueSettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_EnqueueSettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).EnqueueSettlement(ctx, req.(*EnqueueSettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_GetSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_GetSettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).GetSettlement(ctx, req.(*GetSettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_RetrySettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RetrySettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_RetrySettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).RetrySettlement(ctx, req.(*RetrySettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GeneralLedgerService_IgnoreSettlement_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(IgnoreSettlementRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GeneralLedgerService_IgnoreSettlement_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GeneralLedgerServiceServer).IgnoreSettlement(ctx, req.(*IgnoreSettlementRequest))
}
return interceptor(ctx, in, info, handler)
}
// GeneralLedgerService_ServiceDesc is the grpc.ServiceDesc for GeneralLedgerService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GeneralLedgerService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "ledger.v1.GeneralLedgerService",
HandlerType: (*GeneralLedgerServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetOperatingMode",
Handler: _GeneralLedgerService_GetOperatingMode_Handler,
},
{
MethodName: "SetOperatingMode",
Handler: _GeneralLedgerService_SetOperatingMode_Handler,
},
{
MethodName: "PostInternalJournal",
Handler: _GeneralLedgerService_PostInternalJournal_Handler,
},
{
MethodName: "GetUserBalances",
Handler: _GeneralLedgerService_GetUserBalances_Handler,
},
{
MethodName: "Health",
Handler: _GeneralLedgerService_Health_Handler,
},
{
MethodName: "AppendJournal",
Handler: _GeneralLedgerService_AppendJournal_Handler,
},
{
MethodName: "AppendTransactionEvent",
Handler: _GeneralLedgerService_AppendTransactionEvent_Handler,
},
{
MethodName: "GetJournal",
Handler: _GeneralLedgerService_GetJournal_Handler,
},
{
MethodName: "ListEntries",
Handler: _GeneralLedgerService_ListEntries_Handler,
},
{
MethodName: "GetBalance",
Handler: _GeneralLedgerService_GetBalance_Handler,
},
{
MethodName: "ReplayJournals",
Handler: _GeneralLedgerService_ReplayJournals_Handler,
},
{
MethodName: "EnqueueSettlement",
Handler: _GeneralLedgerService_EnqueueSettlement_Handler,
},
{
MethodName: "GetSettlement",
Handler: _GeneralLedgerService_GetSettlement_Handler,
},
{
MethodName: "RetrySettlement",
Handler: _GeneralLedgerService_RetrySettlement_Handler,
},
{
MethodName: "IgnoreSettlement",
Handler: _GeneralLedgerService_IgnoreSettlement_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "ledger/v1/srv.proto",
}
+23
View File
@@ -0,0 +1,23 @@
environment = "local"
[grpc]
host = "0.0.0.0"
port = 8600
timeout = "10s"
[db]
host = "127.0.0.1"
port = 5432
name = "gl_db"
user = "postgres"
password = ""
ssl-mode = "disable"
[settlement]
enabled = false
endpoint = ""
worker-id = "gl-local"
poll-interval = "5s"
batch-size = 20
max-attempts = 8
admin-token = ""
+65
View File
@@ -0,0 +1,65 @@
module gl
go 1.26
require (
github.com/a-h/templ v0.3.1020
github.com/jackc/pgx/v5 v5.4.3
github.com/knadh/koanf/parsers/toml v0.1.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.4
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
)
tool (
github.com/a-h/templ
github.com/a-h/templ/cmd/templ
)
+130
View File
@@ -0,0 +1,130 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18=
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+192
View File
@@ -0,0 +1,192 @@
// Package config owns GL configuration loading and validation.
package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
type Config struct {
Environment string `koanf:"environment"`
GRPC GRPCConfig `koanf:"grpc"`
Database DatabaseConfig `koanf:"db"`
Settlement SettlementConfig `koanf:"settlement"`
Telemetry TelemetryConfig `koanf:"telemetry"`
}
type DashboardConfig struct {
Environment string `koanf:"environment"`
HTTP HTTPConfig `koanf:"http"`
Database DatabaseConfig `koanf:"db"`
Telemetry TelemetryConfig `koanf:"telemetry"`
}
type TelemetryConfig struct {
Enabled bool `koanf:"enabled"`
Protocol string `koanf:"protocol"`
Endpoint string `koanf:"endpoint"`
Insecure bool `koanf:"insecure"`
Headers map[string]string `koanf:"headers"`
Sampler string `koanf:"sampler"`
SamplerArg string `koanf:"sampler-arg"`
ExportTimeout time.Duration `koanf:"export-timeout"`
BatchTimeout time.Duration `koanf:"batch-timeout"`
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
}
type GRPCConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
ShutdownTimeout time.Duration `koanf:"timeout"`
}
type HTTPConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
ReadHeaderTimeout time.Duration `koanf:"read-header-timeout"`
ShutdownTimeout time.Duration `koanf:"shutdown-timeout"`
}
type DatabaseConfig struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
Name string `koanf:"name"`
User string `koanf:"user"`
Password string `koanf:"password"`
GormLogLevel int `koanf:"gorm-log-level"`
SSLMode string `koanf:"-"`
}
type SettlementConfig struct {
Enabled bool `koanf:"enabled"`
Endpoint string `koanf:"endpoint"`
WorkerID string `koanf:"worker-id"`
PollInterval time.Duration `koanf:"poll-interval"`
BatchSize int `koanf:"batch-size"`
MaxAttempts int `koanf:"max-attempts"`
AdminToken string `koanf:"admin-token"`
}
func Load(path string) (*Config, error) {
cfg := &Config{
Environment: "local",
GRPC: GRPCConfig{
Host: "0.0.0.0",
Port: 8600,
ShutdownTimeout: 10 * time.Second,
},
Database: defaultDatabaseConfig(),
Settlement: SettlementConfig{WorkerID: "gl-settlement-1", PollInterval: 5 * time.Second, BatchSize: 20, MaxAttempts: 8},
}
if err := load(path, cfg); err != nil {
return nil, err
}
applyEnvironment(&cfg.Database, &cfg.Settlement)
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func LoadDashboard(path string) (*DashboardConfig, error) {
cfg := &DashboardConfig{
Environment: "local",
HTTP: HTTPConfig{
Host: "0.0.0.0",
Port: 8080,
ReadHeaderTimeout: 5 * time.Second,
ShutdownTimeout: 10 * time.Second,
},
Database: defaultDatabaseConfig(),
}
if err := load(path, cfg); err != nil {
return nil, err
}
applyEnvironment(&cfg.Database, nil)
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func applyEnvironment(database *DatabaseConfig, settlement *SettlementConfig) {
if value, ok := os.LookupEnv("DARANO_GL_DB_PASSWORD"); ok {
database.Password = value
}
if settlement == nil {
return
}
if value, ok := os.LookupEnv("DARANO_GL_SETTLEMENT_ENDPOINT"); ok {
settlement.Endpoint = strings.TrimRight(value, "/")
}
if value, ok := os.LookupEnv("DARANO_GL_ADMIN_TOKEN"); ok {
settlement.AdminToken = value
}
}
func load(path string, target any) error {
k := koanf.New(".")
if err := k.Load(file.Provider(path), toml.Parser()); err != nil {
return fmt.Errorf("load config: %w", err)
}
if err := k.Unmarshal("", target); err != nil {
return fmt.Errorf("decode config: %w", err)
}
return nil
}
func defaultDatabaseConfig() DatabaseConfig {
return DatabaseConfig{
Host: "127.0.0.1",
Port: 5432,
Name: "gl_db",
User: "postgres",
SSLMode: "disable",
}
}
func (c *Config) Validate() error {
if c.GRPC.Host == "" {
return fmt.Errorf("grpc host is required")
}
if c.GRPC.Port < 0 || c.GRPC.Port > 65535 {
return fmt.Errorf("grpc port must be between 0 and 65535")
}
if c.GRPC.ShutdownTimeout <= 0 {
return fmt.Errorf("grpc shutdown timeout must be positive")
}
if c.Environment == "production" && c.Settlement.AdminToken == "" {
return fmt.Errorf("settlement admin token is required in production")
}
return validateDatabase(c.Database)
}
func (c *DashboardConfig) Validate() error {
if c.HTTP.Host == "" {
return fmt.Errorf("http host is required")
}
if c.HTTP.Port < 0 || c.HTTP.Port > 65535 {
return fmt.Errorf("http port must be between 0 and 65535")
}
if c.HTTP.ReadHeaderTimeout <= 0 || c.HTTP.ShutdownTimeout <= 0 {
return fmt.Errorf("http timeouts must be positive")
}
return validateDatabase(c.Database)
}
func validateDatabase(database DatabaseConfig) error {
if database.Host == "" || database.Name == "" || database.User == "" {
return fmt.Errorf("database host, name, and user are required")
}
if database.Port < 1 || database.Port > 65535 {
return fmt.Errorf("database port must be between 1 and 65535")
}
return nil
}
+109
View File
@@ -0,0 +1,109 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadUsesDefaultsAndOverrides(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gl.toml")
contents := []byte("[grpc]\nport = 0\ntimeout = \"3s\"\n")
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.GRPC.Port != 0 || cfg.GRPC.ShutdownTimeout != 3*time.Second {
t.Fatalf("unexpected grpc config: %+v", cfg.GRPC)
}
if cfg.Database.Name != "gl_db" || cfg.Database.Port != 5432 {
t.Fatalf("unexpected database defaults: %+v", cfg.Database)
}
}
func TestLoadDashboardUsesHTTPDefaultsAndOverrides(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dashboard.toml")
contents := []byte("[http]\nport = 0\nshutdown-timeout = \"3s\"\n[db]\nname = \"dashboard_db\"\n")
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadDashboard(path)
if err != nil {
t.Fatal(err)
}
if cfg.HTTP.Port != 0 || cfg.HTTP.ShutdownTimeout != 3*time.Second || cfg.HTTP.ReadHeaderTimeout != 5*time.Second {
t.Fatalf("unexpected http config: %+v", cfg.HTTP)
}
if cfg.Database.Name != "dashboard_db" || cfg.Database.Port != 5432 {
t.Fatalf("unexpected database defaults: %+v", cfg.Database)
}
}
func TestLoadRejectsInvalidConfiguration(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gl.toml")
if err := os.WriteFile(path, []byte("[grpc]\ntimeout = \"0s\"\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("expected validation error")
}
}
func TestLoadReturnsMissingFileError(t *testing.T) {
if _, err := Load(filepath.Join(t.TempDir(), "missing.toml")); err == nil {
t.Fatal("expected missing file error")
}
}
func TestLoadAppliesSecretEnvironmentOverrides(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gl.toml")
contents := []byte("environment = \"production\"\n[grpc]\ntimeout = \"3s\"\n[settlement]\nenabled = true\nendpoint = \"https://invalid.example\"\n")
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret")
t.Setenv("DARANO_GL_ADMIN_TOKEN", "runtime-admin-secret")
t.Setenv("DARANO_GL_SETTLEMENT_ENDPOINT", "https://horizon.example/")
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Database.Password != "runtime-db-secret" {
t.Fatal("database password was not loaded from the environment")
}
if cfg.Settlement.AdminToken != "runtime-admin-secret" {
t.Fatal("admin token was not loaded from the environment")
}
if cfg.Settlement.Endpoint != "https://horizon.example" {
t.Fatalf("unexpected settlement endpoint: %q", cfg.Settlement.Endpoint)
}
}
func TestLoadDashboardAppliesDatabasePasswordEnvironmentOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "dashboard.toml")
if err := os.WriteFile(path, []byte("[http]\nport = 0\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DARANO_GL_DB_PASSWORD", "runtime-db-secret")
cfg, err := LoadDashboard(path)
if err != nil {
t.Fatal(err)
}
if cfg.Database.Password != "runtime-db-secret" {
t.Fatal("database password was not loaded from the environment")
}
}
+142
View File
@@ -0,0 +1,142 @@
package kuknos
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"gl/domain/ledger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
var tracer = otel.Tracer("gl/infrastructure/kuknos")
// Submitter sends a pre-signed Stellar/Kuknos transaction to Horizon. Signing
// remains outside GL; GL owns durable admission, retry, and reconciliation.
type Submitter struct {
Endpoint string
Client *http.Client
}
func (s Submitter) Submit(ctx context.Context, record ledger.Settlement) (string, error) {
ctx, span := tracer.Start(ctx, "kuknos.submit_transaction", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
span.SetAttributes(
attribute.String("kuknos.network", record.Network),
attribute.String("settlement.id", record.ID),
attribute.String("settlement.source_service", record.SourceService),
attribute.String("settlement.source_transaction_id", record.SourceTxID),
attribute.String("settlement.idempotency_key", record.IdempotencyKey),
attribute.Int("settlement.attempt", record.Attempts),
)
if s.Endpoint == "" || record.SignedTransactionXDR == "" {
return s.fail(ctx, span, record, fmt.Errorf("kuknos submission endpoint and signed xdr are required"))
}
endpoint := strings.TrimRight(s.Endpoint, "/") + "/transactions"
form := url.Values{"tx": {record.SignedTransactionXDR}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return s.fail(ctx, span, record, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := s.Client
if client == nil {
client = http.DefaultClient
}
startedAt := time.Now()
slog.InfoContext(ctx, "kuknos transaction submission started",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"endpoint", endpoint,
)
resp, err := client.Do(req)
if err != nil {
return s.fail(ctx, span, record, err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return s.fail(ctx, span, record, fmt.Errorf("read kuknos response: %w", err))
}
rawResponse := string(responseBody)
var response struct {
Hash string `json:"hash"`
Extras struct {
ResultCodes struct {
Transaction string `json:"transaction"`
} `json:"result_codes"`
} `json:"extras"`
}
if err := json.Unmarshal(responseBody, &response); err != nil {
slog.ErrorContext(ctx, "kuknos transaction submission returned an invalid response",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"status_code", resp.StatusCode,
"stellar_response", rawResponse,
"error", err,
)
return s.fail(ctx, span, record, fmt.Errorf("decode kuknos response: %w", err))
}
latency := time.Since(startedAt)
span.SetAttributes(
attribute.Int("http.response.status_code", resp.StatusCode),
attribute.Int64("http.response.body.size", int64(len(responseBody))),
attribute.Int64("kuknos.submission.duration_ms", latency.Milliseconds()),
)
span.AddEvent("kuknos.response", trace.WithAttributes(
attribute.String("stellar_response", rawResponse),
))
span.SetStatus(codes.Ok, "submission response received")
slog.InfoContext(ctx, "kuknos transaction submission response",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"status_code", resp.StatusCode,
"transaction_hash", response.Hash,
"transaction_result_code", response.Extras.ResultCodes.Transaction,
"stellar_response", rawResponse,
"duration_ms", latency.Milliseconds(),
)
if resp.StatusCode < 200 || resp.StatusCode >= 300 || response.Hash == "" {
err := fmt.Errorf("kuknos rejected transaction: status=%d code=%s", resp.StatusCode, response.Extras.ResultCodes.Transaction)
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
if response.Extras.ResultCodes.Transaction == "tx_bad_seq" {
return "", ledger.PermanentSubmissionError{Err: err}
}
return "", err
}
return response.Hash, nil
}
func (s Submitter) fail(ctx context.Context, span trace.Span, record ledger.Settlement, err error) (string, error) {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
slog.ErrorContext(ctx, "kuknos transaction submission failed",
"settlement_id", record.ID,
"source_transaction_id", record.SourceTxID,
"idempotency_key", record.IdempotencyKey,
"network", record.Network,
"attempt", record.Attempts,
"error", err,
)
return "", err
}
+61
View File
@@ -0,0 +1,61 @@
package observability
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
// Configure installs the process-wide JSON logger used by every GL adapter.
func Configure(service string, level slog.Leveler) *slog.Logger {
handler := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
AddSource: true,
Level: level,
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case slog.TimeKey:
attr.Key = "timestamp"
case slog.LevelKey:
attr.Key = "severity"
attr.Value = slog.StringValue(levelName(attr.Value))
case slog.MessageKey:
attr.Key = "message"
case slog.SourceKey:
attr.Key = "source"
}
return attr
},
})
logger := slog.New(&traceHandler{Handler: handler}).With("service", service)
slog.SetDefault(logger)
return logger
}
func levelName(value slog.Value) string {
if value.Kind() == slog.KindInt64 {
return slog.Level(value.Int64()).String()
}
if level, ok := value.Any().(slog.Level); ok {
return level.String()
}
return value.String()
}
type traceHandler struct{ slog.Handler }
func (h *traceHandler) Handle(ctx context.Context, record slog.Record) error {
spanContext := trace.SpanContextFromContext(ctx)
if spanContext.IsValid() {
record.AddAttrs(slog.String("trace_id", spanContext.TraceID().String()), slog.String("span_id", spanContext.SpanID().String()))
}
return h.Handler.Handle(ctx, record)
}
func (h *traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &traceHandler{Handler: h.Handler.WithAttrs(attrs)}
}
func (h *traceHandler) WithGroup(name string) slog.Handler {
return &traceHandler{Handler: h.Handler.WithGroup(name)}
}
+39
View File
@@ -0,0 +1,39 @@
package observability
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
"gl/infrastructure/config"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// Setup configures tracing without making telemetry availability a startup dependency.
func Setup(ctx context.Context, cfg config.TelemetryConfig, service, environment string) func(context.Context) {
if value := os.Getenv("OTEL_SERVICE_NAME"); value != "" { service = value }
if value := os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"); value != "" { cfg.Endpoint = value } else if value := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); value != "" { cfg.Endpoint = value }
if value := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"); value != "" { cfg.Protocol = value }
if value := os.Getenv("OTEL_TRACES_SAMPLER"); value != "" { cfg.Sampler = value }; if value := os.Getenv("OTEL_TRACES_SAMPLER_ARG"); value != "" { cfg.SamplerArg = value }
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
if !cfg.Enabled || cfg.Endpoint == "" || (cfg.Protocol != "grpc" && cfg.Protocol != "http/protobuf") { if cfg.Enabled { slog.Warn("OpenTelemetry disabled due to invalid configuration") }; return func(context.Context) {} }
var exp sdktrace.SpanExporter; var err error
if cfg.Protocol == "grpc" { options := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(cfg.Endpoint), otlptracegrpc.WithTimeout(timeout(cfg.ExportTimeout)), otlptracegrpc.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracegrpc.WithInsecure()) }; exp, err = otlptracegrpc.New(ctx, options...) } else { options := []otlptracehttp.Option{otlptracehttp.WithEndpoint(cfg.Endpoint), otlptracehttp.WithTimeout(timeout(cfg.ExportTimeout)), otlptracehttp.WithHeaders(cfg.Headers)}; if cfg.Insecure { options = append(options, otlptracehttp.WithInsecure()) }; exp, err = otlptracehttp.New(ctx, options...) }
if err != nil { slog.Warn("OpenTelemetry exporter unavailable; tracing disabled", "error", err); return func(context.Context) {} }
host, _ := os.Hostname(); res, err := resource.New(ctx, resource.WithAttributes(attribute.String("service.name", service), attribute.String("service.namespace", "darano"), attribute.String("deployment.environment.name", environment), attribute.String("host.name", host))); if err != nil { slog.Warn("OpenTelemetry resource setup failed", "error", err); return func(context.Context) {} }
ratio, parseErr := strconv.ParseFloat(cfg.SamplerArg, 64); if cfg.Sampler == "parentbased_traceidratio" && (parseErr != nil || ratio < 0 || ratio > 1) { slog.Warn("OpenTelemetry disabled due to invalid sampler", "error", fmt.Errorf("invalid sampler-arg")); return func(context.Context) {} }
provider := sdktrace.NewTracerProvider(sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))), sdktrace.WithBatcher(exp, sdktrace.WithMaxQueueSize(2048), sdktrace.WithBatchTimeout(timeout(cfg.BatchTimeout)), sdktrace.WithExportTimeout(timeout(cfg.ExportTimeout))))
otel.SetTracerProvider(provider); return func(stop context.Context) { stop, cancel := context.WithTimeout(stop, timeout(cfg.ShutdownTimeout)); defer cancel(); if err := provider.Shutdown(stop); err != nil { slog.Warn("OpenTelemetry shutdown failed", "error", err) } }
}
func timeout(value time.Duration) time.Duration { if value > 0 { return value }; return 5*time.Second }
var _ = strings.TrimSpace
@@ -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
}
+104
View File
@@ -0,0 +1,104 @@
// Package postgres provides GL's PostgreSQL adapters.
package postgres
import (
"context"
"fmt"
"net/url"
"strconv"
"gl/infrastructure/config"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Row interface {
Scan(dest ...any) error
}
type Rows interface {
Next() bool
Scan(dest ...any) error
Err() error
Close()
}
type Tx interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
QueryRow(context.Context, string, ...any) Row
Commit(context.Context) error
Rollback(context.Context) error
}
type Database interface {
Begin(context.Context) (Tx, error)
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
QueryRow(context.Context, string, ...any) Row
Query(context.Context, string, ...any) (Rows, error)
Ping(context.Context) error
Close()
}
type Pool struct {
pool *pgxpool.Pool
}
func Open(ctx context.Context, cfg config.DatabaseConfig) (*Pool, error) {
connectionURL := &url.URL{
Scheme: "postgres",
User: url.UserPassword(cfg.User, cfg.Password),
Host: cfg.Host + ":" + strconv.Itoa(cfg.Port),
Path: cfg.Name,
}
query := connectionURL.Query()
query.Set("sslmode", cfg.SSLMode)
connectionURL.RawQuery = query.Encode()
pool, err := pgxpool.New(ctx, connectionURL.String())
if err != nil {
return nil, fmt.Errorf("create postgres pool: %w", err)
}
return &Pool{pool: pool}, nil
}
func (p *Pool) Begin(ctx context.Context) (Tx, error) {
tx, err := p.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return nil, err
}
return txAdapter{Tx: tx}, nil
}
func (p *Pool) Ping(ctx context.Context) error {
return p.pool.Ping(ctx)
}
func (p *Pool) QueryRow(ctx context.Context, sql string, args ...any) Row {
return p.pool.QueryRow(ctx, sql, args...)
}
func (p *Pool) Query(ctx context.Context, sql string, args ...any) (Rows, error) {
return p.pool.Query(ctx, sql, args...)
}
func (p *Pool) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
return p.pool.Exec(ctx, sql, args...)
}
func (p *Pool) Close() {
p.pool.Close()
}
type txAdapter struct {
pgx.Tx
}
func (t txAdapter) QueryRow(ctx context.Context, sql string, args ...any) Row {
return t.Tx.QueryRow(ctx, sql, args...)
}
func (t txAdapter) Query(ctx context.Context, sql string, args ...any) (Rows, error) {
return t.Tx.Query(ctx, sql, args...)
}
+106
View File
@@ -0,0 +1,106 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
)
func (r *JournalRepository) AppendEvent(ctx context.Context, event ledger.TransactionEvent) (ledger.TransactionEvent, bool, error) {
if err := event.Validate(); err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("validate transaction event: %w", err)
}
metadataValues := event.Metadata
if metadataValues == nil {
metadataValues = map[string]string{}
}
metadata, err := json.Marshal(metadataValues)
if err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("encode event metadata: %w", err)
}
err = r.database.QueryRow(ctx, insertEventSQL,
event.ID,
event.SourceService,
event.IdempotencyKey,
event.SourceTransactionID,
event.TrackingCode,
event.EventVersion,
event.State,
event.ErrorCode,
event.ErrorMessage,
event.OccurredAt,
event.CorrelationID,
event.ActorID,
event.Blockchain.Network,
event.Blockchain.TransactionHash,
event.Blockchain.LedgerSequence,
metadata,
event.PayloadHash,
).Scan(&event.RecordedAt)
if !errors.Is(err, pgx.ErrNoRows) {
if err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("insert transaction event: %w", err)
}
return event, false, nil
}
var stored ledger.TransactionEvent
var storedMetadata []byte
err = r.database.QueryRow(ctx, selectEventByIdempotencySQL, event.IdempotencyKey).Scan(
&stored.ID,
&stored.SourceService,
&stored.IdempotencyKey,
&stored.SourceTransactionID,
&stored.TrackingCode,
&stored.EventVersion,
&stored.State,
&stored.ErrorCode,
&stored.ErrorMessage,
&stored.OccurredAt,
&stored.RecordedAt,
&stored.CorrelationID,
&stored.ActorID,
&stored.Blockchain.Network,
&stored.Blockchain.TransactionHash,
&stored.Blockchain.LedgerSequence,
&storedMetadata,
&stored.PayloadHash,
)
if err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("read idempotent transaction event: %w", err)
}
if stored.PayloadHash != event.PayloadHash {
return ledger.TransactionEvent{}, false, ErrIdempotencyConflict
}
if err := json.Unmarshal(storedMetadata, &stored.Metadata); err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("decode event metadata: %w", err)
}
return stored, true, nil
}
const insertEventSQL = `
INSERT INTO transaction_events (
id, source_service, idempotency_key, source_transaction_id, tracking_code,
event_version, state, error_code, error_message, occurred_at, correlation_id,
actor_id, blockchain_network, blockchain_transaction_hash,
blockchain_ledger_sequence, metadata, payload_hash
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING recorded_at`
const selectEventByIdempotencySQL = `
SELECT id, source_service, idempotency_key, source_transaction_id,
tracking_code, event_version, state, error_code, error_message,
occurred_at, recorded_at, correlation_id, actor_id, blockchain_network,
blockchain_transaction_hash, blockchain_ledger_sequence, metadata,
payload_hash
FROM transaction_events
WHERE idempotency_key = $1`
@@ -0,0 +1,73 @@
package postgres
import (
"context"
"errors"
"strings"
"testing"
"time"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
)
func TestAppendEventStoresAndReturnsRecordingTime(t *testing.T) {
event := repositoryEvent()
recordedAt := time.Unix(2, 0).UTC()
database := &fakeDatabase{directRows: []Row{fakeRow{values: []any{recordedAt}}}}
stored, existed, err := NewJournalRepository(database).AppendEvent(context.Background(), event)
if err != nil {
t.Fatal(err)
}
if existed || !stored.RecordedAt.Equal(recordedAt) {
t.Fatalf("unexpected append result: existed=%v event=%+v", existed, stored)
}
}
func TestAppendEventRejectsConflictingIdempotencyPayload(t *testing.T) {
event := repositoryEvent()
storedHash := strings.Repeat("b", 64)
database := &fakeDatabase{directRows: []Row{
fakeRow{err: pgx.ErrNoRows},
fakeRow{values: []any{
event.ID,
event.SourceService,
event.IdempotencyKey,
event.SourceTransactionID,
event.TrackingCode,
event.EventVersion,
event.State,
event.ErrorCode,
event.ErrorMessage,
event.OccurredAt,
time.Unix(2, 0).UTC(),
event.CorrelationID,
event.ActorID,
event.Blockchain.Network,
event.Blockchain.TransactionHash,
event.Blockchain.LedgerSequence,
[]byte(`{}`),
storedHash,
}},
}}
_, _, err := NewJournalRepository(database).AppendEvent(context.Background(), event)
if !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("expected idempotency conflict, got %v", err)
}
}
func repositoryEvent() ledger.TransactionEvent {
return ledger.TransactionEvent{
ID: "11111111-1111-4111-8111-111111111111",
SourceService: "wallet",
IdempotencyKey: "wallet:1:status:v1",
SourceTransactionID: "1",
EventVersion: 1,
State: ledger.TransactionStateCreated,
OccurredAt: time.Unix(1, 0).UTC(),
PayloadHash: strings.Repeat("a", 64),
}
}
@@ -0,0 +1,209 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrIdempotencyConflict = ledger.ErrIdempotencyConflict
ErrIncompleteJournal = ledger.ErrIncompleteJournal
)
type AppendResult = ledger.AppendResult
type JournalRepository struct {
database Database
}
func NewJournalRepository(database Database) *JournalRepository {
return &JournalRepository{database: database}
}
func (r *JournalRepository) Append(ctx context.Context, journal ledger.Journal) (result AppendResult, err error) {
if err := journal.Validate(); err != nil {
return AppendResult{}, fmt.Errorf("validate journal: %w", err)
}
metadataValues := journal.Metadata
if metadataValues == nil {
metadataValues = map[string]string{}
}
metadata, err := json.Marshal(metadataValues)
if err != nil {
return AppendResult{}, fmt.Errorf("encode metadata: %w", err)
}
tx, err := r.database.Begin(ctx)
if err != nil {
return AppendResult{}, fmt.Errorf("begin journal append: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback(ctx)
if classified := authorityError(err); classified != nil {
err = classified
}
}
}()
var insertedID string
err = tx.QueryRow(ctx, insertJournalSQL,
journal.ID,
journal.SourceService,
journal.IdempotencyKey,
journal.SourceTransactionID,
journal.TrackingCode,
journal.EffectKind,
journal.EventVersion,
nullIfEmpty(journal.ReversalOfJournalID),
journal.OccurredAt,
journal.CorrelationID,
journal.ActorID,
journal.Blockchain.Network,
journal.Blockchain.TransactionHash,
journal.Blockchain.LedgerSequence,
metadata,
journal.PayloadHash,
).Scan(&insertedID)
if errors.Is(err, pgx.ErrNoRows) {
_ = tx.Rollback(ctx)
return r.resolveDuplicate(ctx, journal)
}
if err != nil {
var postgresError *pgconn.PgError
if classified := authorityError(err); classified != nil {
return AppendResult{}, classified
}
if errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
return AppendResult{}, ledger.ErrAlreadyReversed
}
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
}
// Resolve and lock accounts in stable order across every writer.
entries := append([]ledger.Entry(nil), journal.Entries...)
sort.Slice(entries, func(i, j int) bool {
left, right := entries[i].Account, entries[j].Account
if left.Class != right.Class {
return left.Class < right.Class
}
if left.OwnerType != right.OwnerType {
return left.OwnerType < right.OwnerType
}
if left.OwnerID != right.OwnerID {
return left.OwnerID < right.OwnerID
}
if left.AssetID != right.AssetID {
return left.AssetID < right.AssetID
}
return entries[i].LineNumber < entries[j].LineNumber
})
for _, entry := range entries {
var accountID int64
err = tx.QueryRow(ctx, insertAccountSQL,
entry.Account.Class,
entry.Account.OwnerType,
entry.Account.OwnerID,
entry.Account.AssetID,
).Scan(&accountID)
if errors.Is(err, pgx.ErrNoRows) {
err = tx.QueryRow(ctx, selectAccountSQL,
entry.Account.Class,
entry.Account.OwnerType,
entry.Account.OwnerID,
entry.Account.AssetID,
).Scan(&accountID)
}
if err != nil {
return AppendResult{}, fmt.Errorf("resolve entry %d account: %w", entry.LineNumber, err)
}
if _, err = tx.Exec(ctx, insertEntrySQL,
journal.ID,
entry.LineNumber,
accountID,
entry.Account.AssetID,
entry.Amount.String(),
entry.Description,
); err != nil {
return AppendResult{}, fmt.Errorf("insert entry %d: %w", entry.LineNumber, err)
}
}
if _, err = tx.Exec(ctx, "UPDATE journals SET sealed_at = clock_timestamp() WHERE id = $1", journal.ID); err != nil {
if classified := authorityError(err); classified != nil {
return AppendResult{}, classified
}
return AppendResult{}, fmt.Errorf("seal journal: %w", err)
}
if err = tx.Commit(ctx); err != nil {
return AppendResult{}, fmt.Errorf("commit journal: %w", err)
}
return AppendResult{JournalID: insertedID}, nil
}
func (r *JournalRepository) resolveDuplicate(ctx context.Context, journal ledger.Journal) (AppendResult, error) {
var (
journalID string
payloadHash string
sealed bool
)
err := r.database.QueryRow(ctx, selectIdempotencySQL, journal.IdempotencyKey).Scan(&journalID, &payloadHash, &sealed)
if err != nil {
return AppendResult{}, fmt.Errorf("read idempotent journal: %w", err)
}
if payloadHash != journal.PayloadHash {
return AppendResult{}, ErrIdempotencyConflict
}
if !sealed {
return AppendResult{}, ErrIncompleteJournal
}
return AppendResult{JournalID: journalID, AlreadyExists: true}, nil
}
func nullIfEmpty(value string) any {
if value == "" {
return nil
}
return value
}
const insertJournalSQL = `
INSERT INTO journals (
id, source_service, idempotency_key, source_transaction_id, tracking_code,
effect_kind, event_version, reversal_of_journal_id, occurred_at,
correlation_id, actor_id, blockchain_network, blockchain_transaction_hash,
blockchain_ledger_sequence, metadata, payload_hash
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`
const insertAccountSQL = `
INSERT INTO ledger_accounts (class, owner_type, owner_id, asset_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (class, owner_type, owner_id, asset_id) DO NOTHING
RETURNING id`
const selectAccountSQL = `
SELECT id FROM ledger_accounts
WHERE class = $1 AND owner_type = $2 AND owner_id = $3 AND asset_id = $4`
const insertEntrySQL = `
INSERT INTO journal_entries (
journal_id, line_number, account_id, asset_id, amount, description
) VALUES ($1, $2, $3, $4, $5, $6)`
const selectIdempotencySQL = `
SELECT id, payload_hash, sealed_at IS NOT NULL
FROM journals
WHERE idempotency_key = $1`
@@ -0,0 +1,239 @@
package postgres
import (
"context"
"errors"
"strings"
"testing"
"time"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type fakeRow struct {
values []any
err error
}
func (r fakeRow) Scan(dest ...any) error {
if r.err != nil {
return r.err
}
if len(dest) != len(r.values) {
return errors.New("unexpected scan width")
}
for index, value := range r.values {
switch target := dest[index].(type) {
case *string:
*target = value.(string)
case *int64:
*target = value.(int64)
case *int:
*target = value.(int)
case *ledger.SettlementStatus:
*target = value.(ledger.SettlementStatus)
case *bool:
*target = value.(bool)
case *uint32:
*target = value.(uint32)
case *time.Time:
*target = value.(time.Time)
case *ledger.TransactionState:
*target = value.(ledger.TransactionState)
case *[]byte:
*target = value.([]byte)
default:
return errors.New("unsupported scan target")
}
}
return nil
}
type fakeTx struct {
rows []Row
execCount int
execErrAt int
committed bool
rolledBack bool
}
func (t *fakeTx) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
t.execCount++
if t.execCount == t.execErrAt {
return pgconn.CommandTag{}, errors.New("exec failed")
}
return pgconn.NewCommandTag("INSERT 0 1"), nil
}
func (t *fakeTx) QueryRow(context.Context, string, ...any) Row {
row := t.rows[0]
t.rows = t.rows[1:]
return row
}
func (t *fakeTx) Commit(context.Context) error {
t.committed = true
return nil
}
func (t *fakeTx) Rollback(context.Context) error {
t.rolledBack = true
return nil
}
type fakeDatabase struct {
tx *fakeTx
directRow Row
directRows []Row
beginCalled bool
}
func (d *fakeDatabase) Begin(context.Context) (Tx, error) {
d.beginCalled = true
return d.tx, nil
}
func (d *fakeDatabase) QueryRow(context.Context, string, ...any) Row {
if len(d.directRows) > 0 {
row := d.directRows[0]
d.directRows = d.directRows[1:]
return row
}
return d.directRow
}
func (d *fakeDatabase) Query(context.Context, string, ...any) (Rows, error) {
return nil, errors.New("unexpected query")
}
func (d *fakeDatabase) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
return pgconn.CommandTag{}, errors.New("unexpected exec")
}
func (d *fakeDatabase) Ping(context.Context) error { return nil }
func (d *fakeDatabase) Close() {}
func TestJournalRepositoryAppendCommitsJournalEntriesAndSeal(t *testing.T) {
journal := repositoryJournal(t)
tx := &fakeTx{rows: []Row{
fakeRow{values: []any{journal.ID}},
fakeRow{values: []any{int64(10)}},
fakeRow{values: []any{int64(20)}},
}}
database := &fakeDatabase{tx: tx}
result, err := NewJournalRepository(database).Append(context.Background(), journal)
if err != nil {
t.Fatal(err)
}
if result.JournalID != journal.ID || result.AlreadyExists {
t.Fatalf("unexpected result: %+v", result)
}
if !tx.committed || tx.rolledBack {
t.Fatalf("unexpected transaction state: %+v", tx)
}
if tx.execCount != 3 {
t.Fatalf("expected two entry inserts and one seal, got %d execs", tx.execCount)
}
}
func TestJournalRepositoryReturnsExistingIdenticalJournal(t *testing.T) {
journal := repositoryJournal(t)
tx := &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}}
database := &fakeDatabase{
tx: tx,
directRow: fakeRow{values: []any{journal.ID, journal.PayloadHash, true}},
}
result, err := NewJournalRepository(database).Append(context.Background(), journal)
if err != nil {
t.Fatal(err)
}
if !result.AlreadyExists || result.JournalID != journal.ID {
t.Fatalf("unexpected result: %+v", result)
}
if !tx.rolledBack || tx.committed {
t.Fatalf("duplicate transaction was not rolled back: %+v", tx)
}
}
func TestJournalRepositoryRejectsConflictingIdempotencyPayload(t *testing.T) {
journal := repositoryJournal(t)
database := &fakeDatabase{
tx: &fakeTx{rows: []Row{fakeRow{err: pgx.ErrNoRows}}},
directRow: fakeRow{values: []any{journal.ID, strings.Repeat("b", 64), true}},
}
_, err := NewJournalRepository(database).Append(context.Background(), journal)
if !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("expected idempotency conflict, got %v", err)
}
}
func TestJournalRepositoryRollsBackEntryFailure(t *testing.T) {
journal := repositoryJournal(t)
tx := &fakeTx{
rows: []Row{
fakeRow{values: []any{journal.ID}},
fakeRow{values: []any{int64(10)}},
},
execErrAt: 1,
}
database := &fakeDatabase{tx: tx}
if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil {
t.Fatal("expected entry insert error")
}
if !tx.rolledBack || tx.committed {
t.Fatalf("failed append was not rolled back: %+v", tx)
}
}
func TestJournalRepositoryValidatesBeforeOpeningTransaction(t *testing.T) {
journal := repositoryJournal(t)
journal.Entries[1].Amount, _ = ledger.ParseAmount("9")
database := &fakeDatabase{tx: &fakeTx{}}
if _, err := NewJournalRepository(database).Append(context.Background(), journal); err == nil {
t.Fatal("expected validation error")
}
if database.beginCalled {
t.Fatal("invalid journal opened a database transaction")
}
}
func repositoryJournal(t *testing.T) ledger.Journal {
t.Helper()
debit, err := ledger.ParseAmount("-10.25")
if err != nil {
t.Fatal(err)
}
return ledger.Journal{
ID: "11111111-1111-4111-8111-111111111111",
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
TrackingCode: "track-1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
PayloadHash: strings.Repeat("a", 64),
Metadata: map[string]string{"origin": "test"},
Entries: []ledger.Entry{
{
LineNumber: 1,
Account: ledger.AccountReference{
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5,
},
Amount: debit,
},
{
LineNumber: 2,
Account: ledger.AccountReference{
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5,
},
Amount: debit.Negate(),
},
},
}
}
+75
View File
@@ -0,0 +1,75 @@
package postgres
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.up.sql
var migrationFiles embed.FS
const migrationLockID int64 = 674301
func Migrate(ctx context.Context, database Database) (err error) {
tx, err := database.Begin(ctx)
if err != nil {
return fmt.Errorf("begin migrations: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback(ctx)
}
}()
if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil {
return fmt.Errorf("lock migrations: %w", err)
}
if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
)`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
files, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(files)
for _, name := range files {
versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0]
version, parseErr := strconv.ParseInt(versionText, 10, 64)
if parseErr != nil {
return fmt.Errorf("parse migration %s: %w", name, parseErr)
}
var applied bool
if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if applied {
continue
}
contents, readErr := migrationFiles.ReadFile(name)
if readErr != nil {
return fmt.Errorf("read migration %s: %w", name, readErr)
}
if _, err = tx.Exec(ctx, string(contents)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
}
if err = tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migrations: %w", err)
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package postgres
import (
"strings"
"testing"
)
func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) {
contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql")
if err != nil {
t.Fatal(err)
}
schema := string(contents)
for _, required := range []string{
"amount numeric(38, 18)",
"idempotency_key text NOT NULL UNIQUE",
"guard_journal_seal",
"journal is not balanced per asset",
"cannot append to a sealed journal",
"reject_ledger_mutation",
"journals_one_reversal_idx",
} {
if !strings.Contains(schema, required) {
t.Fatalf("migration is missing %q", required)
}
}
}
func TestExplorerFilterMigrationAddsSupportingIndexes(t *testing.T) {
contents, err := migrationFiles.ReadFile("migrations/000002_explorer_filters.up.sql")
if err != nil {
t.Fatal(err)
}
schema := string(contents)
for _, required := range []string{"journals_effect_recorded_idx", "lower(effect_kind)", "ledger_accounts_user_owner_idx", "USER_AVAILABLE", "USER_FROZEN"} {
if !strings.Contains(schema, required) {
t.Fatalf("explorer filter migration is missing %q", required)
}
}
}
@@ -0,0 +1,14 @@
DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events;
DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts;
DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries;
DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries;
DROP TRIGGER IF EXISTS journals_reject_delete ON journals;
DROP TRIGGER IF EXISTS journals_guard_update ON journals;
DROP FUNCTION IF EXISTS guard_entry_insert();
DROP FUNCTION IF EXISTS guard_journal_seal();
DROP FUNCTION IF EXISTS reject_ledger_mutation();
DROP TABLE IF EXISTS transaction_events;
DROP TABLE IF EXISTS journal_entries;
DROP TABLE IF EXISTS journals;
DROP TABLE IF EXISTS ledger_accounts;
DROP TABLE IF EXISTS ledger_schema_migrations;
@@ -0,0 +1,176 @@
CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE ledger_accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class text NOT NULL CHECK (class IN (
'USER_AVAILABLE',
'USER_FROZEN',
'EXTERNAL_BLOCKCHAIN',
'TREASURY',
'MARKET_CLEARING',
'IPG_CLEARING',
'COMMISSION_REVENUE'
)),
owner_type text NOT NULL DEFAULT '',
owner_id text NOT NULL DEFAULT '',
asset_id bigint NOT NULL CHECK (asset_id > 0),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE (class, owner_type, owner_id, asset_id),
UNIQUE (id, asset_id),
CHECK (
class NOT IN ('USER_AVAILABLE', 'USER_FROZEN')
OR (owner_type <> '' AND owner_id <> '')
)
);
CREATE TABLE journals (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
tracking_code text NOT NULL DEFAULT '',
effect_kind text NOT NULL CHECK (effect_kind <> ''),
event_version integer NOT NULL CHECK (event_version > 0),
reversal_of_journal_id uuid REFERENCES journals (id),
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
sealed_at timestamptz,
correlation_id text NOT NULL DEFAULT '',
actor_id text NOT NULL DEFAULT '',
blockchain_network text NOT NULL DEFAULT '',
blockchain_transaction_hash text NOT NULL DEFAULT '',
blockchain_ledger_sequence text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id)
);
CREATE INDEX journals_source_transaction_idx
ON journals (source_service, source_transaction_id);
CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id);
CREATE UNIQUE INDEX journals_one_reversal_idx
ON journals (reversal_of_journal_id)
WHERE reversal_of_journal_id IS NOT NULL;
CREATE TABLE journal_entries (
journal_id uuid NOT NULL REFERENCES journals (id),
line_number integer NOT NULL CHECK (line_number > 0),
account_id bigint NOT NULL,
asset_id bigint NOT NULL CHECK (asset_id > 0),
amount numeric(38, 18) NOT NULL CHECK (amount <> 0),
description text NOT NULL DEFAULT '',
PRIMARY KEY (journal_id, line_number),
FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id)
);
CREATE INDEX journal_entries_account_idx
ON journal_entries (account_id, journal_id, line_number);
CREATE TABLE transaction_events (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
tracking_code text NOT NULL DEFAULT '',
event_version integer NOT NULL CHECK (event_version > 0),
state text NOT NULL CHECK (state IN (
'CREATED',
'PENDING_TRANSACTION',
'PENDING_ADMIN',
'SUCCESSFUL',
'FAILED',
'SUSPENDED'
)),
error_code text NOT NULL DEFAULT '',
error_message text NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
correlation_id text NOT NULL DEFAULT '',
actor_id text NOT NULL DEFAULT '',
blockchain_network text NOT NULL DEFAULT '',
blockchain_transaction_hash text NOT NULL DEFAULT '',
blockchain_ledger_sequence text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$')
);
CREATE INDEX transaction_events_source_idx
ON transaction_events (source_service, source_transaction_id, event_version);
CREATE FUNCTION reject_ledger_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME
USING ERRCODE = '55000';
END;
$$;
CREATE FUNCTION guard_journal_seal() RETURNS trigger
LANGUAGE plpgsql AS $$
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;
RETURN NEW;
END;
$$;
CREATE 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;
RETURN NEW;
END;
$$;
CREATE TRIGGER journals_guard_update
BEFORE UPDATE ON journals
FOR EACH ROW EXECUTE FUNCTION guard_journal_seal();
CREATE TRIGGER journals_reject_delete
BEFORE DELETE ON journals
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER entries_guard_insert
BEFORE INSERT ON journal_entries
FOR EACH ROW EXECUTE FUNCTION guard_entry_insert();
CREATE TRIGGER entries_reject_update_or_delete
BEFORE UPDATE OR DELETE ON journal_entries
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER accounts_reject_update_or_delete
BEFORE UPDATE OR DELETE ON ledger_accounts
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER transaction_events_reject_update_or_delete
BEFORE UPDATE OR DELETE ON transaction_events
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS ledger_accounts_user_owner_idx;
DROP INDEX IF EXISTS journals_effect_recorded_idx;
@@ -0,0 +1,7 @@
CREATE INDEX journals_effect_recorded_idx
ON journals (lower(effect_kind), recorded_at DESC, id DESC)
WHERE sealed_at IS NOT NULL;
CREATE INDEX ledger_accounts_user_owner_idx
ON ledger_accounts (owner_id, id)
WHERE class IN ('USER_AVAILABLE', 'USER_FROZEN');
@@ -0,0 +1,22 @@
CREATE TABLE kuknos_settlements (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
status text NOT NULL CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW')),
network text NOT NULL DEFAULT 'kuknos',
signed_transaction_xdr text NOT NULL DEFAULT '',
transaction_hash text NOT NULL DEFAULT '',
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
available_at timestamptz NOT NULL DEFAULT clock_timestamp(),
locked_at timestamptz,
locked_by text NOT NULL DEFAULT '',
last_error text NOT NULL DEFAULT '',
submitted_at timestamptz,
confirmed_at timestamptz,
manual_review_at timestamptz,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX kuknos_settlements_due_idx ON kuknos_settlements (status, available_at);
@@ -0,0 +1,13 @@
CREATE TABLE kuknos_settlement_operator_audits (
id bigserial PRIMARY KEY,
settlement_id uuid NOT NULL REFERENCES kuknos_settlements(id),
actor_id text NOT NULL CHECK (actor_id <> ''),
action text NOT NULL CHECK (action IN ('RETRY')),
reason text NOT NULL CHECK (reason <> ''),
previous_status text NOT NULL,
resulting_status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX kuknos_settlement_operator_audits_settlement_idx
ON kuknos_settlement_operator_audits (settlement_id, created_at DESC);
@@ -0,0 +1,13 @@
CREATE TABLE kuknos_reconciliation_issues (
settlement_id uuid PRIMARY KEY REFERENCES kuknos_settlements(id),
issue_type text NOT NULL CHECK (issue_type IN ('MISSING_LEDGER_CONFIRMATION','TRANSACTION_HASH_MISMATCH')),
expected_hash text NOT NULL DEFAULT '',
actual_hash text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN','RESOLVED')),
first_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(),
last_detected_at timestamptz NOT NULL DEFAULT clock_timestamp(),
resolved_at timestamptz
);
CREATE INDEX kuknos_reconciliation_issues_open_idx
ON kuknos_reconciliation_issues (status, last_detected_at DESC);
@@ -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();
@@ -0,0 +1,7 @@
ALTER TABLE kuknos_settlements DROP CONSTRAINT kuknos_settlements_status_check;
ALTER TABLE kuknos_settlements ADD CONSTRAINT kuknos_settlements_status_check
CHECK (status IN ('PENDING','SUBMITTED','CONFIRMED','RETRYABLE','MANUAL_REVIEW','IGNORED'));
ALTER TABLE kuknos_settlement_operator_audits DROP CONSTRAINT kuknos_settlement_operator_audits_action_check;
ALTER TABLE kuknos_settlement_operator_audits ADD CONSTRAINT kuknos_settlement_operator_audits_action_check
CHECK (action IN ('RETRY','IGNORE'));
+378
View File
@@ -0,0 +1,378 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"gl/application/explorer"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
)
var ErrNotFound = ledger.ErrNotFound
type JournalFilter = ledger.JournalFilter
func (r *JournalRepository) GetByID(ctx context.Context, journalID string) (ledger.Journal, error) {
return r.get(ctx, getJournalByIDSQL, journalID)
}
func (r *JournalRepository) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (ledger.Journal, error) {
return r.get(ctx, getJournalByIdempotencySQL, idempotencyKey)
}
func (r *JournalRepository) get(ctx context.Context, query string, value string) (ledger.Journal, error) {
journal, err := scanJournal(r.database.QueryRow(ctx, query, value))
if errors.Is(err, pgx.ErrNoRows) {
return ledger.Journal{}, ErrNotFound
}
if err != nil {
return ledger.Journal{}, fmt.Errorf("read journal: %w", err)
}
journal.Entries, err = r.loadEntries(ctx, journal.ID)
if err != nil {
return ledger.Journal{}, err
}
return journal, nil
}
func (r *JournalRepository) List(ctx context.Context, filter JournalFilter) ([]ledger.Journal, error) {
limit := filter.Limit
if limit <= 0 || limit > 201 {
limit = 50
}
if filter.Offset < 0 {
filter.Offset = 0
}
var accountClass, ownerType, ownerID any
if filter.Account != nil {
accountClass = filter.Account.Class
ownerType = filter.Account.OwnerType
ownerID = filter.Account.OwnerID
}
return r.queryJournals(ctx, listJournalsSQL,
filter.AssetID,
accountClass,
ownerType,
ownerID,
filter.OwnerID,
filter.EffectKind,
filter.RecordedFrom,
filter.RecordedTo,
limit,
filter.Offset,
)
}
func (r *JournalRepository) GetByTransactionHash(ctx context.Context, hash string) ([]ledger.Journal, error) {
return r.queryJournals(ctx, getJournalsByTransactionHashSQL, hash)
}
func (r *JournalRepository) Stats(ctx context.Context) (explorer.Stats, error) {
var stats explorer.Stats
if err := r.database.QueryRow(ctx, getExplorerStatsSQL).Scan(
&stats.JournalCount,
&stats.EntryCount,
&stats.AccountCount,
&stats.LastRecordedAt,
); err != nil {
return explorer.Stats{}, fmt.Errorf("read explorer stats: %w", err)
}
return stats, nil
}
func (r *JournalRepository) SettlementDashboard(ctx context.Context, limit int) (explorer.SettlementStats, []explorer.Settlement, error) {
if limit <= 0 || limit > 100 {
limit = 12
}
var stats explorer.SettlementStats
if err := r.database.QueryRow(ctx, `SELECT
count(*) FILTER (WHERE status='PENDING'),
count(*) FILTER (WHERE status='RETRYABLE'),
count(*) FILTER (WHERE status='CONFIRMED'),
count(*) FILTER (WHERE status='MANUAL_REVIEW')
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.Confirmed, &stats.ManualReview); err != nil {
return explorer.SettlementStats{}, nil, fmt.Errorf("read settlement stats: %w", err)
}
rows, err := r.database.Query(ctx, `SELECT id, source_service, source_transaction_id, status,
network, transaction_hash, attempts, last_error, updated_at
FROM kuknos_settlements ORDER BY updated_at DESC LIMIT $1`, limit)
if err != nil {
return explorer.SettlementStats{}, nil, fmt.Errorf("list recent settlements: %w", err)
}
defer rows.Close()
settlements := make([]explorer.Settlement, 0, limit)
for rows.Next() {
var settlement explorer.Settlement
if err := rows.Scan(&settlement.ID, &settlement.SourceService, &settlement.SourceTransactionID,
&settlement.Status, &settlement.Network, &settlement.TransactionHash, &settlement.Attempts,
&settlement.LastError, &settlement.UpdatedAt); err != nil {
return explorer.SettlementStats{}, nil, fmt.Errorf("scan recent settlement: %w", err)
}
settlements = append(settlements, settlement)
}
if err := rows.Err(); err != nil {
return explorer.SettlementStats{}, nil, fmt.Errorf("iterate recent settlements: %w", err)
}
return stats, settlements, nil
}
func (r *JournalRepository) TopHolders(ctx context.Context, assetLimit, holderLimit int) ([]explorer.Holder, error) {
if assetLimit <= 0 || assetLimit > 20 {
assetLimit = 5
}
if holderLimit <= 0 || holderLimit > 20 {
holderLimit = 5
}
rows, err := r.database.Query(ctx, topHoldersSQL, assetLimit, holderLimit)
if err != nil {
return nil, fmt.Errorf("list top holders: %w", err)
}
defer rows.Close()
holders := make([]explorer.Holder, 0, assetLimit*holderLimit)
for rows.Next() {
var holder explorer.Holder
var balance string
if err := rows.Scan(&holder.Rank, &holder.OwnerType, &holder.OwnerID, &holder.AssetID, &balance); err != nil {
return nil, fmt.Errorf("scan top holder: %w", err)
}
holder.Balance, err = ledger.ParseAmount(balance)
if err != nil {
return nil, fmt.Errorf("decode top holder balance: %w", err)
}
holders = append(holders, holder)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate top holders: %w", err)
}
return holders, nil
}
func (r *JournalRepository) queryJournals(ctx context.Context, query string, args ...any) ([]ledger.Journal, error) {
rows, err := r.database.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list journals: %w", err)
}
defer rows.Close()
journals := make([]ledger.Journal, 0)
for rows.Next() {
journal, scanErr := scanJournal(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan journal: %w", scanErr)
}
journals = append(journals, journal)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate journals: %w", err)
}
rows.Close()
for index := range journals {
journals[index].Entries, err = r.loadEntries(ctx, journals[index].ID)
if err != nil {
return nil, err
}
}
return journals, nil
}
func (r *JournalRepository) Balance(ctx context.Context, account ledger.AccountReference, asOf time.Time) (ledger.Amount, error) {
if err := account.Validate(); err != nil {
return ledger.Amount{}, err
}
var asOfValue any
if !asOf.IsZero() {
asOfValue = asOf
}
var value string
if err := r.database.QueryRow(ctx, getBalanceSQL,
account.Class,
account.OwnerType,
account.OwnerID,
account.AssetID,
asOfValue,
).Scan(&value); err != nil {
return ledger.Amount{}, fmt.Errorf("read balance: %w", err)
}
amount, err := ledger.ParseAmount(value)
if err != nil {
return ledger.Amount{}, fmt.Errorf("decode balance: %w", err)
}
return amount, nil
}
type scanner interface {
Scan(...any) error
}
func scanJournal(row scanner) (ledger.Journal, error) {
var (
journal ledger.Journal
reversal *string
metadata []byte
)
err := row.Scan(
&journal.ID,
&journal.SourceService,
&journal.IdempotencyKey,
&journal.SourceTransactionID,
&journal.TrackingCode,
&journal.EffectKind,
&journal.EventVersion,
&reversal,
&journal.OccurredAt,
&journal.RecordedAt,
&journal.CorrelationID,
&journal.ActorID,
&journal.Blockchain.Network,
&journal.Blockchain.TransactionHash,
&journal.Blockchain.LedgerSequence,
&metadata,
&journal.PayloadHash,
)
if err != nil {
return ledger.Journal{}, err
}
if reversal != nil {
journal.ReversalOfJournalID = *reversal
}
if err := json.Unmarshal(metadata, &journal.Metadata); err != nil {
return ledger.Journal{}, fmt.Errorf("decode metadata: %w", err)
}
return journal, nil
}
func (r *JournalRepository) loadEntries(ctx context.Context, journalID string) ([]ledger.Entry, error) {
rows, err := r.database.Query(ctx, getEntriesSQL, journalID)
if err != nil {
return nil, fmt.Errorf("read journal entries: %w", err)
}
defer rows.Close()
entries := make([]ledger.Entry, 0)
for rows.Next() {
var (
entry ledger.Entry
amountValue string
)
if err := rows.Scan(
&entry.LineNumber,
&entry.Account.Class,
&entry.Account.OwnerType,
&entry.Account.OwnerID,
&entry.Account.AssetID,
&amountValue,
&entry.Description,
); err != nil {
return nil, fmt.Errorf("scan journal entry: %w", err)
}
entry.Amount, err = ledger.ParseAmount(amountValue)
if err != nil {
return nil, fmt.Errorf("decode journal entry amount: %w", err)
}
entries = append(entries, entry)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate journal entries: %w", err)
}
return entries, nil
}
const journalColumns = `
j.id, j.source_service, j.idempotency_key, j.source_transaction_id,
j.tracking_code, j.effect_kind, j.event_version, j.reversal_of_journal_id,
j.occurred_at, j.recorded_at, j.correlation_id, j.actor_id,
j.blockchain_network, j.blockchain_transaction_hash,
j.blockchain_ledger_sequence, j.metadata, j.payload_hash`
const getJournalByIDSQL = `SELECT ` + journalColumns + `
FROM journals j WHERE j.id = $1 AND j.sealed_at IS NOT NULL`
const getJournalByIdempotencySQL = `SELECT ` + journalColumns + `
FROM journals j WHERE j.idempotency_key = $1 AND j.sealed_at IS NOT NULL`
const getJournalsByTransactionHashSQL = `SELECT ` + journalColumns + `
FROM journals j
WHERE j.blockchain_transaction_hash = $1 AND j.sealed_at IS NOT NULL
ORDER BY j.recorded_at DESC, j.id DESC`
const topHoldersSQL = `WITH recent_assets AS (
SELECT e.asset_id, MAX(j.recorded_at) AS last_activity
FROM journal_entries e
JOIN journals j ON j.id = e.journal_id
WHERE j.sealed_at IS NOT NULL
GROUP BY e.asset_id
ORDER BY last_activity DESC, e.asset_id
LIMIT $1
), holder_balances AS (
SELECT a.owner_type, a.owner_id, e.asset_id, SUM(e.amount) AS balance
FROM journal_entries e
JOIN journals j ON j.id = e.journal_id AND j.sealed_at IS NOT NULL
JOIN ledger_accounts a ON a.id = e.account_id
JOIN recent_assets ra ON ra.asset_id = e.asset_id
WHERE a.class IN ('USER_AVAILABLE', 'USER_FROZEN')
GROUP BY a.owner_type, a.owner_id, e.asset_id
HAVING SUM(e.amount) > 0
), ranked_holders AS (
SELECT owner_type, owner_id, asset_id, balance,
ROW_NUMBER() OVER (
PARTITION BY asset_id
ORDER BY balance DESC, owner_type, owner_id
) AS holder_rank
FROM holder_balances
)
SELECT rh.holder_rank, rh.owner_type, rh.owner_id, rh.asset_id, rh.balance::text
FROM ranked_holders rh
JOIN recent_assets ra ON ra.asset_id = rh.asset_id
WHERE rh.holder_rank <= $2
ORDER BY ra.last_activity DESC, rh.asset_id, rh.holder_rank`
const listJournalsSQL = `SELECT DISTINCT ` + journalColumns + `
FROM journals j
JOIN journal_entries e ON e.journal_id = j.id
JOIN ledger_accounts a ON a.id = e.account_id
WHERE j.sealed_at IS NOT NULL
AND ($1::bigint IS NULL OR e.asset_id = $1)
AND ($2::text IS NULL OR (
a.class = $2 AND a.owner_type = $3 AND a.owner_id = $4
))
AND ($5::text IS NULL OR (
a.class IN ('USER_AVAILABLE', 'USER_FROZEN') AND a.owner_id = $5
))
AND ($6::text IS NULL OR lower(j.effect_kind) = lower($6))
AND ($7::timestamptz IS NULL OR j.recorded_at >= $7)
AND ($8::timestamptz IS NULL OR j.recorded_at <= $8)
ORDER BY j.recorded_at DESC, j.id DESC
LIMIT $9 OFFSET $10`
const getEntriesSQL = `
SELECT e.line_number, a.class, a.owner_type, a.owner_id, e.asset_id,
e.amount::text, e.description
FROM journal_entries e
JOIN ledger_accounts a ON a.id = e.account_id
WHERE e.journal_id = $1
ORDER BY e.line_number`
const getBalanceSQL = `
SELECT COALESCE(sum(e.amount), 0)::text
FROM journal_entries e
JOIN ledger_accounts a ON a.id = e.account_id
JOIN journals j ON j.id = e.journal_id
WHERE j.sealed_at IS NOT NULL
AND a.class = $1 AND a.owner_type = $2 AND a.owner_id = $3
AND a.asset_id = $4
AND ($5::timestamptz IS NULL OR j.recorded_at <= $5)`
const getExplorerStatsSQL = `
SELECT
(SELECT count(*) FROM journals WHERE sealed_at IS NOT NULL),
(SELECT count(*) FROM journal_entries e JOIN journals j ON j.id = e.journal_id WHERE j.sealed_at IS NOT NULL),
(SELECT count(*) FROM ledger_accounts),
COALESCE((SELECT max(recorded_at) FROM journals WHERE sealed_at IS NOT NULL), 'epoch'::timestamptz)`
@@ -0,0 +1,251 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"gl/domain/ledger"
)
type SettlementRepository struct{ database Database }
type ReconciliationStats struct {
Open, Detected, Resolved int64
}
type txQueryer interface {
Query(context.Context, string, ...any) (Rows, error)
}
func NewSettlementRepository(database Database) *SettlementRepository {
return &SettlementRepository{database: database}
}
// Existing normal-mode Wallet clients require SettlementReady; a GL-only
// cutover must not let them continue admitting blockchain-dependent effects.
func (r *SettlementRepository) SettlementAllowed(ctx context.Context) (bool, error) {
var mode string
err := r.database.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton").Scan(&mode)
return mode == string(ledger.ModeNormal), err
}
func (r *SettlementRepository) Stats(ctx context.Context) (ledger.SettlementStats, error) {
var stats ledger.SettlementStats
err := r.database.QueryRow(ctx, `SELECT
COUNT(*) FILTER (WHERE status='PENDING'),
COUNT(*) FILTER (WHERE status='RETRYABLE'),
COUNT(*) FILTER (WHERE status='MANUAL_REVIEW'),
COALESCE(EXTRACT(EPOCH FROM (clock_timestamp() - MIN(created_at) FILTER (WHERE status IN ('PENDING','RETRYABLE'))))::bigint, 0)
FROM kuknos_settlements`).Scan(&stats.Pending, &stats.Retryable, &stats.ManualReview, &stats.OldestPendingSeconds)
if err != nil {
return ledger.SettlementStats{}, fmt.Errorf("settlement stats: %w", err)
}
return stats, nil
}
func (r *SettlementRepository) Enqueue(ctx context.Context, s ledger.Settlement) (ledger.Settlement, bool, error) {
if err := s.Validate(); err != nil {
return ledger.Settlement{}, false, err
}
var inserted string
err := r.database.QueryRow(ctx, `INSERT INTO kuknos_settlements
(id, source_service, source_transaction_id, idempotency_key, status, network, signed_transaction_xdr, available_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id`,
s.ID, s.SourceService, s.SourceTxID, s.IdempotencyKey, s.Status, s.Network, s.SignedTransactionXDR, s.AvailableAt).Scan(&inserted)
if err == nil {
return s, false, nil
}
if err == pgx.ErrNoRows {
var existingXDR string
if err := r.database.QueryRow(ctx, `SELECT signed_transaction_xdr, id FROM kuknos_settlements WHERE idempotency_key=$1`, s.IdempotencyKey).Scan(&existingXDR, &inserted); err != nil {
return ledger.Settlement{}, false, fmt.Errorf("resolve settlement idempotency: %w", err)
}
if existingXDR != s.SignedTransactionXDR {
return ledger.Settlement{}, false, ledger.ErrIdempotencyConflict
}
existing, err := r.Get(ctx, inserted, "")
return existing, true, err
}
if classified := authorityError(err); classified != nil {
return ledger.Settlement{}, false, classified
}
return ledger.Settlement{}, false, fmt.Errorf("enqueue settlement: %w", err)
}
func (r *SettlementRepository) Get(ctx context.Context, id, idempotencyKey string) (ledger.Settlement, error) {
var s ledger.Settlement
err := r.database.QueryRow(ctx, `SELECT id, source_service, source_transaction_id, idempotency_key, status, network, transaction_hash, attempts, available_at, last_error, signed_transaction_xdr FROM kuknos_settlements WHERE id=NULLIF($1,'')::uuid OR idempotency_key=NULLIF($2,'')`, id, idempotencyKey).Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("get settlement: %w", err)
}
return s, nil
}
func (r *SettlementRepository) ClaimDue(ctx context.Context, workerID string, limit int, now time.Time) ([]ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var mode string
if err := tx.QueryRow(ctx, "SELECT mode FROM ledger_operating_state WHERE singleton FOR SHARE").Scan(&mode); err != nil {
return nil, err
}
if mode == string(ledger.ModeGLOnly) {
return nil, nil
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET locked_at=NULL, locked_by='', updated_at=clock_timestamp() WHERE locked_at < clock_timestamp() - interval '10 minutes'`); err != nil {
return nil, fmt.Errorf("release stale settlement locks: %w", err)
}
queryer, ok := tx.(txQueryer)
if !ok {
return nil, fmt.Errorf("settlement transaction does not support row queries")
}
rows, err := queryer.Query(ctx, `WITH due AS (
SELECT id FROM kuknos_settlements
WHERE status IN ('PENDING','RETRYABLE') AND available_at <= $1 AND locked_by=''
ORDER BY available_at, created_at FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE kuknos_settlements s SET locked_at=clock_timestamp(), locked_by=$3, updated_at=clock_timestamp()
FROM due WHERE s.id=due.id
RETURNING s.id, s.source_service, s.source_transaction_id, s.idempotency_key, s.status, s.network, s.transaction_hash, s.attempts, s.available_at, s.last_error, s.signed_transaction_xdr`, now, limit, workerID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []ledger.Settlement
for rows.Next() {
var s ledger.Settlement
if err := rows.Scan(&s.ID, &s.SourceService, &s.SourceTxID, &s.IdempotencyKey, &s.Status, &s.Network, &s.TransactionHash, &s.Attempts, &s.AvailableAt, &s.LastError, &s.SignedTransactionXDR); err != nil {
return nil, err
}
result = append(result, s)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return result, nil
}
func (r *SettlementRepository) Save(ctx context.Context, s ledger.Settlement, workerID string) error {
_, err := r.database.Exec(ctx, `UPDATE kuknos_settlements SET status=$2, transaction_hash=$3, attempts=$4, available_at=$5, last_error=$6, locked_at=NULL, locked_by='', submitted_at=CASE WHEN $2 IN ('SUBMITTED','CONFIRMED') THEN COALESCE(submitted_at, clock_timestamp()) ELSE submitted_at END, confirmed_at=CASE WHEN $2='CONFIRMED' THEN COALESCE(confirmed_at, clock_timestamp()) ELSE confirmed_at END, manual_review_at=CASE WHEN $2='MANUAL_REVIEW' THEN clock_timestamp() ELSE manual_review_at END, updated_at=clock_timestamp() WHERE id=$1 AND locked_by=$7`, s.ID, s.Status, s.TransactionHash, s.Attempts, s.AvailableAt, s.LastError, workerID)
return err
}
func (r *SettlementRepository) RetryByOperator(ctx context.Context, id, actorID, reason string, now time.Time) (ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("begin operator retry: %w", err)
}
defer tx.Rollback(ctx)
var previous ledger.SettlementStatus
if err := tx.QueryRow(ctx, `SELECT status FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("lock settlement for operator retry: %w", err)
}
if previous == ledger.SettlementConfirmed || previous == ledger.SettlementIgnored {
return ledger.Settlement{}, fmt.Errorf("confirmed or ignored settlement cannot be retried")
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='RETRYABLE', available_at=$2, locked_at=NULL, locked_by='', last_error='', manual_review_at=NULL, updated_at=clock_timestamp() WHERE id=$1`, id, now); err != nil {
return ledger.Settlement{}, fmt.Errorf("retry settlement: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'RETRY',$3,$4,'RETRYABLE')`, id, actorID, reason, previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("audit operator retry: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return ledger.Settlement{}, fmt.Errorf("commit operator retry: %w", err)
}
return r.Get(ctx, id, "")
}
func (r *SettlementRepository) IgnoreByOperator(ctx context.Context, id, actorID, reason string) (ledger.Settlement, error) {
tx, err := r.database.Begin(ctx)
if err != nil {
return ledger.Settlement{}, fmt.Errorf("begin operator dismissal: %w", err)
}
defer tx.Rollback(ctx)
var previous, lockedBy string
if err := tx.QueryRow(ctx, `SELECT status, locked_by FROM kuknos_settlements WHERE id=$1 FOR UPDATE`, id).Scan(&previous, &lockedBy); err != nil {
return ledger.Settlement{}, fmt.Errorf("lock settlement for dismissal: %w", err)
}
if lockedBy != "" || (previous != string(ledger.SettlementManualReview) && previous != string(ledger.SettlementRetryable)) {
return ledger.Settlement{}, fmt.Errorf("only unlocked settlements requiring review or retry can be ignored")
}
// Preserve the error and signed payload as evidence; this is not a payment confirmation.
if _, err := tx.Exec(ctx, `UPDATE kuknos_settlements SET status='IGNORED', updated_at=clock_timestamp() WHERE id=$1`, id); err != nil {
return ledger.Settlement{}, fmt.Errorf("ignore settlement: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO kuknos_settlement_operator_audits (settlement_id, actor_id, action, reason, previous_status, resulting_status) VALUES ($1,$2,'IGNORE',$3,$4,'IGNORED')`, id, actorID, reason, previous); err != nil {
return ledger.Settlement{}, fmt.Errorf("audit operator dismissal: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues SET status='RESOLVED', resolved_at=clock_timestamp() WHERE settlement_id=$1 AND status='OPEN'`, id); err != nil {
return ledger.Settlement{}, fmt.Errorf("resolve dismissed settlement issues: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return ledger.Settlement{}, fmt.Errorf("commit operator dismissal: %w", err)
}
return r.Get(ctx, id, "")
}
// ReconcileConfirmationEvidence durably records confirmed Kuknos settlements
// whose latest GL lifecycle evidence is missing or has a different hash.
func (r *SettlementRepository) ReconcileConfirmationEvidence(ctx context.Context, grace time.Duration) (ReconciliationStats, error) {
if grace <= 0 {
grace = 10 * time.Minute
}
tx, err := r.database.Begin(ctx)
if err != nil {
return ReconciliationStats{}, err
}
defer tx.Rollback(ctx)
var detected int64
err = tx.QueryRow(ctx, `WITH inconsistent AS (
SELECT s.id,
CASE WHEN e.id IS NULL OR e.blockchain_transaction_hash='' THEN 'MISSING_LEDGER_CONFIRMATION' ELSE 'TRANSACTION_HASH_MISMATCH' END issue_type,
s.transaction_hash expected_hash, COALESCE(e.blockchain_transaction_hash,'') actual_hash
FROM kuknos_settlements s
LEFT JOIN LATERAL (
SELECT id, blockchain_transaction_hash FROM transaction_events
WHERE source_service=s.source_service AND source_transaction_id=s.source_transaction_id
ORDER BY event_version DESC, recorded_at DESC LIMIT 1
) e ON true
WHERE s.status='CONFIRMED' AND s.confirmed_at < clock_timestamp() - $1::interval
AND (e.id IS NULL OR e.blockchain_transaction_hash='' OR e.blockchain_transaction_hash<>s.transaction_hash)
), upserted AS (
INSERT INTO kuknos_reconciliation_issues (settlement_id, issue_type, expected_hash, actual_hash)
SELECT id, issue_type, expected_hash, actual_hash FROM inconsistent
ON CONFLICT (settlement_id) DO UPDATE SET issue_type=EXCLUDED.issue_type,
expected_hash=EXCLUDED.expected_hash, actual_hash=EXCLUDED.actual_hash,
status='OPEN', last_detected_at=clock_timestamp(), resolved_at=NULL
WHERE kuknos_reconciliation_issues.status<>'OPEN'
OR kuknos_reconciliation_issues.issue_type<>EXCLUDED.issue_type
OR kuknos_reconciliation_issues.expected_hash<>EXCLUDED.expected_hash
OR kuknos_reconciliation_issues.actual_hash<>EXCLUDED.actual_hash
RETURNING 1
) SELECT count(*) FROM upserted`, fmt.Sprintf("%f seconds", grace.Seconds())).Scan(&detected)
if err != nil {
return ReconciliationStats{}, fmt.Errorf("detect settlement reconciliation issues: %w", err)
}
result, err := tx.Exec(ctx, `UPDATE kuknos_reconciliation_issues i SET status='RESOLVED', resolved_at=clock_timestamp()
WHERE status='OPEN' AND EXISTS (
SELECT 1 FROM kuknos_settlements s JOIN transaction_events e
ON e.source_service=s.source_service AND e.source_transaction_id=s.source_transaction_id
WHERE s.id=i.settlement_id AND s.status='CONFIRMED'
AND e.blockchain_transaction_hash=s.transaction_hash AND e.blockchain_transaction_hash<>''
)`)
if err != nil {
return ReconciliationStats{}, fmt.Errorf("resolve settlement reconciliation issues: %w", err)
}
var open int64
if err := tx.QueryRow(ctx, `SELECT count(*) FROM kuknos_reconciliation_issues WHERE status='OPEN'`).Scan(&open); err != nil {
return ReconciliationStats{}, err
}
if err := tx.Commit(ctx); err != nil {
return ReconciliationStats{}, err
}
return ReconciliationStats{Open: open, Detected: detected, Resolved: result.RowsAffected()}, nil
}
@@ -0,0 +1,84 @@
package postgres
import (
"context"
"testing"
"time"
"gl/domain/ledger"
)
func TestIgnoreByOperatorRejectsActiveLockedAndTerminalSettlements(t *testing.T) {
for _, state := range []struct{ status, lock string }{
{"PENDING", ""}, {"SUBMITTED", ""}, {"CONFIRMED", ""}, {"IGNORED", ""}, {"RETRYABLE", "worker"},
} {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{state.status, state.lock}}}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil {
t.Fatalf("dismissed ineligible state: %+v", state)
}
if tx.execCount != 0 || tx.committed || !tx.rolledBack {
t.Fatalf("ineligible dismissal changed state: %+v", tx)
}
}
}
func TestIgnoreByOperatorAuditsAndResolvesAtomically(t *testing.T) {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"MANUAL_REVIEW", ""}}}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx, directRow: fakeRow{values: []any{
"id", "wallet", "110", "wallet:110:kuknos", ledger.SettlementIgnored, "kuknos", "", 1, time.Now(), "tx_bad_seq", "signed-xdr",
}}})
got, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed")
if err != nil || got.Status != ledger.SettlementIgnored || got.LastError != "tx_bad_seq" || !tx.committed || tx.execCount != 3 {
t.Fatalf("unexpected dismissal: %+v tx=%+v err=%v", got, tx, err)
}
for _, failedStep := range []int{2, 3} {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{"RETRYABLE", ""}}}, execErrAt: failedStep}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.IgnoreByOperator(context.Background(), "id", "actor", "reviewed"); err == nil || tx.committed || !tx.rolledBack {
t.Fatalf("audit or issue failure did not roll back: %+v err=%v", tx, err)
}
}
}
func TestIgnoredSettlementCannotBeRetried(t *testing.T) {
tx := &fakeTx{rows: []Row{fakeRow{values: []any{ledger.SettlementIgnored}}}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.RetryByOperator(context.Background(), "id", "actor", "retry", time.Now()); err == nil || tx.execCount != 0 {
t.Fatal("ignored settlement was queued again")
}
}
func TestReconcileConfirmationEvidenceCommitsDurableIssueStats(t *testing.T) {
tx := &fakeTx{rows: []Row{
fakeRow{values: []any{int64(1)}},
fakeRow{values: []any{int64(2)}},
}}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
stats, err := repository.ReconcileConfirmationEvidence(context.Background(), 10*time.Minute)
if err != nil {
t.Fatal(err)
}
if stats.Detected != 1 || stats.Resolved != 1 || stats.Open != 2 {
t.Fatalf("unexpected reconciliation stats: %+v", stats)
}
if !tx.committed || tx.execCount != 1 {
t.Fatalf("unexpected transaction state: %+v", tx)
}
}
func TestReconcileConfirmationEvidenceRollsBackResolutionFailure(t *testing.T) {
tx := &fakeTx{
rows: []Row{fakeRow{values: []any{int64(1)}}},
execErrAt: 1,
}
repository := NewSettlementRepository(&fakeDatabase{tx: tx})
if _, err := repository.ReconcileConfirmationEvidence(context.Background(), time.Minute); err == nil {
t.Fatal("expected resolution error")
}
if !tx.rolledBack || tx.committed {
t.Fatalf("failed reconciliation was not rolled back: %+v", tx)
}
}
+75
View File
@@ -0,0 +1,75 @@
package grpcadapter
import (
"context"
"crypto/subtle"
domain "gl/domain/ledger"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func (h *Handler) requireAdmin(ctx context.Context) error {
values := metadata.ValueFromIncomingContext(ctx, "x-admin-token")
if h.adminToken == "" || len(values) != 1 || subtle.ConstantTimeCompare([]byte(values[0]), []byte(h.adminToken)) != 1 {
return status.Error(codes.PermissionDenied, "invalid administrative token")
}
return nil
}
func modeMessage(state domain.OperatingState) *ledgerv1.OperatingModeResponse {
return &ledgerv1.OperatingModeResponse{Mode: string(state.Mode), Revision: state.Revision, ActorId: state.ActorID, Reason: state.Reason, ChangedAt: timestampMessage(state.ChangedAt)}
}
func (h *Handler) GetOperatingMode(ctx context.Context, _ *basev1.Empty) (*ledgerv1.OperatingModeResponse, error) {
state, err := h.ledger.OperatingMode(ctx)
if err != nil {
return nil, rpcError(err)
}
return modeMessage(state), nil
}
func (h *Handler) SetOperatingMode(ctx context.Context, request *ledgerv1.SetOperatingModeRequest) (*ledgerv1.OperatingModeResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
state, err := h.ledger.SetOperatingMode(ctx, domain.OperatingMode(request.GetMode()), request.GetExpectedRevision(), request.GetActorId(), request.GetReason(), request.GetReconciliationConfirmed())
if err != nil {
return nil, rpcError(err)
}
return modeMessage(state), nil
}
func (h *Handler) PostInternalJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
command, err := appendJournalCommand(request)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
result, err := h.ledger.PostInternalJournal(ctx, command)
if err != nil {
return nil, rpcError(err)
}
return &ledgerv1.AppendJournalResponse{Journal: journalMessage(result.Journal), AlreadyExisted: result.AlreadyExisted}, nil
}
func (h *Handler) GetUserBalances(ctx context.Context, request *ledgerv1.GetUserBalancesRequest) (*ledgerv1.GetUserBalancesResponse, error) {
if request == nil {
return nil, status.Error(codes.InvalidArgument, "request is required")
}
balances, err := h.ledger.UserBalances(ctx, request.GetOwnerType(), request.GetOwnerId(), request.AssetId)
if err != nil {
return nil, rpcError(err)
}
response := &ledgerv1.GetUserBalancesResponse{OwnerType: request.GetOwnerType(), OwnerId: request.GetOwnerId()}
for _, balance := range balances {
response.Balances = append(response.Balances, &ledgerv1.UserAssetBalance{
AssetId: balance.AssetID, Available: balance.Available.String(), Frozen: balance.Frozen.String(), Total: balance.Available.Add(balance.Frozen).String(),
})
}
return response, nil
}
+33
View File
@@ -0,0 +1,33 @@
package grpcadapter
import (
"context"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"testing"
)
func TestAuthorityCommandsRequireOneConfiguredAdministrativeToken(t *testing.T) {
for _, tc := range []struct {
name, configured string
tokens []string
}{
{"unconfigured", "", []string{"token"}},
{"missing", "token", nil},
{"wrong", "token", []string{"wrong"}},
{"duplicate", "token", []string{"token", "token"}},
} {
t.Run(tc.name, func(t *testing.T) {
handler := NewHandler(nil, nil).WithSettlementAdminToken(tc.configured)
ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{"x-admin-token": tc.tokens})
if _, err := handler.SetOperatingMode(ctx, &ledgerv1.SetOperatingModeRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("toggle: %v", err)
}
if _, err := handler.PostInternalJournal(ctx, &ledgerv1.AppendJournalRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("internal posting: %v", err)
}
})
}
}
+47
View File
@@ -0,0 +1,47 @@
package grpcadapter
import (
"context"
"gl/application/health"
applicationledger "gl/application/ledger"
applicationsettlement "gl/application/settlement"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
)
type Handler struct {
ledgerv1.UnimplementedGeneralLedgerServiceServer
health *health.Service
ledger *applicationledger.Service
settlement *applicationsettlement.Service
adminToken string
}
func (h *Handler) WithSettlementAdminToken(token string) *Handler { h.adminToken = token; return h }
func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service, settlementService ...*applicationsettlement.Service) *Handler {
var settlement *applicationsettlement.Service
if len(settlementService) > 0 {
settlement = settlementService[0]
}
return &Handler{health: healthService, ledger: ledgerService, settlement: settlement}
}
func NewHealthHandler(service *health.Service) *Handler {
return NewHandler(service, nil)
}
func (h *Handler) Health(ctx context.Context, _ *basev1.Empty) (*ledgerv1.HealthResponse, error) {
result := h.health.Check(ctx)
return &ledgerv1.HealthResponse{
Serving: result.Serving,
DatabaseReady: result.DatabaseReady,
SettlementEnabled: result.SettlementEnabled,
SettlementReady: result.SettlementReady,
SettlementPending: result.SettlementStats.Pending,
SettlementRetryable: result.SettlementStats.Retryable,
SettlementManualReview: result.SettlementStats.ManualReview,
OldestPendingSeconds: result.SettlementStats.OldestPendingSeconds,
}, nil
}
+19
View File
@@ -0,0 +1,19 @@
package grpcadapter
import (
"context"
"testing"
"gl/application/health"
basev1 "gl/gen/base/v1"
)
func TestHealth(t *testing.T) {
response, err := NewHealthHandler(health.NewService(nil)).Health(context.Background(), &basev1.Empty{})
if err != nil {
t.Fatal(err)
}
if response.Serving || response.DatabaseReady {
t.Fatalf("unexpected response: %+v", response)
}
}
+412
View File
@@ -0,0 +1,412 @@
package grpcadapter
import (
"context"
"errors"
"time"
applicationledger "gl/application/ledger"
domain "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (h *Handler) AppendJournal(ctx context.Context, request *ledgerv1.AppendJournalRequest) (*ledgerv1.AppendJournalResponse, error) {
command, err := appendJournalCommand(request)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
result, err := h.ledger.AppendJournal(ctx, command)
if err != nil {
return nil, rpcError(err)
}
return &ledgerv1.AppendJournalResponse{
Journal: journalMessage(result.Journal),
AlreadyExisted: result.AlreadyExisted,
}, nil
}
func (h *Handler) AppendTransactionEvent(ctx context.Context, request *ledgerv1.AppendTransactionEventRequest) (*ledgerv1.AppendTransactionEventResponse, error) {
occurredAt, err := requiredTime(request.GetOccurredAt(), "occurred_at")
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
event, alreadyExisted, err := h.ledger.AppendEvent(ctx, applicationledger.AppendEventCommand{
SourceService: request.GetSourceService(),
IdempotencyKey: request.GetIdempotencyKey(),
SourceTransactionID: request.GetSourceTransactionId(),
TrackingCode: request.GetTrackingCode(),
EventVersion: request.GetEventVersion(),
State: transactionState(request.GetState()),
ErrorCode: request.GetErrorCode(),
ErrorMessage: request.GetErrorMessage(),
OccurredAt: occurredAt,
CorrelationID: request.GetCorrelationId(),
ActorID: request.GetActorId(),
Blockchain: blockchainReference(request.GetBlockchain()),
Metadata: request.GetMetadata(),
})
if err != nil {
return nil, rpcError(err)
}
return &ledgerv1.AppendTransactionEventResponse{
Event: eventMessage(event),
AlreadyExisted: alreadyExisted,
}, nil
}
func (h *Handler) GetJournal(ctx context.Context, request *ledgerv1.GetJournalRequest) (*ledgerv1.Journal, error) {
var journalID, idempotencyKey string
switch lookup := request.GetLookup().(type) {
case *ledgerv1.GetJournalRequest_JournalId:
journalID = lookup.JournalId
case *ledgerv1.GetJournalRequest_IdempotencyKey:
idempotencyKey = lookup.IdempotencyKey
}
journal, err := h.ledger.GetJournal(ctx, journalID, idempotencyKey)
if err != nil {
return nil, rpcError(err)
}
return journalMessage(journal), nil
}
func (h *Handler) ListEntries(ctx context.Context, request *ledgerv1.ListEntriesRequest) (*ledgerv1.ListEntriesResponse, error) {
command := applicationledger.ListCommand{
PageSize: request.GetPageSize(),
PageToken: request.GetPageToken(),
}
if request.Account != nil {
account := accountReference(request.Account)
command.Account = &account
}
if request.AssetId != nil {
assetID := request.GetAssetId()
command.AssetID = &assetID
}
var err error
if request.RecordedFrom != nil {
value, parseErr := requiredTime(request.RecordedFrom, "recorded_from")
if parseErr != nil {
return nil, status.Error(codes.InvalidArgument, parseErr.Error())
}
command.RecordedFrom = &value
}
if request.RecordedTo != nil {
value, parseErr := requiredTime(request.RecordedTo, "recorded_to")
if parseErr != nil {
return nil, status.Error(codes.InvalidArgument, parseErr.Error())
}
command.RecordedTo = &value
}
journals, nextPageToken, err := h.ledger.List(ctx, command)
if err != nil {
return nil, rpcError(err)
}
response := &ledgerv1.ListEntriesResponse{
Journals: make([]*ledgerv1.Journal, 0, len(journals)),
NextPageToken: nextPageToken,
}
for _, journal := range journals {
response.Journals = append(response.Journals, journalMessage(journal))
}
return response, nil
}
func (h *Handler) GetBalance(ctx context.Context, request *ledgerv1.GetBalanceRequest) (*ledgerv1.GetBalanceResponse, error) {
if request.Account == nil {
return nil, status.Error(codes.InvalidArgument, "account is required")
}
asOf := time.Now().UTC()
if request.AsOf != nil {
value, err := requiredTime(request.AsOf, "as_of")
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
asOf = value
}
account := accountReference(request.Account)
balance, err := h.ledger.Balance(ctx, account, asOf)
if err != nil {
return nil, rpcError(err)
}
return &ledgerv1.GetBalanceResponse{
Account: accountMessage(account),
Balance: balance.String(),
AsOf: timestamppb.New(asOf),
}, nil
}
func (h *Handler) ReplayJournals(ctx context.Context, request *ledgerv1.ReplayJournalsRequest) (*ledgerv1.ReplayJournalsResponse, error) {
commands := make([]applicationledger.AppendJournalCommand, 0, len(request.GetJournals()))
for _, journal := range request.GetJournals() {
command, err := appendJournalCommand(journal)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
commands = append(commands, command)
}
results, err := h.ledger.Replay(ctx, commands)
if err != nil {
return nil, rpcError(err)
}
response := &ledgerv1.ReplayJournalsResponse{Results: make([]*ledgerv1.ReplayJournalResult, 0, len(results))}
for _, result := range results {
response.Results = append(response.Results, &ledgerv1.ReplayJournalResult{
IdempotencyKey: result.Journal.IdempotencyKey,
Journal: journalMessage(result.Journal),
AlreadyExisted: result.AlreadyExisted,
})
}
return response, nil
}
func appendJournalCommand(request *ledgerv1.AppendJournalRequest) (applicationledger.AppendJournalCommand, error) {
occurredAt, err := requiredTime(request.GetOccurredAt(), "occurred_at")
if err != nil {
return applicationledger.AppendJournalCommand{}, err
}
command := applicationledger.AppendJournalCommand{
SourceService: request.GetSourceService(),
IdempotencyKey: request.GetIdempotencyKey(),
SourceTransactionID: request.GetSourceTransactionId(),
TrackingCode: request.GetTrackingCode(),
EffectKind: request.GetEffectKind(),
EventVersion: request.GetEventVersion(),
ReversalOfJournalID: request.GetReversalOfJournalId(),
OccurredAt: occurredAt,
CorrelationID: request.GetCorrelationId(),
ActorID: request.GetActorId(),
Blockchain: blockchainReference(request.GetBlockchain()),
Metadata: request.GetMetadata(),
Entries: make([]applicationledger.EntryCommand, 0, len(request.GetEntries())),
}
for _, entry := range request.GetEntries() {
if entry.Account == nil {
return applicationledger.AppendJournalCommand{}, errors.New("entry account is required")
}
command.Entries = append(command.Entries, applicationledger.EntryCommand{
LineNumber: entry.GetLineNumber(),
Account: accountReference(entry.Account),
Amount: entry.GetAmount(),
Description: entry.GetDescription(),
})
}
return command, nil
}
func requiredTime(value *timestamppb.Timestamp, field string) (time.Time, error) {
if value == nil {
return time.Time{}, errors.New(field + " is required")
}
if err := value.CheckValid(); err != nil {
return time.Time{}, errors.New(field + " is invalid: " + err.Error())
}
return value.AsTime().UTC(), nil
}
func accountReference(value *ledgerv1.AccountReference) domain.AccountReference {
return domain.AccountReference{
Class: accountClass(value.GetAccountClass()),
OwnerType: value.GetOwnerType(),
OwnerID: value.GetOwnerId(),
AssetID: value.GetAssetId(),
}
}
func accountClass(value ledgerv1.AccountClass) domain.AccountClass {
switch value {
case ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE:
return domain.AccountClassUserAvailable
case ledgerv1.AccountClass_ACCOUNT_CLASS_USER_FROZEN:
return domain.AccountClassUserFrozen
case ledgerv1.AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN:
return domain.AccountClassExternalBlockchain
case ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY:
return domain.AccountClassTreasury
case ledgerv1.AccountClass_ACCOUNT_CLASS_MARKET_CLEARING:
return domain.AccountClassMarketClearing
case ledgerv1.AccountClass_ACCOUNT_CLASS_IPG_CLEARING:
return domain.AccountClassIPGClearing
case ledgerv1.AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE:
return domain.AccountClassCommissionRevenue
default:
return ""
}
}
func accountClassMessage(value domain.AccountClass) ledgerv1.AccountClass {
switch value {
case domain.AccountClassUserAvailable:
return ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE
case domain.AccountClassUserFrozen:
return ledgerv1.AccountClass_ACCOUNT_CLASS_USER_FROZEN
case domain.AccountClassExternalBlockchain:
return ledgerv1.AccountClass_ACCOUNT_CLASS_EXTERNAL_BLOCKCHAIN
case domain.AccountClassTreasury:
return ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY
case domain.AccountClassMarketClearing:
return ledgerv1.AccountClass_ACCOUNT_CLASS_MARKET_CLEARING
case domain.AccountClassIPGClearing:
return ledgerv1.AccountClass_ACCOUNT_CLASS_IPG_CLEARING
case domain.AccountClassCommissionRevenue:
return ledgerv1.AccountClass_ACCOUNT_CLASS_COMMISSION_REVENUE
default:
return ledgerv1.AccountClass_ACCOUNT_CLASS_UNSPECIFIED
}
}
func transactionState(value ledgerv1.TransactionState) domain.TransactionState {
switch value {
case ledgerv1.TransactionState_TRANSACTION_STATE_CREATED:
return domain.TransactionStateCreated
case ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION:
return domain.TransactionStatePendingTransaction
case ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_ADMIN:
return domain.TransactionStatePendingAdmin
case ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL:
return domain.TransactionStateSuccessful
case ledgerv1.TransactionState_TRANSACTION_STATE_FAILED:
return domain.TransactionStateFailed
case ledgerv1.TransactionState_TRANSACTION_STATE_SUSPENDED:
return domain.TransactionStateSuspended
default:
return ""
}
}
func transactionStateMessage(value domain.TransactionState) ledgerv1.TransactionState {
switch value {
case domain.TransactionStateCreated:
return ledgerv1.TransactionState_TRANSACTION_STATE_CREATED
case domain.TransactionStatePendingTransaction:
return ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_TRANSACTION
case domain.TransactionStatePendingAdmin:
return ledgerv1.TransactionState_TRANSACTION_STATE_PENDING_ADMIN
case domain.TransactionStateSuccessful:
return ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL
case domain.TransactionStateFailed:
return ledgerv1.TransactionState_TRANSACTION_STATE_FAILED
case domain.TransactionStateSuspended:
return ledgerv1.TransactionState_TRANSACTION_STATE_SUSPENDED
default:
return ledgerv1.TransactionState_TRANSACTION_STATE_UNSPECIFIED
}
}
func blockchainReference(value *ledgerv1.BlockchainReference) domain.BlockchainReference {
if value == nil {
return domain.BlockchainReference{}
}
return domain.BlockchainReference{
Network: value.GetNetwork(),
TransactionHash: value.GetTransactionHash(),
LedgerSequence: value.GetLedgerSequence(),
}
}
func journalMessage(value domain.Journal) *ledgerv1.Journal {
message := &ledgerv1.Journal{
JournalId: value.ID,
SourceService: value.SourceService,
IdempotencyKey: value.IdempotencyKey,
SourceTransactionId: value.SourceTransactionID,
TrackingCode: value.TrackingCode,
EffectKind: value.EffectKind,
EventVersion: value.EventVersion,
OccurredAt: timestampMessage(value.OccurredAt),
RecordedAt: timestampMessage(value.RecordedAt),
CorrelationId: value.CorrelationID,
ActorId: value.ActorID,
Blockchain: blockchainMessage(value.Blockchain),
Metadata: value.Metadata,
PayloadHash: value.PayloadHash,
Entries: make([]*ledgerv1.JournalEntry, 0, len(value.Entries)),
}
if value.ReversalOfJournalID != "" {
message.ReversalOfJournalId = &value.ReversalOfJournalID
}
for _, entry := range value.Entries {
message.Entries = append(message.Entries, &ledgerv1.JournalEntry{
LineNumber: entry.LineNumber,
Account: accountMessage(entry.Account),
Amount: entry.Amount.String(),
Description: entry.Description,
})
}
return message
}
func eventMessage(value domain.TransactionEvent) *ledgerv1.TransactionEvent {
return &ledgerv1.TransactionEvent{
EventId: value.ID,
Event: &ledgerv1.AppendTransactionEventRequest{
SourceService: value.SourceService,
IdempotencyKey: value.IdempotencyKey,
SourceTransactionId: value.SourceTransactionID,
TrackingCode: value.TrackingCode,
EventVersion: value.EventVersion,
State: transactionStateMessage(value.State),
ErrorCode: value.ErrorCode,
ErrorMessage: value.ErrorMessage,
OccurredAt: timestampMessage(value.OccurredAt),
CorrelationId: value.CorrelationID,
ActorId: value.ActorID,
Blockchain: blockchainMessage(value.Blockchain),
Metadata: value.Metadata,
},
RecordedAt: timestampMessage(value.RecordedAt),
PayloadHash: value.PayloadHash,
}
}
func accountMessage(value domain.AccountReference) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: accountClassMessage(value.Class),
OwnerType: value.OwnerType,
OwnerId: value.OwnerID,
AssetId: value.AssetID,
}
}
func blockchainMessage(value domain.BlockchainReference) *ledgerv1.BlockchainReference {
return &ledgerv1.BlockchainReference{
Network: value.Network,
TransactionHash: value.TransactionHash,
LedgerSequence: value.LedgerSequence,
}
}
func timestampMessage(value time.Time) *timestamppb.Timestamp {
if value.IsZero() {
return nil
}
return timestamppb.New(value)
}
func rpcError(err error) error {
switch {
case errors.Is(err, applicationledger.ErrInvalidArgument):
return status.Error(codes.InvalidArgument, err.Error())
case errors.Is(err, domain.ErrNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, domain.ErrIdempotencyConflict):
return status.Error(codes.AlreadyExists, err.Error())
case errors.Is(err, domain.ErrIncompleteJournal):
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, domain.ErrAlreadyReversed):
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, domain.ErrModeConflict), errors.Is(err, domain.ErrConcurrentPosting):
return status.Error(codes.Aborted, err.Error())
case errors.Is(err, domain.ErrModeRequired), errors.Is(err, domain.ErrInsufficientFunds), errors.Is(err, domain.ErrSettlementInFlight):
return status.Error(codes.FailedPrecondition, err.Error())
case errors.Is(err, context.Canceled):
return status.Error(codes.Canceled, err.Error())
case errors.Is(err, context.DeadlineExceeded):
return status.Error(codes.DeadlineExceeded, err.Error())
default:
return status.Error(codes.Internal, "internal ledger error")
}
}
+123
View File
@@ -0,0 +1,123 @@
package grpcadapter
import (
"context"
"errors"
"testing"
"time"
applicationledger "gl/application/ledger"
domain "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type ledgerRepositoryStub struct {
journal domain.Journal
}
func (r *ledgerRepositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) {
journal.RecordedAt = time.Unix(2, 0).UTC()
r.journal = journal
return domain.AppendResult{JournalID: journal.ID}, nil
}
func (r *ledgerRepositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) {
event.RecordedAt = time.Unix(2, 0).UTC()
return event, false, nil
}
func (r *ledgerRepositoryStub) GetByID(context.Context, string) (domain.Journal, error) {
return r.journal, nil
}
func (r *ledgerRepositoryStub) GetByIdempotencyKey(context.Context, string) (domain.Journal, error) {
return r.journal, nil
}
func (r *ledgerRepositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) {
return []domain.Journal{r.journal}, nil
}
func (r *ledgerRepositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) {
return domain.ParseAmount("12.5")
}
func TestAppendJournalMapsProtoToExactDomainAndBack(t *testing.T) {
repository := &ledgerRepositoryStub{}
service := applicationledger.NewService(repository, func() (string, error) {
return "11111111-1111-4111-8111-111111111111", nil
})
handler := NewHandler(nil, service)
response, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionId: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: timestamppb.New(time.Unix(1, 0).UTC()),
Entries: []*ledgerv1.JournalEntry{
{
LineNumber: 1,
Account: &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: "1",
AssetId: 5,
},
Amount: "-1.2500",
},
{
LineNumber: 2,
Account: &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: "2",
AssetId: 5,
},
Amount: "1.25",
},
},
})
if err != nil {
t.Fatal(err)
}
if got := response.GetJournal().GetEntries()[0].GetAmount(); got != "-1.25" {
t.Fatalf("unexpected canonical amount: %q", got)
}
if response.GetJournal().GetPayloadHash() == "" {
t.Fatal("payload hash is missing")
}
}
func TestAppendJournalRejectsMissingTimestamp(t *testing.T) {
handler := NewHandler(nil, nil)
_, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", err)
}
}
func TestRPCErrorMapping(t *testing.T) {
for _, testCase := range []struct {
err error
code codes.Code
}{
{err: applicationledger.ErrInvalidArgument, code: codes.InvalidArgument},
{err: domain.ErrNotFound, code: codes.NotFound},
{err: domain.ErrIdempotencyConflict, code: codes.AlreadyExists},
{err: domain.ErrIncompleteJournal, code: codes.FailedPrecondition},
{err: domain.ErrAlreadyReversed, code: codes.FailedPrecondition},
{err: context.Canceled, code: codes.Canceled},
{err: context.DeadlineExceeded, code: codes.DeadlineExceeded},
{err: errors.New("database details must not escape"), code: codes.Internal},
} {
if got := status.Code(rpcError(testCase.err)); got != testCase.code {
t.Fatalf("rpcError(%v) = %v, want %v", testCase.err, got, testCase.code)
}
}
}
+110
View File
@@ -0,0 +1,110 @@
package grpcadapter
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"runtime/debug"
"time"
ledgerv1 "gl/gen/ledger/v1"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
)
type ServerConfig struct {
Host string
Port int
ShutdownTimeout time.Duration
}
func Run(ctx context.Context, cfg ServerConfig, handler ledgerv1.GeneralLedgerServiceServer) error {
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
if err != nil {
return fmt.Errorf("listen for grpc: %w", err)
}
return runWithListener(ctx, cfg.ShutdownTimeout, listener, handler)
}
func runWithListener(ctx context.Context, shutdownTimeout time.Duration, listener net.Listener, handler ledgerv1.GeneralLedgerServiceServer) error {
defer listener.Close()
server := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()), grpc.ChainUnaryInterceptor(structuredUnaryLogger(), panicRecovery()))
ledgerv1.RegisterGeneralLedgerServiceServer(server, handler)
reflection.Register(server)
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
select {
case err := <-serveErr:
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return fmt.Errorf("serve grpc: %w", err)
case <-ctx.Done():
}
stopped := make(chan struct{})
go func() {
server.GracefulStop()
close(stopped)
}()
timer := time.NewTimer(shutdownTimeout)
defer timer.Stop()
select {
case <-stopped:
case <-timer.C:
server.Stop()
<-stopped
}
err := <-serveErr
if err != nil && !errors.Is(err, grpc.ErrServerStopped) {
return fmt.Errorf("serve grpc: %w", err)
}
return nil
}
func structuredUnaryLogger() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
started := time.Now()
response, err := handler(ctx, req)
code := status.Code(err)
level := slog.LevelInfo
if code != codes.OK {
level = slog.LevelError
}
slog.Log(ctx, level, "grpc call finished",
"component", "grpc_server",
"grpc_method", info.FullMethod,
"grpc_code", code.String(),
"duration_ms", float64(time.Since(started).Microseconds())/1000,
)
return response, err
}
}
func panicRecovery() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (response any, err error) {
defer func() {
if recovered := recover(); recovered != nil {
slog.ErrorContext(ctx, "grpc panic recovered",
"component", "grpc_server",
"grpc_method", info.FullMethod,
"panic", recovered,
"stack", string(debug.Stack()),
)
err = status.Error(codes.Internal, "internal server error")
}
}()
return handler(ctx, req)
}
}
+26
View File
@@ -0,0 +1,26 @@
package grpcadapter
import (
"context"
"testing"
"time"
"gl/application/health"
"google.golang.org/grpc/test/bufconn"
)
func TestRunStopsWhenContextIsCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := runWithListener(
ctx,
time.Second,
bufconn.Listen(1024),
NewHealthHandler(health.NewService(nil)),
)
if err != nil {
t.Fatal(err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package grpcadapter
import (
"context"
"errors"
"gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (h *Handler) EnqueueSettlement(ctx context.Context, request *ledgerv1.EnqueueSettlementRequest) (*ledgerv1.EnqueueSettlementResponse, error) {
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
record, already, err := h.settlement.Enqueue(ctx, request.GetSourceService(), request.GetSourceTransactionId(), request.GetIdempotencyKey(), request.GetNetwork(), request.GetSignedTransactionXdr())
if err != nil {
if errors.Is(err, ledger.ErrModeRequired) {
return nil, rpcError(err)
}
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &ledgerv1.EnqueueSettlementResponse{SettlementId: record.ID, Status: string(record.Status), AlreadyExisted: already}, nil
}
func (h *Handler) RetrySettlement(ctx context.Context, request *ledgerv1.RetrySettlementRequest) (*ledgerv1.RetrySettlementResponse, error) {
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
record, err := h.settlement.RetryByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
if err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &ledgerv1.RetrySettlementResponse{Settlement: settlementMessage(record)}, nil
}
func (h *Handler) IgnoreSettlement(ctx context.Context, request *ledgerv1.IgnoreSettlementRequest) (*ledgerv1.IgnoreSettlementResponse, error) {
if err := h.requireAdmin(ctx); err != nil {
return nil, err
}
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
record, err := h.settlement.IgnoreByOperator(ctx, request.GetSettlementId(), request.GetActorId(), request.GetReason())
if err != nil {
return nil, status.Error(codes.FailedPrecondition, err.Error())
}
return &ledgerv1.IgnoreSettlementResponse{Settlement: settlementMessage(record)}, nil
}
func settlementMessage(record ledger.Settlement) *ledgerv1.Settlement {
return &ledgerv1.Settlement{SettlementId: record.ID, SourceService: record.SourceService, SourceTransactionId: record.SourceTxID, IdempotencyKey: record.IdempotencyKey, Status: string(record.Status), Network: record.Network, TransactionHash: record.TransactionHash, Attempts: uint32(record.Attempts), LastError: record.LastError}
}
func (h *Handler) GetSettlement(ctx context.Context, request *ledgerv1.GetSettlementRequest) (*ledgerv1.Settlement, error) {
if h.settlement == nil {
return nil, status.Error(codes.Unimplemented, "settlement is not configured")
}
var id, key string
switch lookup := request.GetLookup().(type) {
case *ledgerv1.GetSettlementRequest_SettlementId:
id = lookup.SettlementId
case *ledgerv1.GetSettlementRequest_IdempotencyKey:
key = lookup.IdempotencyKey
}
record, err := h.settlement.Get(ctx, id, key)
if err != nil {
return nil, status.Error(codes.NotFound, err.Error())
}
return settlementMessage(record), nil
}
+21
View File
@@ -0,0 +1,21 @@
package grpcadapter
import (
"context"
"testing"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
func TestIgnoreSettlementRequiresAdminToken(t *testing.T) {
handler := NewHandler(nil, nil).WithSettlementAdminToken("secret")
for _, token := range []string{"", "wrong"} {
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-admin-token", token))
if _, err := handler.IgnoreSettlement(ctx, &ledgerv1.IgnoreSettlementRequest{}); status.Code(err) != codes.PermissionDenied {
t.Fatalf("unauthorized dismissal: %v", err)
}
}
}

Some files were not shown because too many files have changed in this diff Show More