Compare commits
10 Commits
| Author | SHA256 | Date | |
|---|---|---|---|
| 32e7b6b749 | |||
| 675def5fe6 | |||
| a54070cfad | |||
| 5b4cb5a2a3 | |||
| ce5f8b4a84 | |||
| 8ba5d6cef8 | |||
| 76905ab451 | |||
| 0b3eaa0c3d | |||
| b1e0b01598 | |||
| 1863de1f3b |
@@ -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
|
||||
@@ -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 build/Dockerfile \
|
||||
--build-arg "GO_PROXY=https://go.reg.darano.ir" \
|
||||
--load \
|
||||
-t "$IMAGE:$BRANCH" \
|
||||
-t "$IMAGE:$COMMIT_SHA" \
|
||||
.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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@v5
|
||||
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 }}
|
||||
@@ -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@v5
|
||||
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 }}
|
||||
@@ -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@v5
|
||||
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 }}
|
||||
+3
-1
@@ -22,6 +22,9 @@
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Air live-reload build artifacts
|
||||
/tmp/
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
@@ -240,4 +243,3 @@ cython_debug/
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# General Ledger service design
|
||||
|
||||
Status: accepted for the first implementation slice on 2026-08-14.
|
||||
|
||||
## Purpose and ownership
|
||||
|
||||
`GL` is Darano's durable financial journal. It records every committed wallet
|
||||
value movement independently of Stellar so balances and transaction history can
|
||||
be reconstructed during a blockchain or provider outage.
|
||||
|
||||
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 Wallet remains the transaction orchestrator and Stellar remains the
|
||||
external settlement network. Promoting GL from a mirror to an operational
|
||||
fallback is an explicit, audited mode change; an outage must never make a failed
|
||||
blockchain operation appear successful 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. 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.
|
||||
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
|
||||
|
||||
- `MIRROR`: normal mode. Wallet follows its existing settlement policy and GL
|
||||
asynchronously records every committed effect.
|
||||
- `DEGRADED_LEDGER`: explicitly enabled by an authorized operator. Eligible
|
||||
internal operations may settle against GL while blockchain-bound operations
|
||||
remain pending. The initial implementation does not activate this mode.
|
||||
- `RECONCILE`: outbound posting is paused or restricted while tooling compares
|
||||
Wallet, GL, and blockchain state and appends approved reversals/corrections.
|
||||
|
||||
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;
|
||||
- `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
|
||||
|
||||
- Replacing Stellar 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.
|
||||
@@ -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)
|
||||
@@ -1,2 +1,60 @@
|
||||
# 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).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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
|
||||
transactionPageSize = 20
|
||||
topAssetLimit = 5
|
||||
topHoldersPerAsset = 5
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
JournalCount int64
|
||||
EntryCount int64
|
||||
AccountCount int64
|
||||
LastRecordedAt 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)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
Stats Stats
|
||||
Journals []ledger.Journal
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return Dashboard{Stats: stats, Journals: journals}, 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)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package health provides the service readiness use case.
|
||||
package health
|
||||
|
||||
import "context"
|
||||
|
||||
type Database interface {
|
||||
Ping(context.Context) error
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Serving bool
|
||||
DatabaseReady bool
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
database Database
|
||||
}
|
||||
|
||||
func NewService(database Database) *Service {
|
||||
return &Service{database: database}
|
||||
}
|
||||
|
||||
func (s *Service) Check(ctx context.Context) Status {
|
||||
status := Status{Serving: true}
|
||||
if s.database != nil {
|
||||
status.DatabaseReady = s.database.Ping(ctx) == nil
|
||||
}
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type databaseStub struct{ err error }
|
||||
|
||||
func (s databaseStub) Ping(context.Context) error { return 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 || got.DatabaseReady != tc.ready {
|
||||
t.Fatalf("unexpected status: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
// 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) {
|
||||
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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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
|
||||
|
||||
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 gl.cfg.toml /app/gl.cfg.toml
|
||||
|
||||
USER darano
|
||||
|
||||
EXPOSE 8600
|
||||
|
||||
ENTRYPOINT ["/app/gl"]
|
||||
CMD ["-conf", "/app/gl.cfg.toml"]
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunSendsConcurrentRequestsAcrossTargets(t *testing.T) {
|
||||
server := httptest.NewServer(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 := httptest.NewServer(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gl/application/explorer"
|
||||
"gl/infrastructure/config"
|
||||
"gl/infrastructure/postgres"
|
||||
webadapter "gl/interface/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gl/application/health"
|
||||
applicationledger "gl/application/ledger"
|
||||
"gl/infrastructure/config"
|
||||
"gl/infrastructure/postgres"
|
||||
grpcadapter "gl/interface/grpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
handler := grpcadapter.NewHandler(health.NewService(database), applicationledger.NewService(repository, nil))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
environment = "local"
|
||||
|
||||
[http]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
read-header-timeout = "5s"
|
||||
shutdown-timeout = "10s"
|
||||
|
||||
[database]
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
ssl-mode = "disable"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// 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
@@ -0,0 +1,102 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// 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\xbe\x04\n" +
|
||||
"\x14GeneralLedgerService\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.ReplayJournalsResponseBy\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
|
||||
(*AppendJournalRequest)(nil), // 1: ledger.v1.AppendJournalRequest
|
||||
(*AppendTransactionEventRequest)(nil), // 2: ledger.v1.AppendTransactionEventRequest
|
||||
(*GetJournalRequest)(nil), // 3: ledger.v1.GetJournalRequest
|
||||
(*ListEntriesRequest)(nil), // 4: ledger.v1.ListEntriesRequest
|
||||
(*GetBalanceRequest)(nil), // 5: ledger.v1.GetBalanceRequest
|
||||
(*ReplayJournalsRequest)(nil), // 6: ledger.v1.ReplayJournalsRequest
|
||||
(*HealthResponse)(nil), // 7: ledger.v1.HealthResponse
|
||||
(*AppendJournalResponse)(nil), // 8: ledger.v1.AppendJournalResponse
|
||||
(*AppendTransactionEventResponse)(nil), // 9: ledger.v1.AppendTransactionEventResponse
|
||||
(*Journal)(nil), // 10: ledger.v1.Journal
|
||||
(*ListEntriesResponse)(nil), // 11: ledger.v1.ListEntriesResponse
|
||||
(*GetBalanceResponse)(nil), // 12: ledger.v1.GetBalanceResponse
|
||||
(*ReplayJournalsResponse)(nil), // 13: ledger.v1.ReplayJournalsResponse
|
||||
}
|
||||
var file_ledger_v1_srv_proto_depIdxs = []int32{
|
||||
0, // 0: ledger.v1.GeneralLedgerService.Health:input_type -> base.v1.Empty
|
||||
1, // 1: ledger.v1.GeneralLedgerService.AppendJournal:input_type -> ledger.v1.AppendJournalRequest
|
||||
2, // 2: ledger.v1.GeneralLedgerService.AppendTransactionEvent:input_type -> ledger.v1.AppendTransactionEventRequest
|
||||
3, // 3: ledger.v1.GeneralLedgerService.GetJournal:input_type -> ledger.v1.GetJournalRequest
|
||||
4, // 4: ledger.v1.GeneralLedgerService.ListEntries:input_type -> ledger.v1.ListEntriesRequest
|
||||
5, // 5: ledger.v1.GeneralLedgerService.GetBalance:input_type -> ledger.v1.GetBalanceRequest
|
||||
6, // 6: ledger.v1.GeneralLedgerService.ReplayJournals:input_type -> ledger.v1.ReplayJournalsRequest
|
||||
7, // 7: ledger.v1.GeneralLedgerService.Health:output_type -> ledger.v1.HealthResponse
|
||||
8, // 8: ledger.v1.GeneralLedgerService.AppendJournal:output_type -> ledger.v1.AppendJournalResponse
|
||||
9, // 9: ledger.v1.GeneralLedgerService.AppendTransactionEvent:output_type -> ledger.v1.AppendTransactionEventResponse
|
||||
10, // 10: ledger.v1.GeneralLedgerService.GetJournal:output_type -> ledger.v1.Journal
|
||||
11, // 11: ledger.v1.GeneralLedgerService.ListEntries:output_type -> ledger.v1.ListEntriesResponse
|
||||
12, // 12: ledger.v1.GeneralLedgerService.GetBalance:output_type -> ledger.v1.GetBalanceResponse
|
||||
13, // 13: ledger.v1.GeneralLedgerService.ReplayJournals:output_type -> ledger.v1.ReplayJournalsResponse
|
||||
7, // [7:14] is the sub-list for method output_type
|
||||
0, // [0:7] 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
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - 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_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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
type generalLedgerServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewGeneralLedgerServiceClient(cc grpc.ClientConnInterface) GeneralLedgerServiceClient {
|
||||
return &generalLedgerServiceClient{cc}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// GeneralLedgerServiceServer is the server API for GeneralLedgerService service.
|
||||
// All implementations should embed UnimplementedGeneralLedgerServiceServer
|
||||
// for forward compatibility.
|
||||
type GeneralLedgerServiceServer interface {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) Health(context.Context, *v1.Empty) (*HealthResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Health not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) AppendJournal(context.Context, *AppendJournalRequest) (*AppendJournalResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AppendJournal not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) AppendTransactionEvent(context.Context, *AppendTransactionEventRequest) (*AppendTransactionEventResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AppendTransactionEvent not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetJournal(context.Context, *GetJournalRequest) (*Journal, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetJournal not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListEntries not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) GetBalance(context.Context, *GetBalanceRequest) (*GetBalanceResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetBalance not implemented")
|
||||
}
|
||||
func (UnimplementedGeneralLedgerServiceServer) ReplayJournals(context.Context, *ReplayJournalsRequest) (*ReplayJournalsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ReplayJournals 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 panics, 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_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)
|
||||
}
|
||||
|
||||
// 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: "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,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "ledger/v1/srv.proto",
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
environment = "local"
|
||||
|
||||
[grpc]
|
||||
host = "0.0.0.0"
|
||||
port = 8600
|
||||
shutdown-timeout = "10s"
|
||||
|
||||
[database]
|
||||
host = "127.0.0.1"
|
||||
port = 5432
|
||||
name = "gl_db"
|
||||
user = "postgres"
|
||||
password = "postgres"
|
||||
ssl-mode = "disable"
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
google.golang.org/grpc v1.67.1
|
||||
google.golang.org/protobuf v1.36.6
|
||||
)
|
||||
|
||||
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/cli/browser v1.3.0 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.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
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||
)
|
||||
|
||||
tool github.com/a-h/templ
|
||||
@@ -0,0 +1,83 @@
|
||||
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/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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
|
||||
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
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=
|
||||
@@ -0,0 +1,142 @@
|
||||
// Package config owns GL configuration loading and validation.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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:"database"`
|
||||
}
|
||||
|
||||
type DashboardConfig struct {
|
||||
Environment string `koanf:"environment"`
|
||||
HTTP HTTPConfig `koanf:"http"`
|
||||
Database DatabaseConfig `koanf:"database"`
|
||||
}
|
||||
|
||||
type GRPCConfig struct {
|
||||
Host string `koanf:"host"`
|
||||
Port int `koanf:"port"`
|
||||
ShutdownTimeout time.Duration `koanf:"shutdown-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"`
|
||||
SSLMode string `koanf:"ssl-mode"`
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
if err := load(path, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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\nshutdown-timeout = \"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")
|
||||
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 != "gl_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]\nshutdown-timeout = \"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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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...)
|
||||
}
|
||||
@@ -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,181 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"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)
|
||||
}
|
||||
}()
|
||||
|
||||
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 errors.As(err, &postgresError) && postgresError.ConstraintName == "journals_one_reversal_idx" {
|
||||
return AppendResult{}, ledger.ErrAlreadyReversed
|
||||
}
|
||||
return AppendResult{}, fmt.Errorf("insert journal: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range journal.Entries {
|
||||
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 {
|
||||
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,235 @@
|
||||
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 *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(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,342 @@
|
||||
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) 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,32 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gl/application/health"
|
||||
applicationledger "gl/application/ledger"
|
||||
basev1 "gl/gen/base/v1"
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
ledgerv1.UnimplementedGeneralLedgerServiceServer
|
||||
health *health.Service
|
||||
ledger *applicationledger.Service
|
||||
}
|
||||
|
||||
func NewHandler(healthService *health.Service, ledgerService *applicationledger.Service) *Handler {
|
||||
return &Handler{health: healthService, ledger: ledgerService}
|
||||
}
|
||||
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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, 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package grpcadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
ledgerv1 "gl/gen/ledger/v1"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
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()
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed assets/favicon.ico
|
||||
var favicon []byte
|
||||
|
||||
//go:embed assets/theme.js
|
||||
var themeScript []byte
|
||||
|
||||
func (h *Handler) favicon(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
http.ServeContent(response, request, "favicon.ico", time.Time{}, bytes.NewReader(favicon))
|
||||
}
|
||||
|
||||
func (h *Handler) theme(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
response.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
http.ServeContent(response, request, "theme.js", time.Time{}, bytes.NewReader(themeScript))
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,54 @@
|
||||
(() => {
|
||||
const storageKey = "gl-theme";
|
||||
const root = document.documentElement;
|
||||
|
||||
const storedTheme = () => {
|
||||
try {
|
||||
const value = localStorage.getItem(storageKey);
|
||||
return value === "dark" || value === "light" ? value : "";
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const systemTheme = () =>
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
const updateControls = (theme) => {
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((control) => {
|
||||
const dark = theme === "dark";
|
||||
control.setAttribute("aria-pressed", String(dark));
|
||||
control.setAttribute("aria-label", dark ? control.dataset.lightLabel : control.dataset.darkLabel);
|
||||
const icon = control.querySelector("[data-theme-icon]");
|
||||
if (icon) icon.textContent = dark ? "☀" : "☾";
|
||||
});
|
||||
};
|
||||
|
||||
const applyTheme = (theme) => {
|
||||
root.dataset.theme = theme;
|
||||
root.style.colorScheme = theme;
|
||||
updateControls(theme);
|
||||
};
|
||||
|
||||
applyTheme(storedTheme() || systemTheme());
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => updateControls(root.dataset.theme));
|
||||
document.addEventListener("htmx:afterSwap", () => updateControls(root.dataset.theme));
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
const control = event.target.closest("[data-theme-toggle]");
|
||||
if (!control) return;
|
||||
const theme = root.dataset.theme === "dark" ? "light" : "dark";
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
} catch (_) {
|
||||
// The selected theme still applies for this page when storage is unavailable.
|
||||
}
|
||||
applyTheme(theme);
|
||||
});
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
media.addEventListener?.("change", () => {
|
||||
if (!storedTheme()) applyTheme(systemTheme());
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,318 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
|
||||
"gl/application/explorer"
|
||||
"gl/domain/ledger"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
type Explorer interface {
|
||||
Dashboard(context.Context) (explorer.Dashboard, error)
|
||||
Assets(context.Context) (explorer.Assets, error)
|
||||
Transactions(context.Context, explorer.TransactionFilter) (explorer.TransactionListing, error)
|
||||
Transaction(context.Context, string) ([]ledger.Journal, error)
|
||||
Account(context.Context, ledger.AccountReference) (explorer.Account, error)
|
||||
Holder(context.Context, explorer.HolderReference) (explorer.HolderAccount, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
explorer Explorer
|
||||
routes *http.ServeMux
|
||||
}
|
||||
|
||||
type TransactionPageData struct {
|
||||
Query string
|
||||
Journals []ledger.Journal
|
||||
Listing *explorer.TransactionListing
|
||||
Error string
|
||||
}
|
||||
|
||||
type AccountInput struct {
|
||||
Class string
|
||||
OwnerType string
|
||||
OwnerID string
|
||||
AssetID string
|
||||
}
|
||||
|
||||
type AccountPageData struct {
|
||||
Input AccountInput
|
||||
Account *explorer.Account
|
||||
Error string
|
||||
}
|
||||
|
||||
type HolderPageData struct {
|
||||
Account *explorer.HolderAccount
|
||||
Error string
|
||||
}
|
||||
|
||||
func NewHandler(service Explorer) *Handler {
|
||||
handler := &Handler{explorer: service, routes: http.NewServeMux()}
|
||||
handler.routes.HandleFunc("GET /favicon.ico", handler.favicon)
|
||||
handler.routes.HandleFunc("GET /theme.js", handler.theme)
|
||||
handler.routes.HandleFunc("GET /{$}", handler.dashboard)
|
||||
handler.routes.HandleFunc("GET /assets", handler.assets)
|
||||
handler.routes.HandleFunc("GET /transactions", handler.transaction)
|
||||
handler.routes.HandleFunc("GET /transactions/{reference}", handler.transaction)
|
||||
handler.routes.HandleFunc("GET /accounts", handler.account)
|
||||
handler.routes.HandleFunc("GET /holders", handler.holder)
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Handler) assets(response http.ResponseWriter, request *http.Request) {
|
||||
locale := localeForRequest(response, request)
|
||||
data, err := h.explorer.Assets(request.Context())
|
||||
if err != nil {
|
||||
h.renderFailure(response, request, locale, tr(locale, "assets_unavailable"), err)
|
||||
return
|
||||
}
|
||||
h.render(response, request, http.StatusOK, locale, tr(locale, "title_assets"), "assets", AssetsContent(data, locale))
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
response.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
response.Header().Set("Referrer-Policy", "same-origin")
|
||||
response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'")
|
||||
h.routes.ServeHTTP(response, request)
|
||||
}
|
||||
|
||||
func (h *Handler) dashboard(response http.ResponseWriter, request *http.Request) {
|
||||
locale := localeForRequest(response, request)
|
||||
data, err := h.explorer.Dashboard(request.Context())
|
||||
if err != nil {
|
||||
h.renderFailure(response, request, locale, tr(locale, "dashboard_unavailable"), err)
|
||||
return
|
||||
}
|
||||
h.render(response, request, http.StatusOK, locale, tr(locale, "title_overview"), "dashboard", DashboardContent(data, locale))
|
||||
}
|
||||
|
||||
func (h *Handler) transaction(response http.ResponseWriter, request *http.Request) {
|
||||
locale := localeForRequest(response, request)
|
||||
query := strings.TrimSpace(request.PathValue("reference"))
|
||||
if query == "" {
|
||||
query = strings.TrimSpace(request.URL.Query().Get("q"))
|
||||
}
|
||||
data := TransactionPageData{Query: query}
|
||||
showLatest := query == ""
|
||||
if query != "" {
|
||||
journals, err := h.explorer.Transaction(request.Context(), query)
|
||||
switch {
|
||||
case err == nil:
|
||||
data.Journals = journals
|
||||
case requestEnded(request, err):
|
||||
return
|
||||
case explorer.IsNotFound(err):
|
||||
data.Error = tr(locale, "transaction_missing")
|
||||
showLatest = true
|
||||
default:
|
||||
data.Error = tr(locale, "transaction_lookup_error")
|
||||
}
|
||||
}
|
||||
if showLatest {
|
||||
page, parseErr := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get("page")))
|
||||
if parseErr != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
listing, err := h.explorer.Transactions(request.Context(), explorer.TransactionFilter{
|
||||
Page: page,
|
||||
Wallet: request.URL.Query().Get("wallet"),
|
||||
EffectKind: request.URL.Query().Get("effect"),
|
||||
})
|
||||
if requestEnded(request, err) {
|
||||
return
|
||||
} else if err != nil {
|
||||
h.renderFailure(response, request, locale, tr(locale, "transactions_unavailable"), err)
|
||||
return
|
||||
}
|
||||
data.Listing = &listing
|
||||
}
|
||||
h.render(response, request, http.StatusOK, locale, tr(locale, "title_transactions"), "transactions", TransactionContent(data, locale))
|
||||
}
|
||||
|
||||
func (h *Handler) account(response http.ResponseWriter, request *http.Request) {
|
||||
locale := localeForRequest(response, request)
|
||||
data := AccountPageData{Input: AccountInput{
|
||||
Class: strings.TrimSpace(request.URL.Query().Get("class")),
|
||||
OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")),
|
||||
OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")),
|
||||
AssetID: strings.TrimSpace(request.URL.Query().Get("asset_id")),
|
||||
}}
|
||||
if request.URL.Query().Has("class") {
|
||||
assetID, err := strconv.ParseInt(data.Input.AssetID, 10, 64)
|
||||
if err != nil || assetID <= 0 {
|
||||
data.Error = tr(locale, "asset_positive")
|
||||
} else {
|
||||
result, lookupErr := h.explorer.Account(request.Context(), ledger.AccountReference{
|
||||
Class: ledger.AccountClass(data.Input.Class),
|
||||
OwnerType: data.Input.OwnerType,
|
||||
OwnerID: data.Input.OwnerID,
|
||||
AssetID: assetID,
|
||||
})
|
||||
if requestEnded(request, lookupErr) {
|
||||
return
|
||||
} else if lookupErr != nil {
|
||||
data.Error = tr(locale, "account_lookup_error")
|
||||
} else {
|
||||
data.Account = &result
|
||||
}
|
||||
}
|
||||
}
|
||||
h.render(response, request, http.StatusOK, locale, tr(locale, "title_accounts"), "accounts", AccountContent(data, locale))
|
||||
}
|
||||
|
||||
func (h *Handler) holder(response http.ResponseWriter, request *http.Request) {
|
||||
locale := localeForRequest(response, request)
|
||||
data := HolderPageData{}
|
||||
assetID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("asset_id")), 10, 64)
|
||||
reference := explorer.HolderReference{
|
||||
OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")),
|
||||
OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")),
|
||||
AssetID: assetID,
|
||||
}
|
||||
if err != nil || reference.OwnerType == "" || reference.OwnerID == "" || reference.AssetID <= 0 {
|
||||
data.Error = tr(locale, "holder_reference_invalid")
|
||||
} else {
|
||||
result, lookupErr := h.explorer.Holder(request.Context(), reference)
|
||||
if requestEnded(request, lookupErr) {
|
||||
return
|
||||
} else if lookupErr != nil {
|
||||
data.Error = tr(locale, "account_lookup_error")
|
||||
} else {
|
||||
data.Account = &result
|
||||
}
|
||||
}
|
||||
h.render(response, request, http.StatusOK, locale, tr(locale, "title_holder"), "assets", HolderContent(data, locale))
|
||||
}
|
||||
|
||||
func (h *Handler) render(response http.ResponseWriter, request *http.Request, status int, locale Locale, title, active string, content templ.Component) {
|
||||
component := Page(title, active, locale, localeURL(request, LocaleEnglish), localeURL(request, LocalePersian), content)
|
||||
if request.Header.Get("HX-Request") == "true" && request.Header.Get("HX-History-Restore-Request") != "true" {
|
||||
component = content
|
||||
}
|
||||
response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
response.WriteHeader(status)
|
||||
if err := component.Render(request.Context(), response); err != nil {
|
||||
if requestEnded(request, err) {
|
||||
return
|
||||
}
|
||||
slog.Error("render explorer", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) renderFailure(response http.ResponseWriter, request *http.Request, locale Locale, title string, err error) {
|
||||
if requestEnded(request, err) {
|
||||
return
|
||||
}
|
||||
slog.Error("explorer request failed", "error", err)
|
||||
h.render(response, request, http.StatusInternalServerError, locale, title, "", FailureContent(title, tr(locale, "read_model_unavailable"), locale))
|
||||
}
|
||||
|
||||
func requestEnded(request *http.Request, err error) bool {
|
||||
return request.Context().Err() != nil ||
|
||||
errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, syscall.EPIPE) ||
|
||||
errors.Is(err, syscall.ECONNRESET)
|
||||
}
|
||||
|
||||
func accountClasses() []ledger.AccountClass {
|
||||
return []ledger.AccountClass{
|
||||
ledger.AccountClassUserAvailable,
|
||||
ledger.AccountClassUserFrozen,
|
||||
ledger.AccountClassExternalBlockchain,
|
||||
ledger.AccountClassTreasury,
|
||||
ledger.AccountClassMarketClearing,
|
||||
ledger.AccountClassIPGClearing,
|
||||
ledger.AccountClassCommissionRevenue,
|
||||
}
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() || value.Unix() == 0 {
|
||||
return "—"
|
||||
}
|
||||
return value.In(defaultTimezone).Format("02 Jan 2006 · 15:04:05") + " Asia/Tehran"
|
||||
}
|
||||
|
||||
func short(value string, size int) string {
|
||||
if len(value) <= size {
|
||||
return value
|
||||
}
|
||||
left := size / 2
|
||||
return value[:left] + "…" + value[len(value)-(size-left):]
|
||||
}
|
||||
|
||||
func count(value int64) string {
|
||||
text := strconv.FormatInt(value, 10)
|
||||
for index := len(text) - 3; index > 0; index -= 3 {
|
||||
text = text[:index] + "," + text[index:]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func transactionName(journal ledger.Journal, locale Locale) string {
|
||||
if journal.Blockchain.TransactionHash != "" {
|
||||
return short(journal.Blockchain.TransactionHash, 22)
|
||||
}
|
||||
return tr(locale, "internal") + " · " + short(journal.ID, 14)
|
||||
}
|
||||
|
||||
func transactionReference(journal ledger.Journal) string {
|
||||
if journal.Blockchain.TransactionHash != "" {
|
||||
return journal.Blockchain.TransactionHash
|
||||
}
|
||||
return journal.ID
|
||||
}
|
||||
|
||||
func transactionURL(reference string) string {
|
||||
return "/transactions/" + url.PathEscape(reference)
|
||||
}
|
||||
|
||||
func transactionPageURL(filter explorer.TransactionFilter, page int) string {
|
||||
values := url.Values{"page": []string{strconv.Itoa(page)}}
|
||||
if filter.Wallet != "" {
|
||||
values.Set("wallet", filter.Wallet)
|
||||
}
|
||||
if filter.EffectKind != "" {
|
||||
values.Set("effect", filter.EffectKind)
|
||||
}
|
||||
return "/transactions?" + values.Encode()
|
||||
}
|
||||
|
||||
func accountURL(account ledger.AccountReference) string {
|
||||
values := url.Values{
|
||||
"class": []string{string(account.Class)},
|
||||
"owner_type": []string{account.OwnerType},
|
||||
"owner_id": []string{account.OwnerID},
|
||||
"asset_id": []string{strconv.FormatInt(account.AssetID, 10)},
|
||||
}
|
||||
return "/accounts?" + values.Encode()
|
||||
}
|
||||
|
||||
func holderURL(holder explorer.Holder) string {
|
||||
values := url.Values{
|
||||
"owner_type": []string{holder.OwnerType},
|
||||
"owner_id": []string{holder.OwnerID},
|
||||
"asset_id": []string{strconv.FormatInt(holder.AssetID, 10)},
|
||||
}
|
||||
return "/holders?" + values.Encode()
|
||||
}
|
||||
|
||||
func accountName(account ledger.AccountReference, locale Locale) string {
|
||||
owner := account.OwnerID
|
||||
if owner == "" {
|
||||
owner = tr(locale, "system")
|
||||
}
|
||||
return fmt.Sprintf("%s / %s", account.Class, owner)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gl/application/explorer"
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
type explorerStub struct {
|
||||
dashboard explorer.Dashboard
|
||||
dashboardErr error
|
||||
assets explorer.Assets
|
||||
assetsErr error
|
||||
listing explorer.TransactionListing
|
||||
listingErr error
|
||||
transaction []ledger.Journal
|
||||
transactionErr error
|
||||
account explorer.Account
|
||||
accountErr error
|
||||
holder explorer.HolderAccount
|
||||
holderErr error
|
||||
lastHash string
|
||||
lastFilter explorer.TransactionFilter
|
||||
lastAccount ledger.AccountReference
|
||||
}
|
||||
|
||||
func (s *explorerStub) Dashboard(context.Context) (explorer.Dashboard, error) {
|
||||
return s.dashboard, s.dashboardErr
|
||||
}
|
||||
|
||||
func (s *explorerStub) Assets(context.Context) (explorer.Assets, error) {
|
||||
return s.assets, s.assetsErr
|
||||
}
|
||||
|
||||
func (s *explorerStub) Transactions(_ context.Context, filter explorer.TransactionFilter) (explorer.TransactionListing, error) {
|
||||
s.lastFilter = filter
|
||||
return s.listing, s.listingErr
|
||||
}
|
||||
|
||||
func (s *explorerStub) Transaction(_ context.Context, hash string) ([]ledger.Journal, error) {
|
||||
s.lastHash = hash
|
||||
return s.transaction, s.transactionErr
|
||||
}
|
||||
|
||||
func (s *explorerStub) Account(_ context.Context, account ledger.AccountReference) (explorer.Account, error) {
|
||||
s.lastAccount = account
|
||||
return s.account, s.accountErr
|
||||
}
|
||||
|
||||
func (s *explorerStub) Holder(_ context.Context, reference explorer.HolderReference) (explorer.HolderAccount, error) {
|
||||
s.holder.Reference = reference
|
||||
return s.holder, s.holderErr
|
||||
}
|
||||
|
||||
func TestDashboardRendersFullTemplPage(t *testing.T) {
|
||||
service := &explorerStub{dashboard: explorer.Dashboard{
|
||||
Stats: explorer.Stats{JournalCount: 1234, LastRecordedAt: time.Unix(10, 0).UTC()},
|
||||
Journals: []ledger.Journal{{
|
||||
ID: "journal-1", EffectKind: "deposit", SourceService: "wallet",
|
||||
Blockchain: ledger.BlockchainReference{TransactionHash: "abc123"},
|
||||
}},
|
||||
}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"<!doctype html>", "DARANO", "1,234", "abc123", "htmx.org@2.0.10", "src=\"/theme.js\"", "data-theme-toggle", "html[data-theme=\"dark\"]", "class=\"mark\" src=\"/favicon.ico\"", "#F5F5F5", "#DFF1F1", "#BBD5DA", "#FF0000"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("response did not contain %q", expected)
|
||||
}
|
||||
}
|
||||
if response.Header().Get("Content-Security-Policy") == "" {
|
||||
t.Fatal("expected content security policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardLinksInternalTransactionsByJournalID(t *testing.T) {
|
||||
const journalID = "5c1e31b0-0000-4000-8000-0000084accc8"
|
||||
service := &explorerStub{dashboard: explorer.Dashboard{Journals: []ledger.Journal{{ID: journalID}}}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
if !strings.Contains(response.Body.String(), `href="/transactions/`+journalID+`"`) {
|
||||
t.Fatalf("internal transaction was not linked: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetsPageRendersLinkedTopAssetHolders(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("987.25")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &explorerStub{assets: explorer.Assets{TopHolders: []explorer.Holder{{
|
||||
Rank: 1, OwnerType: "user", OwnerID: "holder-42", AssetID: 7, Balance: balance,
|
||||
}}}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/assets", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"Top asset holders", "holder-42", "Asset 7", "987.25", "/holders?asset_id=7&owner_id=holder-42&owner_type=user"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("top holder response did not contain %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolderPageLoadsAggregateAccountDetail(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("42.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &explorerStub{holder: explorer.HolderAccount{Balance: balance}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/holders?owner_type=user&owner_id=holder-42&asset_id=7", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"holder-42", "Asset 7", "42.5", "AVAILABLE + FROZEN"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("holder response did not contain %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardDefaultsToEnglishAndSupportsPersian(t *testing.T) {
|
||||
service := &explorerStub{}
|
||||
handler := NewHandler(service)
|
||||
|
||||
englishRequest := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
englishResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(englishResponse, englishRequest)
|
||||
if !strings.Contains(englishResponse.Body.String(), `lang="en" dir="ltr"`) || !strings.Contains(englishResponse.Body.String(), "Every movement") {
|
||||
t.Fatalf("dashboard did not default to English: %s", englishResponse.Body.String())
|
||||
}
|
||||
|
||||
persianRequest := httptest.NewRequest(http.MethodGet, "/?lang=fa", nil)
|
||||
persianResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(persianResponse, persianRequest)
|
||||
if !strings.Contains(persianResponse.Body.String(), `lang="fa" dir="rtl"`) || !strings.Contains(persianResponse.Body.String(), "هر جابهجایی") {
|
||||
t.Fatalf("dashboard did not render Persian: %s", persianResponse.Body.String())
|
||||
}
|
||||
if !strings.Contains(persianResponse.Header().Get("Set-Cookie"), "gl_locale=fa") {
|
||||
t.Fatal("Persian locale was not persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardUsesLocaleCookie(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/transactions", nil)
|
||||
request.AddCookie(&http.Cookie{Name: localeCookie, Value: "fa"})
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(&explorerStub{}).ServeHTTP(response, request)
|
||||
|
||||
if !strings.Contains(response.Body.String(), "کاوشگر تراکنش") {
|
||||
t.Fatalf("locale cookie was ignored: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaviconIsServedFromEmbeddedBrandAsset(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(&explorerStub{}).ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK || response.Body.Len() < 1000 {
|
||||
t.Fatalf("unexpected favicon response: status=%d bytes=%d", response.Code, response.Body.Len())
|
||||
}
|
||||
if !strings.Contains(response.Header().Get("Content-Type"), "image/") {
|
||||
t.Fatalf("unexpected favicon content type: %s", response.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeScriptIsServedFromEmbeddedAsset(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/theme.js", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(&explorerStub{}).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(body, "localStorage") || !strings.Contains(body, "prefers-color-scheme") {
|
||||
t.Fatalf("unexpected theme script response: status=%d body=%s", response.Code, body)
|
||||
}
|
||||
if !strings.Contains(response.Header().Get("Content-Type"), "javascript") {
|
||||
t.Fatalf("unexpected theme script content type: %s", response.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionHTMXRequestRendersOnlyExplorerContent(t *testing.T) {
|
||||
service := &explorerStub{transaction: []ledger.Journal{{
|
||||
ID: "journal-1", EffectKind: "withdrawal",
|
||||
Blockchain: ledger.BlockchainReference{TransactionHash: "tx-hash"},
|
||||
}}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/transactions?q=tx-hash", nil)
|
||||
request.Header.Set("HX-Request", "true")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="explorer-content"`) {
|
||||
t.Fatalf("unexpected HTMX fragment: %s", body)
|
||||
}
|
||||
if service.lastHash != "tx-hash" || !strings.Contains(body, "COMMITTED") {
|
||||
t.Fatalf("transaction was not rendered: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionsPageShowsLatestTransactionsWithPagination(t *testing.T) {
|
||||
service := &explorerStub{listing: explorer.TransactionListing{
|
||||
Journals: []ledger.Journal{{ID: "latest-journal", EffectKind: "transfer"}},
|
||||
Filter: explorer.TransactionFilter{Page: 2, Wallet: "wallet-42", EffectKind: "transfer"},
|
||||
HasPrevious: true, HasNext: true,
|
||||
}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/transactions?page=2&wallet=wallet-42&effect=transfer", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"Latest transactions", "latest-journal", "value=\"wallet-42\"", "value=\"transfer\"", "/transactions?effect=transfer&page=1&wallet=wallet-42", "/transactions?effect=transfer&page=3&wallet=wallet-42"} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("transaction listing did not contain %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
if service.lastFilter.Page != 2 || service.lastFilter.Wallet != "wallet-42" || service.lastFilter.EffectKind != "transfer" {
|
||||
t.Fatalf("unexpected transaction filter: %+v", service.lastFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingTransactionShowsLatestTransactions(t *testing.T) {
|
||||
service := &explorerStub{
|
||||
transactionErr: ledger.ErrNotFound,
|
||||
listing: explorer.TransactionListing{Journals: []ledger.Journal{{ID: "latest-journal"}}, Filter: explorer.TransactionFilter{Page: 1}},
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/transactions?q=missing", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
body := response.Body.String()
|
||||
if !strings.Contains(body, "No committed transaction matches") || !strings.Contains(body, "latest-journal") {
|
||||
t.Fatalf("missing transaction did not include latest activity: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountExplorerParsesStableAccountIdentity(t *testing.T) {
|
||||
balance, err := ledger.ParseAmount("42.5")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := &explorerStub{account: explorer.Account{Balance: balance, Reference: ledger.AccountReference{
|
||||
Class: ledger.AccountClassUserAvailable, OwnerType: "user", OwnerID: "17", AssetID: 9,
|
||||
}}}
|
||||
request := httptest.NewRequest(http.MethodGet, "/accounts?class=USER_AVAILABLE&owner_type=user&owner_id=17&asset_id=9", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
if service.lastAccount.OwnerID != "17" || service.lastAccount.AssetID != 9 {
|
||||
t.Fatalf("unexpected account lookup: %+v", service.lastAccount)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "42.5") {
|
||||
t.Fatal("account balance was not rendered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledDashboardRequestDoesNotRenderAnErrorPage(t *testing.T) {
|
||||
service := &explorerStub{dashboardErr: context.Canceled}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
NewHandler(service).ServeHTTP(response, request)
|
||||
|
||||
if response.Body.Len() != 0 {
|
||||
t.Fatalf("expected canceled request to produce no response, got %q", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimeUsesTehranTimezone(t *testing.T) {
|
||||
value := time.Date(2026, time.August, 14, 0, 0, 0, 0, time.UTC)
|
||||
if got := formatTime(value); got != "14 Aug 2026 · 03:30:00 Asia/Tehran" {
|
||||
t.Fatalf("unexpected localized time: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Locale string
|
||||
|
||||
const (
|
||||
LocaleEnglish Locale = "en"
|
||||
LocalePersian Locale = "fa"
|
||||
localeCookie = "gl_locale"
|
||||
)
|
||||
|
||||
var defaultTimezone = mustLoadLocation("Asia/Tehran")
|
||||
|
||||
func mustLoadLocation(name string) *time.Location {
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
panic("load dashboard timezone: " + err.Error())
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func localeForRequest(response http.ResponseWriter, request *http.Request) Locale {
|
||||
if locale, ok := validLocale(request.URL.Query().Get("lang")); ok {
|
||||
http.SetCookie(response, &http.Cookie{
|
||||
Name: localeCookie,
|
||||
Value: string(locale),
|
||||
Path: "/",
|
||||
MaxAge: 365 * 24 * 60 * 60,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return locale
|
||||
}
|
||||
if cookie, err := request.Cookie(localeCookie); err == nil {
|
||||
if locale, ok := validLocale(cookie.Value); ok {
|
||||
return locale
|
||||
}
|
||||
}
|
||||
return LocaleEnglish
|
||||
}
|
||||
|
||||
func validLocale(value string) (Locale, bool) {
|
||||
switch Locale(value) {
|
||||
case LocaleEnglish:
|
||||
return LocaleEnglish, true
|
||||
case LocalePersian:
|
||||
return LocalePersian, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (l Locale) Direction() string {
|
||||
if l == LocalePersian {
|
||||
return "rtl"
|
||||
}
|
||||
return "ltr"
|
||||
}
|
||||
|
||||
func localeURL(request *http.Request, locale Locale) string {
|
||||
query := cloneQuery(request.URL.Query())
|
||||
query.Set("lang", string(locale))
|
||||
return request.URL.Path + "?" + query.Encode()
|
||||
}
|
||||
|
||||
func cloneQuery(source url.Values) url.Values {
|
||||
result := make(url.Values, len(source))
|
||||
for key, values := range source {
|
||||
result[key] = append([]string(nil), values...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func tr(locale Locale, key string) string {
|
||||
if locale == LocalePersian {
|
||||
if value, ok := persian[key]; ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if value, ok := english[key]; ok {
|
||||
return value
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
var english = map[string]string{
|
||||
"site_name": "DARANO",
|
||||
"site_subtitle": "LEDGER NETWORK",
|
||||
"overview": "Overview",
|
||||
"transactions": "Transactions",
|
||||
"assets": "Assets",
|
||||
"accounts": "Accounts",
|
||||
"read_only": "READ ONLY",
|
||||
"dark_mode": "Use dark mode",
|
||||
"light_mode": "Use light mode",
|
||||
"general_ledger_explorer": "General ledger explorer",
|
||||
"hero_line_one": "Every movement.",
|
||||
"hero_line_two": "One durable record.",
|
||||
"hero_description": "Inspect committed journals, trace blockchain transaction hashes, and reconstruct any ledger account without touching the write path.",
|
||||
"transaction_placeholder": "Enter a transaction hash or journal ID",
|
||||
"transaction_hash": "Transaction hash",
|
||||
"explore_transaction": "Explore transaction",
|
||||
"committed_journals": "Committed journals",
|
||||
"ledger_entries": "Ledger entries",
|
||||
"tracked_accounts": "Tracked accounts",
|
||||
"last_recorded": "Last recorded",
|
||||
"latest_activity": "Latest ledger activity",
|
||||
"newest_first": "NEWEST FIRST",
|
||||
"top_asset_holders": "Top asset holders",
|
||||
"holder_balance_scope": "AVAILABLE + FROZEN · RECENT ASSETS",
|
||||
"no_asset_holders": "No positive user holdings yet.",
|
||||
"assets_explorer": "Asset explorer",
|
||||
"track_asset_ownership": "Track asset ownership.",
|
||||
"assets_description": "Review the leading positive user balances for the most recently active ledger assets.",
|
||||
"rank": "Rank",
|
||||
"holder": "Holder",
|
||||
"balance": "Balance",
|
||||
"holder_account": "Holder account",
|
||||
"inspect_holder": "Inspect a holder.",
|
||||
"holder_description": "This account combines the holder's available and frozen balances and activity for one asset.",
|
||||
"combined_balance": "Combined balance",
|
||||
"available_and_frozen": "AVAILABLE + FROZEN",
|
||||
"holder_reference_invalid": "A holder identity and positive asset ID are required.",
|
||||
"transaction": "Transaction",
|
||||
"effect": "Effect",
|
||||
"source": "Source",
|
||||
"entries": "Entries",
|
||||
"recorded": "Recorded",
|
||||
"no_journals": "No committed journals yet.",
|
||||
"network": "NETWORK",
|
||||
"transaction_explorer": "Transaction explorer",
|
||||
"trace_settlement": "Trace a settlement.",
|
||||
"transaction_description": "Search an exact blockchain hash or internal journal ID retained on committed ledger journals.",
|
||||
"transaction_not_found": "Transaction not found",
|
||||
"transaction_missing": "No committed transaction matches that hash or journal ID.",
|
||||
"transaction_lookup_error": "The transaction could not be loaded. Try again shortly.",
|
||||
"enter_hash": "Enter a hash or journal ID to inspect its balanced financial effects.",
|
||||
"latest_transactions": "Latest transactions",
|
||||
"page": "PAGE",
|
||||
"pagination": "Transaction pages",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"user_wallet": "User / wallet",
|
||||
"user_wallet_placeholder": "Exact owner or wallet ID",
|
||||
"effect_type": "Effect type",
|
||||
"effect_type_placeholder": "Exact effect type",
|
||||
"apply_filters": "Apply filters",
|
||||
"clear_filters": "Clear",
|
||||
"committed": "COMMITTED",
|
||||
"journal_id": "Journal ID",
|
||||
"ledger_sequence": "Ledger sequence",
|
||||
"balanced_entries": "Balanced entries",
|
||||
"asset": "Asset",
|
||||
"internal": "internal",
|
||||
"system": "system",
|
||||
"account_explorer": "Account explorer",
|
||||
"rebuild_account": "Rebuild an account.",
|
||||
"account_description": "Select the stable ledger identity and asset to calculate its current balance from immutable entries.",
|
||||
"account_class": "Account class",
|
||||
"owner_type": "Owner type",
|
||||
"owner_id": "Owner ID",
|
||||
"stable_owner_id": "Stable owner ID",
|
||||
"asset_id": "Asset ID",
|
||||
"explore": "Explore",
|
||||
"account_unavailable": "Account unavailable",
|
||||
"asset_positive": "Asset ID must be a positive integer.",
|
||||
"account_lookup_error": "The account could not be loaded. Check its identity and try again.",
|
||||
"ledger_identity": "Ledger identity",
|
||||
"current_balance": "Current balance",
|
||||
"account_activity": "Account activity",
|
||||
"journals": "JOURNALS",
|
||||
"choose_account": "Choose an account class, owner, and asset to begin.",
|
||||
"explorer_error": "Explorer error",
|
||||
"dashboard_unavailable": "Dashboard unavailable",
|
||||
"assets_unavailable": "Assets unavailable",
|
||||
"transactions_unavailable": "Transactions unavailable",
|
||||
"read_model_unavailable": "The ledger read model could not be reached. Try again shortly.",
|
||||
"footer_description": "Darano General Ledger · immutable financial history",
|
||||
"timezone_notice": "All times shown in Asia/Tehran",
|
||||
"title_overview": "Network overview",
|
||||
"title_transactions": "Transaction explorer",
|
||||
"title_assets": "Asset explorer",
|
||||
"title_accounts": "Account explorer",
|
||||
"title_holder": "Holder account",
|
||||
}
|
||||
|
||||
var persian = map[string]string{
|
||||
"site_name": "دارانو",
|
||||
"site_subtitle": "شبکه دفتر کل",
|
||||
"overview": "نمای کلی",
|
||||
"transactions": "تراکنشها",
|
||||
"assets": "داراییها",
|
||||
"accounts": "حسابها",
|
||||
"read_only": "فقط خواندنی",
|
||||
"dark_mode": "استفاده از حالت تیره",
|
||||
"light_mode": "استفاده از حالت روشن",
|
||||
"general_ledger_explorer": "کاوشگر دفتر کل",
|
||||
"hero_line_one": "هر جابهجایی.",
|
||||
"hero_line_two": "یک سابقه ماندگار.",
|
||||
"hero_description": "دفاتر ثبتشده را ببینید، هش تراکنشهای بلاکچین را ردیابی کنید و هر حساب دفتر کل را بدون دسترسی به مسیر نوشتن بازسازی کنید.",
|
||||
"transaction_placeholder": "هش تراکنش یا شناسه دفتر را وارد کنید",
|
||||
"transaction_hash": "هش تراکنش",
|
||||
"explore_transaction": "جستوجوی تراکنش",
|
||||
"committed_journals": "دفاتر ثبتشده",
|
||||
"ledger_entries": "ردیفهای دفتر کل",
|
||||
"tracked_accounts": "حسابهای ردیابیشده",
|
||||
"last_recorded": "آخرین ثبت",
|
||||
"latest_activity": "آخرین فعالیت دفتر کل",
|
||||
"newest_first": "جدیدترین ابتدا",
|
||||
"top_asset_holders": "دارندگان برتر دارایی",
|
||||
"holder_balance_scope": "در دسترس + مسدود · داراییهای اخیر",
|
||||
"no_asset_holders": "هنوز موجودی مثبت کاربری ثبت نشده است.",
|
||||
"assets_explorer": "کاوشگر دارایی",
|
||||
"track_asset_ownership": "مالکیت دارایی را ردیابی کنید.",
|
||||
"assets_description": "بالاترین موجودیهای مثبت کاربران را برای داراییهای فعال اخیر دفتر کل بررسی کنید.",
|
||||
"rank": "رتبه",
|
||||
"holder": "دارنده",
|
||||
"balance": "موجودی",
|
||||
"holder_account": "حساب دارنده",
|
||||
"inspect_holder": "دارنده را بررسی کنید.",
|
||||
"holder_description": "این حساب موجودی و فعالیت در دسترس و مسدود دارنده را برای یک دارایی ترکیب میکند.",
|
||||
"combined_balance": "موجودی ترکیبی",
|
||||
"available_and_frozen": "در دسترس + مسدود",
|
||||
"holder_reference_invalid": "شناسه دارنده و شناسه مثبت دارایی الزامی است.",
|
||||
"transaction": "تراکنش",
|
||||
"effect": "اثر",
|
||||
"source": "منبع",
|
||||
"entries": "ردیفها",
|
||||
"recorded": "زمان ثبت",
|
||||
"no_journals": "هنوز دفتری ثبت نشده است.",
|
||||
"network": "شبکه",
|
||||
"transaction_explorer": "کاوشگر تراکنش",
|
||||
"trace_settlement": "تسویه را ردیابی کنید.",
|
||||
"transaction_description": "هش دقیق بلاکچین یا شناسه دفتر داخلی را در دفاتر قطعی جستوجو کنید.",
|
||||
"transaction_not_found": "تراکنش پیدا نشد",
|
||||
"transaction_missing": "هیچ تراکنش قطعی با این هش یا شناسه دفتر پیدا نشد.",
|
||||
"transaction_lookup_error": "بارگذاری تراکنش ممکن نشد. کمی بعد دوباره تلاش کنید.",
|
||||
"enter_hash": "برای مشاهده اثرهای مالی تراز، هش یا شناسه دفتر را وارد کنید.",
|
||||
"latest_transactions": "آخرین تراکنشها",
|
||||
"page": "صفحه",
|
||||
"pagination": "صفحههای تراکنش",
|
||||
"previous": "قبلی",
|
||||
"next": "بعدی",
|
||||
"user_wallet": "کاربر / کیف پول",
|
||||
"user_wallet_placeholder": "شناسه دقیق مالک یا کیف پول",
|
||||
"effect_type": "نوع اثر",
|
||||
"effect_type_placeholder": "نوع دقیق اثر",
|
||||
"apply_filters": "اعمال فیلترها",
|
||||
"clear_filters": "پاککردن",
|
||||
"committed": "ثبتشده",
|
||||
"journal_id": "شناسه دفتر",
|
||||
"ledger_sequence": "شماره دفتر",
|
||||
"balanced_entries": "ردیفهای تراز",
|
||||
"asset": "دارایی",
|
||||
"internal": "داخلی",
|
||||
"system": "سیستم",
|
||||
"account_explorer": "کاوشگر حساب",
|
||||
"rebuild_account": "یک حساب را بازسازی کنید.",
|
||||
"account_description": "شناسه پایدار حساب و دارایی را انتخاب کنید تا موجودی فعلی از ردیفهای تغییرناپذیر محاسبه شود.",
|
||||
"account_class": "نوع حساب",
|
||||
"owner_type": "نوع مالک",
|
||||
"owner_id": "شناسه مالک",
|
||||
"stable_owner_id": "شناسه پایدار مالک",
|
||||
"asset_id": "شناسه دارایی",
|
||||
"explore": "جستوجو",
|
||||
"account_unavailable": "حساب در دسترس نیست",
|
||||
"asset_positive": "شناسه دارایی باید یک عدد صحیح مثبت باشد.",
|
||||
"account_lookup_error": "بارگذاری حساب ممکن نشد. شناسه آن را بررسی و دوباره تلاش کنید.",
|
||||
"ledger_identity": "شناسه دفتر کل",
|
||||
"current_balance": "موجودی فعلی",
|
||||
"account_activity": "فعالیت حساب",
|
||||
"journals": "دفتر",
|
||||
"choose_account": "برای شروع، نوع حساب، مالک و دارایی را انتخاب کنید.",
|
||||
"explorer_error": "خطای کاوشگر",
|
||||
"dashboard_unavailable": "داشبورد در دسترس نیست",
|
||||
"assets_unavailable": "داراییها در دسترس نیستند",
|
||||
"transactions_unavailable": "تراکنشها در دسترس نیستند",
|
||||
"read_model_unavailable": "مدل خواندنی دفتر کل در دسترس نیست. کمی بعد دوباره تلاش کنید.",
|
||||
"footer_description": "دفتر کل دارانو · تاریخچه مالی تغییرناپذیر",
|
||||
"timezone_notice": "همه زمانها بر پایه منطقه زمانی تهران نمایش داده میشوند",
|
||||
"title_overview": "نمای کلی شبکه",
|
||||
"title_transactions": "کاوشگر تراکنش",
|
||||
"title_assets": "کاوشگر دارایی",
|
||||
"title_accounts": "کاوشگر حساب",
|
||||
"title_holder": "حساب دارنده",
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocaleURLPreservesExplorerQuery(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/accounts?class=USER_AVAILABLE&asset_id=9", nil)
|
||||
value := localeURL(request, LocalePersian)
|
||||
for _, expected := range []string{"/accounts?", "class=USER_AVAILABLE", "asset_id=9", "lang=fa"} {
|
||||
if !strings.Contains(value, expected) {
|
||||
t.Fatalf("locale URL %q did not preserve %q", value, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslationFallsBackToEnglish(t *testing.T) {
|
||||
if got := tr(Locale("invalid"), "overview"); got != "Overview" {
|
||||
t.Fatalf("unexpected fallback translation: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
ReadHeaderTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, cfg ServerConfig, handler http.Handler) error {
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
|
||||
}
|
||||
serveErr := make(chan error, 1)
|
||||
go func() { serveErr <- server.ListenAndServe() }()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("serve explorer: %w", err)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
_ = server.Close()
|
||||
return fmt.Errorf("shut down explorer: %w", err)
|
||||
}
|
||||
err := <-serveErr
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serve explorer: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"gl/application/explorer"
|
||||
"gl/domain/ledger"
|
||||
)
|
||||
|
||||
templ Page(title string, active string, locale Locale, englishURL string, persianURL string, content templ.Component) {
|
||||
<!doctype html>
|
||||
<html lang={ string(locale) } dir={ locale.Direction() }>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="description" content={ tr(locale, "general_ledger_explorer") }/>
|
||||
<meta name="htmx-config" content='{"historyRestoreAsHxRequest":false,"selfRequestsOnly":true}'/>
|
||||
<title>{ title } · { tr(locale, "site_name") }</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any"/>
|
||||
<script src="/theme.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js" integrity="sha384-H5SrcfygHmAuTDZphMHqBJLc3FhssKjG7w/CeCpFReSfwBWDTKpkzPP8c+cLsK+V" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
:root { color-scheme:light; --ink:#1f2a2c; --on-ink:#ffffff; --muted:rgba(31,42,44,.68); --paper:#F5F5F5; --surface:#DFF1F1; --card:#ffffff; --glass:rgba(255,255,255,.78); --hover:rgba(223,241,241,.72); --line:rgba(31,42,44,.15); --teal:#BBD5DA; --accent:#FF0000; --positive:#497f82; --teal-glow:rgba(187,213,218,.48); --accent-glow:rgba(255,0,0,.04); --shadow:0 18px 50px rgba(31,42,44,.10); }
|
||||
html[data-theme="dark"] { color-scheme:dark; --ink:#F5F5F5; --on-ink:#1f2a2c; --muted:rgba(223,241,241,.68); --paper:#101719; --surface:#253639; --card:#182326; --glass:rgba(24,35,38,.86); --hover:rgba(37,54,57,.82); --line:rgba(187,213,218,.18); --teal:#BBD5DA; --accent:#FF0000; --positive:#9bc8cc; --teal-glow:rgba(187,213,218,.12); --accent-glow:rgba(255,0,0,.08); --shadow:0 18px 50px rgba(0,0,0,.28); }
|
||||
* { box-sizing:border-box; }
|
||||
html { background:var(--paper); color:var(--ink); font-family:Inter,Tahoma,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
||||
body { margin:0; min-height:100vh; background:radial-gradient(circle at 78% 0%,var(--teal-glow),transparent 30rem),radial-gradient(circle at 5% 30%,var(--accent-glow),transparent 24rem),var(--paper); }
|
||||
a { color:inherit; }
|
||||
.shell { width:min(1180px,calc(100% - 40px)); margin:0 auto; }
|
||||
.topbar { min-height:76px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||
.brand { display:flex; align-items:center; gap:12px; text-decoration:none; font-weight:850; letter-spacing:-.03em; }
|
||||
.mark { width:35px; height:35px; display:block; flex:none; object-fit:contain; }
|
||||
.brand small { display:block; color:var(--muted); font-size:9px; letter-spacing:.2em; margin-top:2px; }
|
||||
.nav { display:flex; gap:6px; align-items:center; }
|
||||
.nav a { padding:10px 13px; border-radius:999px; text-decoration:none; color:var(--muted); font-size:13px; font-weight:700; }
|
||||
.nav a:hover,.nav a.active { color:var(--ink); background:var(--surface); }
|
||||
.live { display:flex; align-items:center; gap:8px; margin-inline-start:10px; padding:8px 11px; border:1px solid var(--line); border-radius:999px; font:700 11px/1 ui-monospace,SFMono-Regular,monospace; background:var(--glass); }
|
||||
.dot { width:7px; height:7px; background:var(--teal); border-radius:50%; box-shadow:0 0 0 4px rgba(187,213,218,.35); }
|
||||
.theme-toggle { width:34px; height:34px; flex:none; margin-inline-start:8px; border:1px solid var(--line); border-radius:50%; background:var(--glass); color:var(--ink); cursor:pointer; font-size:17px; line-height:1; }
|
||||
.theme-toggle:hover { background:var(--surface); }
|
||||
.language-switch { display:flex; padding:3px; margin-inline-start:8px; border:1px solid var(--line); border-radius:999px; background:var(--glass); direction:ltr; }
|
||||
.language-switch a { padding:6px 8px; font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.language-switch a.active { background:var(--ink); color:var(--on-ink); }
|
||||
main { padding:62px 0 90px; }
|
||||
.eyebrow { display:flex; align-items:center; gap:9px; color:var(--teal); text-transform:uppercase; letter-spacing:.14em; font:800 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.eyebrow:before { content:""; width:26px; height:2px; background:var(--accent); }
|
||||
h1 { max-width:830px; margin:20px 0 14px; font-size:clamp(42px,7vw,82px); line-height:.94; letter-spacing:-.065em; font-weight:850; }
|
||||
.lede { max-width:660px; margin:0; color:var(--muted); font-size:17px; line-height:1.65; }
|
||||
.search { display:flex; gap:10px; margin:34px 0 46px; padding:9px; border:1px solid var(--line); border-radius:16px; background:var(--glass); box-shadow:var(--shadow); }
|
||||
.search input { flex:1; min-width:0; border:0; outline:0; background:transparent; padding:9px 12px; color:var(--ink); font:500 14px/1.4 ui-monospace,SFMono-Regular,monospace; }
|
||||
.search button,.button { border:0; border-radius:10px; background:var(--accent); color:white; padding:13px 18px; font-weight:800; cursor:pointer; }
|
||||
.search button:hover,.button:hover { background:var(--ink); color:var(--on-ink); }
|
||||
.stats { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:46px; }
|
||||
.stat { min-height:136px; padding:22px; border:1px solid var(--line); border-radius:16px; background:var(--glass); }
|
||||
.stat.accent { background:var(--accent); border-color:var(--accent); color:white; }
|
||||
.stat.accent .stat-label { color:rgba(255,255,255,.78); }
|
||||
.stat-label { color:var(--muted); text-transform:uppercase; letter-spacing:.13em; font:750 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.stat strong { display:block; margin-top:21px; font-size:32px; line-height:1; letter-spacing:-.05em; }
|
||||
.stat time { display:block; margin-top:18px; font:650 12px/1.5 ui-monospace,SFMono-Regular,monospace; }
|
||||
.section-head { display:flex; align-items:end; justify-content:space-between; margin:0 0 14px; }
|
||||
.section-head h2 { margin:0; font-size:22px; letter-spacing:-.035em; }
|
||||
.section-head span { color:var(--muted); font:600 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.dashboard-section { margin-top:46px; }
|
||||
.panel { overflow:hidden; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 8px 25px rgba(28,35,29,.04); }
|
||||
table { width:100%; border-collapse:collapse; }
|
||||
th { padding:14px 18px; background:var(--surface); color:var(--ink); text-align:start; text-transform:uppercase; letter-spacing:.1em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
td { padding:17px 18px; border-top:1px solid var(--line); font-size:13px; vertical-align:middle; }
|
||||
tr:first-child td { border-top:0; }
|
||||
tr:hover td { background:var(--hover); }
|
||||
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; direction:ltr; unicode-bidi:isolate; }
|
||||
.hash-link { font-weight:750; text-decoration:none; border-bottom:1px solid var(--teal); }
|
||||
.pill { display:inline-flex; padding:6px 9px; border-radius:999px; background:var(--surface); font:750 10px/1 ui-monospace,SFMono-Regular,monospace; text-transform:uppercase; }
|
||||
.holder-id { max-width:330px; overflow-wrap:anywhere; font-weight:750; }
|
||||
.holder-id small { display:block; margin-top:5px; color:var(--muted); font:650 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.empty { padding:45px 24px; text-align:center; color:var(--muted); }
|
||||
.notice + .transaction-list { margin-top:46px; }
|
||||
.pagination { display:flex; align-items:center; justify-content:center; gap:8px; margin-top:18px; direction:ltr; }
|
||||
.pagination a,.pagination span { min-width:42px; padding:10px 13px; border:1px solid var(--line); border-radius:10px; text-align:center; text-decoration:none; font:750 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.pagination a { background:var(--card); }
|
||||
.pagination a:hover { background:var(--surface); }
|
||||
.pagination .current { background:var(--ink); color:var(--on-ink); border-color:var(--ink); }
|
||||
.pagination .disabled { color:var(--muted); opacity:.45; }
|
||||
.transaction-filters { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto auto; gap:10px; margin-bottom:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--card); }
|
||||
.transaction-filters .field input { height:40px; }
|
||||
.transaction-filters .button { align-self:end; height:40px; padding-block:0; }
|
||||
.transaction-filters .clear-filter { display:flex; align-items:center; align-self:end; height:40px; padding:0 12px; color:var(--muted); font-weight:750; text-decoration:none; }
|
||||
.page-title h1 { font-size:clamp(42px,6vw,68px); }
|
||||
.breadcrumb { margin-bottom:25px; color:var(--muted); font:650 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.breadcrumb a { text-decoration:none; }
|
||||
.result-stack { display:grid; gap:18px; margin-top:34px; }
|
||||
.journal { border:1px solid var(--line); border-radius:18px; background:var(--card); overflow:hidden; box-shadow:var(--shadow); }
|
||||
.journal-head { padding:22px; display:flex; gap:20px; align-items:flex-start; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||
.journal-head h2 { margin:8px 0 0; max-width:770px; overflow-wrap:anywhere; font:750 14px/1.5 ui-monospace,SFMono-Regular,monospace; }
|
||||
.status { flex:none; padding:7px 10px; border-radius:999px; background:var(--teal); color:#1f2a2c; font:800 10px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.detail-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:1px; background:var(--line); border-bottom:1px solid var(--line); }
|
||||
.detail { min-width:0; padding:18px 22px; background:var(--card); }
|
||||
.detail label { display:block; margin-bottom:8px; color:var(--muted); text-transform:uppercase; letter-spacing:.1em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.detail div { overflow-wrap:anywhere; font-size:13px; }
|
||||
.entries { padding:18px 22px 22px; }
|
||||
.entries h3 { margin:0 0 12px; font-size:13px; }
|
||||
.entry { display:grid; grid-template-columns:46px minmax(0,1fr) 170px; gap:15px; align-items:center; padding:13px 0; border-top:1px solid var(--line); }
|
||||
.entry:first-of-type { border-top:0; }
|
||||
.entry-number { color:var(--muted); font:650 11px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.entry-account a { font-weight:700; text-decoration:none; }
|
||||
.entry-account small { display:block; color:var(--muted); margin-top:4px; }
|
||||
.amount { text-align:end; direction:ltr; font:800 13px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.amount.positive { color:var(--positive); }
|
||||
.amount.negative { color:var(--accent); }
|
||||
.notice { margin-top:28px; padding:20px; border:1px solid rgba(255,0,0,.32); border-radius:14px; background:rgba(255,0,0,.06); color:var(--ink); }
|
||||
.notice strong { display:block; margin-bottom:5px; }
|
||||
.account-form { display:grid; grid-template-columns:1.25fr 1fr 1.2fr .65fr auto; gap:10px; margin:34px 0; padding:14px; border:1px solid var(--line); border-radius:16px; background:var(--card); box-shadow:var(--shadow); }
|
||||
.field label { display:block; margin:0 0 7px 3px; color:var(--muted); text-transform:uppercase; letter-spacing:.09em; font:750 9px/1 ui-monospace,SFMono-Regular,monospace; }
|
||||
.field input,.field select { width:100%; height:43px; border:1px solid var(--line); border-radius:9px; outline:0; padding:0 11px; background:var(--card); color:var(--ink); }
|
||||
.field input:focus,.field select:focus { border-color:var(--teal); box-shadow:0 0 0 3px rgba(187,213,218,.42); }
|
||||
.account-form button { align-self:end; height:43px; }
|
||||
.account-card { display:grid; grid-template-columns:1.35fr .65fr; gap:1px; background:var(--line); border:1px solid var(--line); border-radius:18px; overflow:hidden; margin-bottom:30px; }
|
||||
.account-card > div { padding:25px; background:var(--card); }
|
||||
.account-card h2 { margin:8px 0 0; font:800 16px/1.5 ui-monospace,SFMono-Regular,monospace; overflow-wrap:anywhere; }
|
||||
.balance { font-size:34px; line-height:1; font-weight:850; letter-spacing:-.04em; overflow-wrap:anywhere; }
|
||||
.htmx-request .search-label { display:none; }
|
||||
.htmx-request button:after { content:" …"; }
|
||||
footer { padding:24px 0 38px; border-top:1px solid var(--line); color:var(--muted); display:flex; justify-content:space-between; font-size:11px; }
|
||||
html[dir="rtl"] body { letter-spacing:0; }
|
||||
html[dir="rtl"] h1 { letter-spacing:-.035em; }
|
||||
html[dir="rtl"] .search input { font-family:Tahoma,ui-sans-serif,system-ui,sans-serif; }
|
||||
@media (max-width:850px) { .stats{grid-template-columns:repeat(2,1fr)} .account-form{grid-template-columns:1fr 1fr} .account-form button{grid-column:1/-1} .transaction-filters{grid-template-columns:1fr 1fr} .detail-grid{grid-template-columns:1fr 1fr} .nav>a{display:none} }
|
||||
@media (max-width:620px) { .shell{width:min(100% - 24px,1180px)} .topbar{min-height:66px} .live{display:none} main{padding-top:42px} .stats{grid-template-columns:1fr 1fr} .stat{min-height:115px;padding:17px}.stat strong{font-size:25px} .search{display:grid}.search button{width:100%} .transaction-filters{grid-template-columns:1fr}.panel{overflow-x:auto} table{min-width:720px} .journal-head{display:block}.status{display:inline-flex;margin-top:14px}.detail-grid{grid-template-columns:1fr}.entry{grid-template-columns:32px minmax(0,1fr)}.amount{grid-column:2;text-align:start}.account-form{grid-template-columns:1fr}.account-form button{grid-column:auto}.account-card{grid-template-columns:1fr} footer{display:block;line-height:1.8} }
|
||||
</style>
|
||||
</head>
|
||||
<body hx-boost="true" hx-target="#explorer-content" hx-select="#explorer-content" hx-swap="outerHTML show:top" hx-push-url="true">
|
||||
<header class="shell topbar">
|
||||
<a class="brand" href="/" hx-target="body" hx-select="body">
|
||||
<img class="mark" src="/favicon.ico" alt="" width="35" height="35" aria-hidden="true"/>
|
||||
<span>{ tr(locale, "site_name") } <small>{ tr(locale, "site_subtitle") }</small></span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="Main navigation" hx-target="body" hx-select="body">
|
||||
if active == "dashboard" {
|
||||
<a class="active" href="/">{ tr(locale, "overview") }</a>
|
||||
} else {
|
||||
<a href="/">{ tr(locale, "overview") }</a>
|
||||
}
|
||||
if active == "transactions" {
|
||||
<a class="active" href="/transactions">{ tr(locale, "transactions") }</a>
|
||||
} else {
|
||||
<a href="/transactions">{ tr(locale, "transactions") }</a>
|
||||
}
|
||||
if active == "assets" {
|
||||
<a class="active" href="/assets">{ tr(locale, "assets") }</a>
|
||||
} else {
|
||||
<a href="/assets">{ tr(locale, "assets") }</a>
|
||||
}
|
||||
if active == "accounts" {
|
||||
<a class="active" href="/accounts">{ tr(locale, "accounts") }</a>
|
||||
} else {
|
||||
<a href="/accounts">{ tr(locale, "accounts") }</a>
|
||||
}
|
||||
<span class="live"><span class="dot"></span> { tr(locale, "read_only") }</span>
|
||||
<button class="theme-toggle" type="button" data-theme-toggle data-light-label={ tr(locale, "light_mode") } data-dark-label={ tr(locale, "dark_mode") } aria-label={ tr(locale, "dark_mode") } aria-pressed="false"><span data-theme-icon aria-hidden="true">☾</span></button>
|
||||
<span class="language-switch" aria-label="Language">
|
||||
if locale == LocaleEnglish {
|
||||
<a class="active" href={ templ.URL(englishURL) } hx-boost="false" lang="en">EN</a>
|
||||
} else {
|
||||
<a href={ templ.URL(englishURL) } hx-boost="false" lang="en">EN</a>
|
||||
}
|
||||
if locale == LocalePersian {
|
||||
<a class="active" href={ templ.URL(persianURL) } hx-boost="false" lang="fa">فا</a>
|
||||
} else {
|
||||
<a href={ templ.URL(persianURL) } hx-boost="false" lang="fa">فا</a>
|
||||
}
|
||||
</span>
|
||||
</nav>
|
||||
</header>
|
||||
@content
|
||||
<footer class="shell">
|
||||
<span>{ tr(locale, "footer_description") }</span>
|
||||
<span>{ tr(locale, "timezone_notice") }</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ TransactionSearch(value string, locale Locale) {
|
||||
<form class="search" action="/transactions" method="get" hx-get="/transactions" hx-target="#explorer-content" hx-push-url="true">
|
||||
<input name="q" value={ value } placeholder={ tr(locale, "transaction_placeholder") } aria-label={ tr(locale, "transaction_hash") } autocomplete="off" required/>
|
||||
<button type="submit"><span class="search-label">{ tr(locale, "explore_transaction") }</span></button>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ DashboardContent(data explorer.Dashboard, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<section>
|
||||
<div class="eyebrow">{ tr(locale, "general_ledger_explorer") }</div>
|
||||
<h1>{ tr(locale, "hero_line_one") }<br/>{ tr(locale, "hero_line_two") }</h1>
|
||||
<p class="lede">{ tr(locale, "hero_description") }</p>
|
||||
@TransactionSearch("", locale)
|
||||
</section>
|
||||
<section class="stats" aria-label={ tr(locale, "general_ledger_explorer") }>
|
||||
<div class="stat accent"><span class="stat-label">{ tr(locale, "committed_journals") }</span><strong>{ count(data.Stats.JournalCount) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "ledger_entries") }</span><strong>{ count(data.Stats.EntryCount) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "tracked_accounts") }</span><strong>{ count(data.Stats.AccountCount) }</strong></div>
|
||||
<div class="stat"><span class="stat-label">{ tr(locale, "last_recorded") }</span><time>{ formatTime(data.Stats.LastRecordedAt) }</time></div>
|
||||
</section>
|
||||
<section>
|
||||
<div class="section-head"><h2>{ tr(locale, "latest_activity") }</h2><span>{ tr(locale, "newest_first") }</span></div>
|
||||
@JournalTable(data.Journals, locale)
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
|
||||
templ AssetsContent(data explorer.Assets, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "assets") }</div>
|
||||
<section class="page-title">
|
||||
<div class="eyebrow">{ tr(locale, "assets_explorer") }</div>
|
||||
<h1>{ tr(locale, "track_asset_ownership") }</h1>
|
||||
<p class="lede">{ tr(locale, "assets_description") }</p>
|
||||
</section>
|
||||
<section class="dashboard-section">
|
||||
<div class="section-head"><h2>{ tr(locale, "top_asset_holders") }</h2><span>{ tr(locale, "holder_balance_scope") }</span></div>
|
||||
@HolderTable(data.TopHolders, locale)
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
|
||||
templ HolderTable(holders []explorer.Holder, locale Locale) {
|
||||
<div class="panel">
|
||||
if len(holders) == 0 {
|
||||
<div class="empty">{ tr(locale, "no_asset_holders") }</div>
|
||||
} else {
|
||||
<table>
|
||||
<thead><tr><th>{ tr(locale, "asset") }</th><th>{ tr(locale, "rank") }</th><th>{ tr(locale, "holder") }</th><th>{ tr(locale, "balance") }</th></tr></thead>
|
||||
<tbody>
|
||||
for _, holder := range holders {
|
||||
<tr>
|
||||
<td><span class="pill">{ tr(locale, "asset") } { strconv.FormatInt(holder.AssetID, 10) }</span></td>
|
||||
<td class="mono">#{ strconv.FormatInt(holder.Rank, 10) }</td>
|
||||
<td class="holder-id mono"><a class="hash-link" href={ templ.URL(holderURL(holder)) }>{ holder.OwnerID }</a><small>{ holder.OwnerType }</small></td>
|
||||
<td class="amount positive">{ holder.Balance.String() }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ HolderContent(data HolderPageData, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / <a href="/assets">{ tr(locale, "assets") }</a> / { tr(locale, "holder") }</div>
|
||||
<section class="page-title">
|
||||
<div class="eyebrow">{ tr(locale, "holder_account") }</div>
|
||||
<h1>{ tr(locale, "inspect_holder") }</h1>
|
||||
<p class="lede">{ tr(locale, "holder_description") }</p>
|
||||
</section>
|
||||
if data.Error != "" {
|
||||
<div class="notice"><strong>{ tr(locale, "account_unavailable") }</strong>{ data.Error }</div>
|
||||
} else if data.Account != nil {
|
||||
<div class="account-card result-stack">
|
||||
<div><span class="stat-label">{ tr(locale, "holder") }</span><h2 class="mono">{ data.Account.Reference.OwnerID }</h2><p class="mono">{ data.Account.Reference.OwnerType } · { tr(locale, "asset") } { strconv.FormatInt(data.Account.Reference.AssetID, 10) }</p></div>
|
||||
<div><span class="stat-label">{ tr(locale, "combined_balance") }</span><div class="balance">{ data.Account.Balance.String() }</div></div>
|
||||
</div>
|
||||
<section class="dashboard-section">
|
||||
<div class="section-head"><h2>{ tr(locale, "account_activity") }</h2><span>{ tr(locale, "available_and_frozen") }</span></div>
|
||||
@JournalTable(data.Account.Journals, locale)
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
|
||||
templ JournalTable(journals []ledger.Journal, locale Locale) {
|
||||
<div class="panel">
|
||||
if len(journals) == 0 {
|
||||
<div class="empty">{ tr(locale, "no_journals") }</div>
|
||||
} else {
|
||||
<table>
|
||||
<thead><tr><th>{ tr(locale, "transaction") }</th><th>{ tr(locale, "effect") }</th><th>{ tr(locale, "source") }</th><th>{ tr(locale, "entries") }</th><th>{ tr(locale, "recorded") }</th></tr></thead>
|
||||
<tbody>
|
||||
for _, journal := range journals {
|
||||
<tr>
|
||||
<td class="mono">
|
||||
<a class="hash-link" href={ templ.URL(transactionURL(transactionReference(journal))) }>{ transactionName(journal, locale) }</a>
|
||||
</td>
|
||||
<td><span class="pill">{ journal.EffectKind }</span></td>
|
||||
<td>{ journal.SourceService }</td>
|
||||
<td>{ strconv.Itoa(len(journal.Entries)) }</td>
|
||||
<td class="mono">{ formatTime(journal.RecordedAt) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransactionContent(data TransactionPageData, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "transactions") }</div>
|
||||
<section class="page-title">
|
||||
<div class="eyebrow">{ tr(locale, "transaction_explorer") }</div>
|
||||
<h1>{ tr(locale, "trace_settlement") }</h1>
|
||||
<p class="lede">{ tr(locale, "transaction_description") }</p>
|
||||
@TransactionSearch(data.Query, locale)
|
||||
</section>
|
||||
if data.Error != "" {
|
||||
<div class="notice"><strong>{ tr(locale, "transaction_not_found") }</strong>{ data.Error }</div>
|
||||
}
|
||||
if data.Query != "" && data.Error == "" {
|
||||
<div class="result-stack">
|
||||
for _, journal := range data.Journals {
|
||||
@JournalCard(journal, locale)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if data.Listing != nil {
|
||||
<section class="transaction-list">
|
||||
<div class="section-head"><h2>{ tr(locale, "latest_transactions") }</h2><span>{ tr(locale, "page") } { strconv.Itoa(data.Listing.Filter.Page) }</span></div>
|
||||
<form class="transaction-filters" action="/transactions" method="get" hx-get="/transactions" hx-target="#explorer-content" hx-push-url="true">
|
||||
<div class="field"><label for="wallet">{ tr(locale, "user_wallet") }</label><input id="wallet" name="wallet" value={ data.Listing.Filter.Wallet } placeholder={ tr(locale, "user_wallet_placeholder") } autocomplete="off"/></div>
|
||||
<div class="field"><label for="effect">{ tr(locale, "effect_type") }</label><input id="effect" name="effect" value={ data.Listing.Filter.EffectKind } placeholder={ tr(locale, "effect_type_placeholder") } autocomplete="off"/></div>
|
||||
<button class="button" type="submit">{ tr(locale, "apply_filters") }</button>
|
||||
<a class="clear-filter" href="/transactions">{ tr(locale, "clear_filters") }</a>
|
||||
</form>
|
||||
@JournalTable(data.Listing.Journals, locale)
|
||||
@Pagination(*data.Listing, locale)
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
|
||||
templ Pagination(listing explorer.TransactionListing, locale Locale) {
|
||||
<nav class="pagination" aria-label={ tr(locale, "pagination") }>
|
||||
if listing.HasPrevious {
|
||||
<a href={ templ.URL(transactionPageURL(listing.Filter, listing.Filter.Page - 1)) } rel="prev">{ tr(locale, "previous") }</a>
|
||||
} else {
|
||||
<span class="disabled">{ tr(locale, "previous") }</span>
|
||||
}
|
||||
<span class="current" aria-current="page">{ strconv.Itoa(listing.Filter.Page) }</span>
|
||||
if listing.HasNext {
|
||||
<a href={ templ.URL(transactionPageURL(listing.Filter, listing.Filter.Page + 1)) } rel="next">{ tr(locale, "next") }</a>
|
||||
} else {
|
||||
<span class="disabled">{ tr(locale, "next") }</span>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
templ JournalCard(journal ledger.Journal, locale Locale) {
|
||||
<article class="journal">
|
||||
<header class="journal-head">
|
||||
<div>
|
||||
if journal.Blockchain.TransactionHash != "" {
|
||||
<span class="stat-label">{ tr(locale, "transaction_hash") }</span>
|
||||
} else {
|
||||
<span class="stat-label">{ tr(locale, "journal_id") }</span>
|
||||
}
|
||||
<h2 class="mono">{ transactionReference(journal) }</h2>
|
||||
</div>
|
||||
<span class="status">{ tr(locale, "committed") }</span>
|
||||
</header>
|
||||
<div class="detail-grid">
|
||||
<div class="detail"><label>{ tr(locale, "effect") }</label><div><span class="pill">{ journal.EffectKind }</span></div></div>
|
||||
<div class="detail"><label>{ tr(locale, "journal_id") }</label><div class="mono">{ journal.ID }</div></div>
|
||||
<div class="detail"><label>{ tr(locale, "recorded") }</label><div>{ formatTime(journal.RecordedAt) }</div></div>
|
||||
<div class="detail"><label>{ tr(locale, "source") }</label><div>{ journal.SourceService } · { journal.SourceTransactionID }</div></div>
|
||||
<div class="detail"><label>{ tr(locale, "network") }</label><div>{ journal.Blockchain.Network }</div></div>
|
||||
<div class="detail"><label>{ tr(locale, "ledger_sequence") }</label><div class="mono">{ journal.Blockchain.LedgerSequence }</div></div>
|
||||
</div>
|
||||
<div class="entries">
|
||||
<h3>{ tr(locale, "balanced_entries") }</h3>
|
||||
for _, entry := range journal.Entries {
|
||||
<div class="entry">
|
||||
<span class="entry-number">#{ strconv.FormatUint(uint64(entry.LineNumber), 10) }</span>
|
||||
<div class="entry-account"><a href={ templ.URL(accountURL(entry.Account)) }>{ accountName(entry.Account, locale) }</a><small>{ tr(locale, "asset") } { strconv.FormatInt(entry.Account.AssetID, 10) } · { entry.Account.OwnerType }</small></div>
|
||||
if len(entry.Amount.String()) > 0 && entry.Amount.String()[0] == '-' {
|
||||
<div class="amount negative">{ entry.Amount.String() }</div>
|
||||
} else {
|
||||
<div class="amount positive">+{ entry.Amount.String() }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
|
||||
templ AccountContent(data AccountPageData, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<div class="breadcrumb"><a href="/">{ tr(locale, "network") }</a> / { tr(locale, "accounts") }</div>
|
||||
<section class="page-title">
|
||||
<div class="eyebrow">{ tr(locale, "account_explorer") }</div>
|
||||
<h1>{ tr(locale, "rebuild_account") }</h1>
|
||||
<p class="lede">{ tr(locale, "account_description") }</p>
|
||||
</section>
|
||||
<form class="account-form" action="/accounts" method="get" hx-get="/accounts" hx-target="#explorer-content" hx-push-url="true">
|
||||
<div class="field"><label for="class">{ tr(locale, "account_class") }</label><select id="class" name="class" required>
|
||||
for _, class := range accountClasses() {
|
||||
if string(class) == data.Input.Class {
|
||||
<option value={ string(class) } selected>{ string(class) }</option>
|
||||
} else {
|
||||
<option value={ string(class) }>{ string(class) }</option>
|
||||
}
|
||||
}
|
||||
</select></div>
|
||||
<div class="field"><label for="owner_type">{ tr(locale, "owner_type") }</label><input id="owner_type" name="owner_type" value={ data.Input.OwnerType } placeholder="user"/></div>
|
||||
<div class="field"><label for="owner_id">{ tr(locale, "owner_id") }</label><input id="owner_id" name="owner_id" value={ data.Input.OwnerID } placeholder={ tr(locale, "stable_owner_id") }/></div>
|
||||
<div class="field"><label for="asset_id">{ tr(locale, "asset_id") }</label><input id="asset_id" name="asset_id" value={ data.Input.AssetID } inputmode="numeric" placeholder="1" required/></div>
|
||||
<button class="button" type="submit">{ tr(locale, "explore") }</button>
|
||||
</form>
|
||||
if data.Error != "" {
|
||||
<div class="notice"><strong>{ tr(locale, "account_unavailable") }</strong>{ data.Error }</div>
|
||||
} else if data.Account != nil {
|
||||
<div class="account-card">
|
||||
<div><span class="stat-label">{ tr(locale, "ledger_identity") }</span><h2>{ accountName(data.Account.Reference, locale) }</h2><p class="mono">{ data.Account.Reference.OwnerType } · { tr(locale, "asset") } { strconv.FormatInt(data.Account.Reference.AssetID, 10) }</p></div>
|
||||
<div><span class="stat-label">{ tr(locale, "current_balance") }</span><div class="balance">{ data.Account.Balance.String() }</div></div>
|
||||
</div>
|
||||
<div class="section-head"><h2>{ tr(locale, "account_activity") }</h2><span>{ strconv.Itoa(len(data.Account.Journals)) } { tr(locale, "journals") }</span></div>
|
||||
@JournalTable(data.Account.Journals, locale)
|
||||
} else {
|
||||
<div class="panel empty">{ tr(locale, "choose_account") }</div>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
|
||||
templ FailureContent(title string, message string, locale Locale) {
|
||||
<main id="explorer-content" class="shell">
|
||||
<section class="page-title"><div class="eyebrow">{ tr(locale, "explorer_error") }</div><h1>{ title }</h1><p class="lede">{ message }</p></section>
|
||||
</main>
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -u
|
||||
|
||||
./tmp/gl -conf ./gl.cfg.toml &
|
||||
gl_pid=$!
|
||||
|
||||
./tmp/dashboard -conf ./dashboard.cfg.toml &
|
||||
dashboard_pid=$!
|
||||
|
||||
shutdown() {
|
||||
trap - EXIT
|
||||
kill -TERM "$gl_pid" "$dashboard_pid" 2>/dev/null || true
|
||||
wait "$gl_pid" 2>/dev/null || true
|
||||
wait "$dashboard_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
trap 'exit 0' INT TERM
|
||||
trap shutdown EXIT
|
||||
|
||||
wait -n "$gl_pid" "$dashboard_pid"
|
||||
status=$?
|
||||
exit "$status"
|
||||
Reference in New Issue
Block a user