Compare commits

...

10 Commits

75 changed files with 13024 additions and 1 deletions
+29
View File
@@ -0,0 +1,29 @@
#:schema https://json.schemastore.org/any.json
root = "."
tmp_dir = "tmp"
[build]
cmd = "go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate && go build -o ./tmp/gl ./cmd/gl && go build -o ./tmp/dashboard ./cmd/dashboard"
entrypoint = ["bash", "./scripts/air-run.sh"]
include_ext = ["go", "templ", "toml"]
exclude_dir = ["tmp", "vendor", ".git"]
exclude_regex = ["_test\\.go$", "_templ\\.go$"]
exclude_unchanged = true
delay = 300
stop_on_error = true
send_interrupt = true
kill_delay = 1000
[log]
time = true
[misc]
clean_on_exit = true
[screen]
clear_on_rebuild = false
keep_scroll = true
[proxy]
enabled = false
+62
View File
@@ -0,0 +1,62 @@
name: Deploy over SSH
description: SSH into server and update compose services
inputs:
ssh_key:
description: Private SSH key content
required: true
user:
description: SSH username
required: true
host:
description: SSH hostname or IP
required: true
compose_file:
description: Path to compose file on remote server
required: true
compose_project_name:
description: Compose project name on remote server
required: true
services:
description: Space-separated compose services
required: true
registry:
description: OCI registry hostname
required: false
default: oci.reg.darano.ir
registry_username:
description: OCI registry username
required: false
default: admin
registry_password:
description: OCI registry password
required: true
runs:
using: composite
steps:
- name: Deploy over SSH
shell: bash
env:
SSH_KEY: ${{ inputs.ssh_key }}
REMOTE_USER: ${{ inputs.user }}
REMOTE_HOST: ${{ inputs.host }}
COMPOSE_FILE: ${{ inputs.compose_file }}
COMPOSE_PROJECT_NAME: ${{ inputs.compose_project_name }}
SERVICES: ${{ inputs.services }}
REGISTRY: ${{ inputs.registry }}
REGISTRY_USERNAME: ${{ inputs.registry_username }}
REGISTRY_PASSWORD: ${{ inputs.registry_password }}
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "$SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
printf '%s' "$REGISTRY_PASSWORD" | ssh -i ~/.ssh/deploy_key \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
"$REMOTE_USER@$REMOTE_HOST" \
"docker login '$REGISTRY' -u '$REGISTRY_USERNAME' --password-stdin && \
export COMPOSE_PROJECT_NAME='$COMPOSE_PROJECT_NAME' && \
docker compose -f '$COMPOSE_FILE' up $SERVICES -d --pull=always; \
rc=\$?; \
docker logout '$REGISTRY'; \
exit \$rc"
@@ -0,0 +1,31 @@
name: Docker Build
description: Build image and load it into the local Docker daemon
inputs:
image:
description: Full image name without a tag
required: true
branch:
description: Branch tag
required: true
commit_sha:
description: Commit SHA tag
required: true
runs:
using: composite
steps:
- name: Build
shell: bash
env:
IMAGE: ${{ inputs.image }}
BRANCH: ${{ inputs.branch }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
set -euo pipefail
docker buildx build \
-f build/Dockerfile \
--build-arg "GO_PROXY=https://go.reg.darano.ir" \
--load \
-t "$IMAGE:$BRANCH" \
-t "$IMAGE:$COMMIT_SHA" \
.
+25
View File
@@ -0,0 +1,25 @@
name: Docker Login
description: Login to OCI registry
inputs:
registry:
description: Registry hostname
required: false
default: oci.reg.darano.ir
username:
description: Registry username
required: false
default: admin
password:
description: Registry password
required: true
runs:
using: composite
steps:
- name: Login to registry
shell: bash
env:
REG_USER: ${{ inputs.username }}
REG_PASS: ${{ inputs.password }}
REGISTRY: ${{ inputs.registry }}
run: |
echo "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin
+38
View File
@@ -0,0 +1,38 @@
name: Notify Telegram
description: Send CI/CD status message via Bale bot
inputs:
bot_token:
description: Bale bot token
required: true
chat_id:
description: Target chat ID
required: true
status:
description: Job status
required: true
repository:
description: Repository name
required: true
sha:
description: Commit SHA
required: true
runs:
using: composite
steps:
- name: Send notification
shell: bash
env:
BOT_TOKEN: ${{ inputs.bot_token }}
CHAT_ID: ${{ inputs.chat_id }}
STATUS: ${{ inputs.status }}
REPO: ${{ inputs.repository }}
SHA: ${{ inputs.sha }}
run: |
if [ "$STATUS" = "success" ]; then
MSG="✅ CI/CD passed: ${REPO}@${SHA}"
else
MSG="❌ CI/CD failed: ${REPO}@${SHA}"
fi
curl -s -X POST "https://tapi.bale.ai/bot${BOT_TOKEN}/sendMessage" \
-d chat_id="${CHAT_ID}" \
-d text="$MSG"
+49
View File
@@ -0,0 +1,49 @@
---
name: CI/CD Dev
on:
push:
branches:
- dev
jobs:
dev:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@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 }}
+49
View File
@@ -0,0 +1,49 @@
---
name: CI/CD Stage
on:
push:
branches:
- stage
jobs:
stage:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@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 }}
+49
View File
@@ -0,0 +1,49 @@
---
name: CI/CD
on:
push:
branches:
- main
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout Git
uses: https://git.darano.ir/actions/checkout@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
View File
@@ -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*
+162
View File
@@ -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.
+20
View File
@@ -0,0 +1,20 @@
.PHONY: build generate gl-load-test gl-redistribution-load-test load-test test
generate:
go run github.com/a-h/templ/cmd/templ@v0.3.1020 generate
buf generate ../proto --template ./buf.gen.yaml --path ../proto/base/v1 --path ../proto/ledger/v1
build: generate
go build ./...
test:
go test ./...
load-test:
go run ./cmd/dashboard-loadtest $(LOADTEST_ARGS)
gl-load-test:
go run ./cmd/gl-loadtest $(GL_LOADTEST_ARGS)
gl-redistribution-load-test:
go run ./cmd/gl-redistribution-loadtest $(GL_REDISTRIBUTION_LOADTEST_ARGS)
+58
View File
@@ -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.
+255
View File
@@ -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)
}
+131
View File
@@ -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)
}
}
+29
View File
@@ -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
}
+30
View File
@@ -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)
}
})
}
}
+384
View File
@@ -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
}
+223
View File
@@ -0,0 +1,223 @@
package ledger
import (
"context"
"errors"
"strings"
"testing"
"time"
domain "gl/domain/ledger"
)
type repositoryStub struct {
journals map[string]domain.Journal
byKey map[string]string
events map[string]domain.TransactionEvent
appendCalls int
list []domain.Journal
balance domain.Amount
lastAttempt domain.Journal
}
func newRepositoryStub() *repositoryStub {
return &repositoryStub{
journals: make(map[string]domain.Journal),
byKey: make(map[string]string),
events: make(map[string]domain.TransactionEvent),
}
}
func (r *repositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) {
r.appendCalls++
r.lastAttempt = journal
if existingID, ok := r.byKey[journal.IdempotencyKey]; ok {
return domain.AppendResult{JournalID: existingID, AlreadyExists: true}, nil
}
journal.RecordedAt = time.Unix(2, 0).UTC()
r.journals[journal.ID] = journal
r.byKey[journal.IdempotencyKey] = journal.ID
return domain.AppendResult{JournalID: journal.ID}, nil
}
func (r *repositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) {
if existing, ok := r.events[event.IdempotencyKey]; ok {
return existing, true, nil
}
event.RecordedAt = time.Unix(2, 0).UTC()
r.events[event.IdempotencyKey] = event
return event, false, nil
}
func (r *repositoryStub) GetByID(_ context.Context, id string) (domain.Journal, error) {
journal, ok := r.journals[id]
if !ok {
return domain.Journal{}, errors.New("not found")
}
return journal, nil
}
func (r *repositoryStub) GetByIdempotencyKey(_ context.Context, key string) (domain.Journal, error) {
id, ok := r.byKey[key]
if !ok {
return domain.Journal{}, errors.New("not found")
}
return r.journals[id], nil
}
func (r *repositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) {
return append([]domain.Journal(nil), r.list...), nil
}
func (r *repositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) {
return r.balance, nil
}
func TestAppendJournalIsBalancedCanonicalAndIdempotent(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := validAppendCommand()
first, err := service.AppendJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
command.Entries[0], command.Entries[1] = command.Entries[1], command.Entries[0]
second, err := service.AppendJournal(context.Background(), command)
if err != nil {
t.Fatal(err)
}
if first.AlreadyExisted || !second.AlreadyExisted {
t.Fatalf("unexpected idempotency results: first=%+v second=%+v", first, second)
}
if first.Journal.PayloadHash == "" || first.Journal.PayloadHash != second.Journal.PayloadHash {
t.Fatalf("unexpected payload hashes: %q %q", first.Journal.PayloadHash, second.Journal.PayloadHash)
}
if repository.lastAttempt.PayloadHash != first.Journal.PayloadHash {
t.Fatal("entry order changed the canonical payload hash")
}
if got := first.Journal.Entries[0].Amount.String(); got != "-10.25" {
t.Fatalf("amount was not canonical: %s", got)
}
}
func TestAppendJournalRejectsInvalidAmountBeforeRepository(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := validAppendCommand()
command.Entries[0].Amount = "NaN"
_, err := service.AppendJournal(context.Background(), command)
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected invalid argument, got %v", err)
}
if repository.appendCalls != 0 {
t.Fatal("invalid journal reached repository")
}
}
func TestAppendJournalRequiresExactFullReversal(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("22222222-2222-4222-8222-222222222222"))
originalCommand := validAppendCommand()
original, err := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111")).AppendJournal(context.Background(), originalCommand)
if err != nil {
t.Fatal(err)
}
reversal := validAppendCommand()
reversal.IdempotencyKey = "wallet:1:internal-transfer-reversal:v1"
reversal.EffectKind = "internal-transfer-reversal"
reversal.ReversalOfJournalID = original.Journal.ID
reversal.Entries[0].Amount = "10.25"
reversal.Entries[1].Amount = "-10.25"
if _, err := service.AppendJournal(context.Background(), reversal); err != nil {
t.Fatalf("valid reversal failed: %v", err)
}
reversal.IdempotencyKey = "wallet:1:bad-reversal:v1"
reversal.Entries[0].Account.OwnerID = "different-user"
if _, err := service.AppendJournal(context.Background(), reversal); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("expected invalid reversal, got %v", err)
}
}
func TestAppendEventIsIdempotent(t *testing.T) {
repository := newRepositoryStub()
service := NewService(repository, fixedID("11111111-1111-4111-8111-111111111111"))
command := AppendEventCommand{
SourceService: "wallet",
IdempotencyKey: "wallet:1:status:v1",
SourceTransactionID: "1",
EventVersion: 1,
State: domain.TransactionStateCreated,
OccurredAt: time.Unix(1, 0).UTC(),
}
first, existed, err := service.AppendEvent(context.Background(), command)
if err != nil || existed {
t.Fatalf("first append: existed=%v err=%v", existed, err)
}
second, existed, err := service.AppendEvent(context.Background(), command)
if err != nil || !existed || first.PayloadHash != second.PayloadHash {
t.Fatalf("second append: existed=%v err=%v first=%+v second=%+v", existed, err, first, second)
}
}
func TestListUsesOpaquePageToken(t *testing.T) {
repository := newRepositoryStub()
repository.list = make([]domain.Journal, 3)
service := NewService(repository, nil)
journals, next, err := service.List(context.Background(), ListCommand{PageSize: 2})
if err != nil {
t.Fatal(err)
}
if len(journals) != 2 || next == "" {
t.Fatalf("unexpected page: len=%d next=%q", len(journals), next)
}
if _, err := decodePageToken(next); err != nil {
t.Fatalf("invalid generated page token: %v", err)
}
}
func TestNewUUIDProducesRFC4122Shape(t *testing.T) {
id, err := newUUID()
if err != nil {
t.Fatal(err)
}
if len(id) != 36 || id[14] != '4' || !strings.Contains("89ab", string(id[19])) {
t.Fatalf("unexpected UUID: %q", id)
}
}
func validAppendCommand() AppendJournalCommand {
return AppendJournalCommand{
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
Entries: []EntryCommand{
{
LineNumber: 1,
Account: domain.AccountReference{
Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5,
},
Amount: "-10.2500",
},
{
LineNumber: 2,
Account: domain.AccountReference{
Class: domain.AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5,
},
Amount: "10.25",
},
},
}
}
func fixedID(id string) IDGenerator {
return func() (string, error) { return id, nil }
}
+16
View File
@@ -0,0 +1,16 @@
version: v2
clean: true
managed:
enabled: true
override:
- file_option: go_package_prefix
value: gl/gen
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-go-grpc
out: gen
opt:
- paths=source_relative
- require_unimplemented_servers=false
+36
View File
@@ -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"]
+165
View File
@@ -0,0 +1,165 @@
// Command dashboard-loadtest runs a concurrent read-only HTTP load test against
// a deployed GL dashboard.
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
type result struct {
Duration time.Duration
Requests int64
Errors int64
Non2xx int64
Latency []time.Duration
}
func main() {
baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL")
paths := flag.String("paths", "/", "comma-separated request paths")
concurrency := flag.Int("concurrency", 20, "number of concurrent workers")
duration := flag.Duration("duration", 15*time.Second, "test duration")
requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request")
flag.Parse()
if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 {
fmt.Fprintln(os.Stderr, "concurrency and durations must be positive")
os.Exit(2)
}
targets, err := targets(*baseURL, *paths)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = *concurrency
transport.MaxIdleConnsPerHost = *concurrency
client := &http.Client{Transport: transport, Timeout: *requestTimeout}
defer transport.CloseIdleConnections()
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(signalCtx, *duration)
defer cancel()
fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL)
started := time.Now()
result := run(ctx, client, targets, *concurrency)
result.Duration = time.Since(started)
printResult(result)
if result.Errors > 0 || result.Non2xx > 0 {
os.Exit(1)
}
}
func targets(baseURL, pathList string) ([]string, error) {
base, err := url.Parse(baseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return nil, fmt.Errorf("invalid dashboard URL %q", baseURL)
}
paths := strings.Split(pathList, ",")
result := make([]string, 0, len(paths))
for _, value := range paths {
value = strings.TrimSpace(value)
if value == "" {
continue
}
reference, parseErr := url.Parse(value)
if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") {
return nil, fmt.Errorf("invalid request path %q", value)
}
result = append(result, base.ResolveReference(reference).String())
}
if len(result) == 0 {
return nil, errors.New("at least one request path is required")
}
return result, nil
}
func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result {
var (
requests int64
failures int64
non2xx int64
sequence uint64
latency = make([]time.Duration, 0, concurrency*100)
lock sync.Mutex
workers sync.WaitGroup
)
workers.Add(concurrency)
for range concurrency {
go func() {
defer workers.Done()
for ctx.Err() == nil {
target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))]
request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil)
if err != nil {
atomic.AddInt64(&failures, 1)
continue
}
started := time.Now()
response, err := client.Do(request)
elapsed := time.Since(started)
if err != nil {
atomic.AddInt64(&requests, 1)
atomic.AddInt64(&failures, 1)
continue
}
_, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20))
closeErr := response.Body.Close()
atomic.AddInt64(&requests, 1)
if response.StatusCode < 200 || response.StatusCode >= 300 {
atomic.AddInt64(&non2xx, 1)
}
if copyErr != nil || closeErr != nil {
atomic.AddInt64(&failures, 1)
}
lock.Lock()
latency = append(latency, elapsed)
lock.Unlock()
}
}()
}
workers.Wait()
return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency}
}
func printResult(value result) {
seconds := value.Duration.Seconds()
requestsPerSecond := float64(value.Requests)
if seconds > 0 {
requestsPerSecond /= seconds
}
fmt.Printf("requests: %d\n", value.Requests)
fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond)
fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99))
fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx)
}
func percentile(values []time.Duration, percent int) time.Duration {
if len(values) == 0 {
return 0
}
ordered := append([]time.Duration(nil), values...)
sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] })
index := (len(ordered)*percent + 99) / 100
if index < 1 {
index = 1
}
return ordered[index-1]
}
+68
View File
@@ -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)
}
}
+54
View File
@@ -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)
}
}
+22
View File
@@ -0,0 +1,22 @@
# GL load test
This command exercises the write and read paths of the gRPC ledger service.
Each simulated transaction:
1. appends a `CREATED` lifecycle event;
2. posts a balanced transfer between two generated user wallet accounts;
3. reads the committed journal;
4. rebuilds the receiving wallet balance;
5. appends a `SUCCESSFUL` lifecycle event.
Ledger accounts are created implicitly by `AppendJournal`; GL does not expose a
separate wallet-creation RPC.
Start GL, then run:
```bash
go run ./cmd/gl-loadtest -duration 30s -concurrency 20 -wallets 1000
```
Use a fresh `-run-id` for every dataset. The command generates one automatically
when the flag is omitted.
+323
View File
@@ -0,0 +1,323 @@
// Command gl-loadtest runs a stateful gRPC load test against the GL service.
// Wallet accounts are created implicitly by the first journal that references
// each generated owner ID, matching normal GL behavior.
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
domain "gl/domain/ledger"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb"
)
type client interface {
AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error)
AppendTransactionEvent(context.Context, *ledgerv1.AppendTransactionEventRequest, ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error)
GetJournal(context.Context, *ledgerv1.GetJournalRequest, ...grpc.CallOption) (*ledgerv1.Journal, error)
GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error)
}
type config struct {
RunID string
Wallets int
AssetID int64
Amount string
RequestTimeout time.Duration
}
type sample struct {
Operation string
Latency time.Duration
Err error
}
type operationStats struct {
Requests int64
Errors int64
Latency []time.Duration
}
func main() {
target := flag.String("target", "127.0.0.1:8600", "GL gRPC target")
concurrency := flag.Int("concurrency", 20, "number of concurrent transaction workers")
duration := flag.Duration("duration", 30*time.Second, "load duration")
wallets := flag.Int("wallets", 1000, "number of simulated wallet accounts")
assetID := flag.Int64("asset-id", 1, "asset ID used by transfers")
amount := flag.String("amount", "1", "canonical decimal transfer amount")
requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each gRPC call")
runID := flag.String("run-id", "", "idempotency namespace; generated when empty")
flag.Parse()
if *concurrency < 1 || *duration <= 0 || *wallets < 2 || *assetID <= 0 || *requestTimeout <= 0 || *amount == "" {
fmt.Fprintln(os.Stderr, "concurrency and durations must be positive; wallets must be at least 2; asset-id and amount are required")
os.Exit(2)
}
parsedAmount, err := domain.ParseAmount(*amount)
if err != nil || parsedAmount.IsZero() || strings.HasPrefix(*amount, "-") {
fmt.Fprintf(os.Stderr, "amount must be a positive canonical ledger decimal: %v\n", err)
os.Exit(2)
}
if *runID == "" {
*runID = "gl-load-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36)
}
connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fmt.Fprintf(os.Stderr, "create gRPC client: %v\n", err)
os.Exit(2)
}
defer connection.Close()
ledger := ledgerv1.NewGeneralLedgerServiceClient(connection)
healthCtx, cancelHealth := context.WithTimeout(context.Background(), *requestTimeout)
health, err := ledger.Health(healthCtx, &basev1.Empty{})
cancelHealth()
if err != nil || !health.GetServing() || !health.GetDatabaseReady() {
fmt.Fprintf(os.Stderr, "GL is not ready at %s: response=%v error=%v\n", *target, health, err)
os.Exit(1)
}
testConfig := config{
RunID: *runID,
Wallets: *wallets,
AssetID: *assetID,
Amount: *amount,
RequestTimeout: *requestTimeout,
}
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ctx, cancel := context.WithTimeout(signalCtx, *duration)
defer cancel()
fmt.Printf("GL load test: target=%s workers=%d duration=%s wallets=%d asset=%d run=%s\n", *target, *concurrency, *duration, *wallets, *assetID, *runID)
started := time.Now()
samples, transactions := run(ctx, ledger, testConfig, *concurrency)
elapsed := time.Since(started)
failed := printSummary(samples, transactions, elapsed)
if failed {
os.Exit(1)
}
}
func run(ctx context.Context, ledger client, cfg config, concurrency int) ([]sample, uint64) {
var sequence atomic.Uint64
results := make(chan []sample, concurrency)
var workers sync.WaitGroup
workers.Add(concurrency)
for range concurrency {
go func() {
defer workers.Done()
workerSamples := make([]sample, 0, 256)
for ctx.Err() == nil {
transaction := sequence.Add(1)
workerSamples = append(workerSamples, runTransaction(ctx, ledger, cfg, transaction)...)
}
results <- workerSamples
}()
}
workers.Wait()
close(results)
all := make([]sample, 0)
for workerSamples := range results {
all = append(all, workerSamples...)
}
return all, sequence.Load()
}
func runTransaction(ctx context.Context, ledger client, cfg config, sequence uint64) []sample {
transactionID := fmt.Sprintf("%s-%d", cfg.RunID, sequence)
senderID := fmt.Sprintf("load-wallet-%d", sequence%uint64(cfg.Wallets))
receiverID := fmt.Sprintf("load-wallet-%d", (sequence+1)%uint64(cfg.Wallets))
correlationID := "load-correlation-" + transactionID
blockchainHash := transactionHash(transactionID)
occurredAt := timestamppb.Now()
created := &ledgerv1.AppendTransactionEventRequest{
SourceService: "gl-loadtest",
IdempotencyKey: "load:" + transactionID + ":event:v1",
SourceTransactionId: transactionID,
TrackingCode: "load-" + strconv.FormatUint(sequence, 10),
EventVersion: 1,
State: ledgerv1.TransactionState_TRANSACTION_STATE_CREATED,
OccurredAt: occurredAt,
CorrelationId: correlationID,
ActorId: senderID,
Metadata: map[string]string{"load_test": cfg.RunID},
}
samples := []sample{measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error {
_, err := ledger.AppendTransactionEvent(callCtx, created)
return err
})}
journalRequest := &ledgerv1.AppendJournalRequest{
SourceService: "gl-loadtest",
IdempotencyKey: "load:" + transactionID + ":transfer:v1",
SourceTransactionId: transactionID,
TrackingCode: created.TrackingCode,
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: occurredAt,
CorrelationId: correlationID,
ActorId: senderID,
Blockchain: &ledgerv1.BlockchainReference{
Network: "load-test",
TransactionHash: blockchainHash,
LedgerSequence: strconv.FormatUint(sequence, 10),
},
Metadata: map[string]string{"load_test": cfg.RunID},
Entries: []*ledgerv1.JournalEntry{
{
LineNumber: 1,
Account: walletAccount(senderID, cfg.AssetID),
Amount: "-" + cfg.Amount,
Description: "load-test transfer debit",
},
{
LineNumber: 2,
Account: walletAccount(receiverID, cfg.AssetID),
Amount: cfg.Amount,
Description: "load-test transfer credit",
},
},
}
var journalID string
samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_journal", func(callCtx context.Context) error {
response, err := ledger.AppendJournal(callCtx, journalRequest)
if err == nil && response.GetJournal() != nil {
journalID = response.GetJournal().GetJournalId()
}
return err
}))
if journalID != "" {
samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_journal", func(callCtx context.Context) error {
_, err := ledger.GetJournal(callCtx, &ledgerv1.GetJournalRequest{
Lookup: &ledgerv1.GetJournalRequest_JournalId{JournalId: journalID},
})
return err
}))
}
samples = append(samples, measure(ctx, cfg.RequestTimeout, "get_balance", func(callCtx context.Context) error {
_, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: walletAccount(receiverID, cfg.AssetID)})
return err
}))
successful := &ledgerv1.AppendTransactionEventRequest{
SourceService: "gl-loadtest",
IdempotencyKey: "load:" + transactionID + ":event:v2",
SourceTransactionId: transactionID,
TrackingCode: created.TrackingCode,
EventVersion: 2,
State: ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL,
OccurredAt: timestamppb.Now(),
CorrelationId: correlationID,
ActorId: senderID,
Blockchain: journalRequest.Blockchain,
Metadata: map[string]string{"load_test": cfg.RunID},
}
samples = append(samples, measure(ctx, cfg.RequestTimeout, "append_event", func(callCtx context.Context) error {
_, err := ledger.AppendTransactionEvent(callCtx, successful)
return err
}))
return samples
}
func walletAccount(ownerID string, assetID int64) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: ownerID,
AssetId: assetID,
}
}
func transactionHash(transactionID string) string {
value := sha256.Sum256([]byte(transactionID))
return hex.EncodeToString(value[:])
}
func measure(ctx context.Context, timeout time.Duration, operation string, call func(context.Context) error) sample {
callCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout)
defer cancel()
started := time.Now()
err := call(callCtx)
return sample{Operation: operation, Latency: time.Since(started), Err: err}
}
func printSummary(samples []sample, transactions uint64, elapsed time.Duration) bool {
stats := make(map[string]*operationStats)
var failed bool
for _, value := range samples {
operation := stats[value.Operation]
if operation == nil {
operation = &operationStats{}
stats[value.Operation] = operation
}
operation.Requests++
operation.Latency = append(operation.Latency, value.Latency)
if value.Err != nil {
operation.Errors++
failed = true
}
}
names := make([]string, 0, len(stats))
for name := range stats {
names = append(names, name)
}
sort.Strings(names)
fmt.Printf("transactions: %d (%.1f tx/s)\n", transactions, rate(float64(transactions), elapsed))
fmt.Printf("%-18s %10s %8s %10s %12s %12s %12s\n", "operation", "requests", "errors", "req/s", "p50", "p95", "p99")
for _, name := range names {
value := stats[name]
fmt.Printf("%-18s %10d %8d %10.1f %12s %12s %12s\n",
name,
value.Requests,
value.Errors,
rate(float64(value.Requests), elapsed),
percentile(value.Latency, 50),
percentile(value.Latency, 95),
percentile(value.Latency, 99),
)
}
return failed
}
func rate(count float64, elapsed time.Duration) float64 {
if elapsed <= 0 {
return 0
}
return count / elapsed.Seconds()
}
func percentile(values []time.Duration, percent int) time.Duration {
if len(values) == 0 {
return 0
}
ordered := append([]time.Duration(nil), values...)
sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] })
index := (len(ordered)*percent + 99) / 100
if index < 1 {
index = 1
}
return ordered[index-1]
}
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"context"
"testing"
"time"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
)
type clientStub struct {
journal *ledgerv1.AppendJournalRequest
events []*ledgerv1.AppendTransactionEventRequest
balance *ledgerv1.GetBalanceRequest
lookup *ledgerv1.GetJournalRequest
}
func (s *clientStub) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) {
s.journal = request
return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: "journal-1"}}, nil
}
func (s *clientStub) AppendTransactionEvent(_ context.Context, request *ledgerv1.AppendTransactionEventRequest, _ ...grpc.CallOption) (*ledgerv1.AppendTransactionEventResponse, error) {
s.events = append(s.events, request)
return &ledgerv1.AppendTransactionEventResponse{}, nil
}
func (s *clientStub) GetJournal(_ context.Context, request *ledgerv1.GetJournalRequest, _ ...grpc.CallOption) (*ledgerv1.Journal, error) {
s.lookup = request
return &ledgerv1.Journal{JournalId: "journal-1"}, nil
}
func (s *clientStub) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) {
s.balance = request
return &ledgerv1.GetBalanceResponse{Balance: "1"}, nil
}
func TestRunTransactionCreatesWalletAccountsAndFullLifecycle(t *testing.T) {
ledger := &clientStub{}
samples := runTransaction(context.Background(), ledger, config{
RunID: "test-run", Wallets: 10, AssetID: 7, Amount: "2.5", RequestTimeout: time.Second,
}, 3)
if len(samples) != 5 {
t.Fatalf("expected five RPC measurements, got %d", len(samples))
}
if ledger.journal == nil || len(ledger.journal.Entries) != 2 {
t.Fatal("balanced journal was not appended")
}
debit, credit := ledger.journal.Entries[0], ledger.journal.Entries[1]
if debit.Amount != "-2.5" || credit.Amount != "2.5" {
t.Fatalf("unexpected journal amounts: %q and %q", debit.Amount, credit.Amount)
}
if debit.Account.OwnerId == credit.Account.OwnerId || debit.Account.AssetId != 7 || credit.Account.AssetId != 7 {
t.Fatalf("unexpected simulated wallets: debit=%+v credit=%+v", debit.Account, credit.Account)
}
if len(ledger.events) != 2 || ledger.events[0].State != ledgerv1.TransactionState_TRANSACTION_STATE_CREATED || ledger.events[1].State != ledgerv1.TransactionState_TRANSACTION_STATE_SUCCESSFUL {
t.Fatalf("unexpected transaction lifecycle: %+v", ledger.events)
}
if ledger.lookup.GetJournalId() != "journal-1" || ledger.balance.Account.OwnerId != credit.Account.OwnerId {
t.Fatal("journal and balance reads did not follow the write")
}
}
func TestTransactionHashIsStableAndUnique(t *testing.T) {
first := transactionHash("run-1")
if len(first) != 64 || first != transactionHash("run-1") || first == transactionHash("run-2") {
t.Fatalf("unexpected transaction hashes: %q", first)
}
}
func TestPercentileUsesNearestRank(t *testing.T) {
values := []time.Duration{time.Millisecond, 4 * time.Millisecond, 2 * time.Millisecond, 3 * time.Millisecond}
if got := percentile(values, 95); got != 4*time.Millisecond {
t.Fatalf("unexpected p95: %s", got)
}
}
+28
View File
@@ -0,0 +1,28 @@
# GL redistribution load test
This fixed-count scenario creates ten run-scoped user accounts for an operator-
selected asset. It verifies that all ten start at zero, funds only the first
person with 10,000, and then submits 10,000,000 user-to-user transfers.
The first nine transfers give every other person 45. The remaining transfers
use reproducible random senders, receivers, and positive integer amounts while
reserving a minimum balance of 45 for every person. The test finishes by reading
all ten balances from GL and fails unless every balance is at least 45 and their
sum is exactly 10,000.
Start GL, then run:
```bash
go run ./cmd/gl-redistribution-loadtest
Asset number: 7
```
For automation, pass the asset without the prompt:
```bash
go run ./cmd/gl-redistribution-loadtest -asset-id 7 -concurrency 32
```
The defaults are 10,000,000 transfers against `127.0.0.1:8600`. Use a fresh
`-run-id` for every dataset; one is generated automatically. `-transactions`
can be reduced for a smoke test, and `-seed` makes the random sequence repeatable.
+458
View File
@@ -0,0 +1,458 @@
// Command gl-redistribution-loadtest proves balance conservation while applying
// a fixed number of random transfers between ten isolated test users.
package main
import (
"context"
"flag"
"fmt"
"io"
mathrand "math/rand"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
personCount = 10
initialSupply = int64(10_000)
minimumBalance = int64(45)
defaultTransactions = uint64(10_000_000)
seedTransactions = uint64(personCount - 1)
)
type client interface {
Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error)
AppendJournal(context.Context, *ledgerv1.AppendJournalRequest, ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error)
GetBalance(context.Context, *ledgerv1.GetBalanceRequest, ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error)
}
type config struct {
RunID string
AssetID int64
Transactions uint64
Concurrency int
RequestTimeout time.Duration
RandomSeed int64
}
type transfer struct {
Sender int
Receiver int
Amount int64
}
type balanceState struct {
mu sync.Mutex
balances [personCount]int64
}
type metrics struct {
attempted atomic.Uint64
succeeded atomic.Uint64
errors atomic.Uint64
latencyNanos atomic.Int64
maxNanos atomic.Int64
}
func main() {
os.Exit(runCLI(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
func runCLI(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
flags := flag.NewFlagSet("gl-redistribution-loadtest", flag.ContinueOnError)
flags.SetOutput(stderr)
target := flags.String("target", "127.0.0.1:8600", "GL gRPC target")
assetID := flags.Int64("asset-id", 0, "asset number; prompted when omitted")
transactions := flags.Uint64("transactions", defaultTransactions, "total user-to-user transfers")
concurrency := flags.Int("concurrency", 32, "number of concurrent transfer workers")
requestTimeout := flags.Duration("request-timeout", 10*time.Second, "timeout for each gRPC call")
runID := flags.String("run-id", "", "isolated idempotency namespace; generated when empty")
randomSeed := flags.Int64("seed", 0, "random seed; generated when zero")
if err := flags.Parse(args); err != nil {
return 2
}
if *transactions < seedTransactions || *concurrency < 1 || *requestTimeout <= 0 {
fmt.Fprintf(stderr, "transactions must be at least %d; concurrency and request-timeout must be positive\n", seedTransactions)
return 2
}
resolvedAssetID, err := resolveAssetID(*assetID, stdin, stdout)
if err != nil {
fmt.Fprintf(stderr, "asset number: %v\n", err)
return 2
}
if *runID == "" {
*runID = "gl-redistribution-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 36)
}
if *randomSeed == 0 {
*randomSeed = time.Now().UnixNano()
}
connection, err := grpc.NewClient(*target, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fmt.Fprintf(stderr, "create gRPC client: %v\n", err)
return 2
}
defer connection.Close()
ledger := ledgerv1.NewGeneralLedgerServiceClient(connection)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg := config{
RunID: *runID,
AssetID: resolvedAssetID,
Transactions: *transactions,
Concurrency: *concurrency,
RequestTimeout: *requestTimeout,
RandomSeed: *randomSeed,
}
if err := execute(ctx, ledger, cfg, stdout); err != nil {
fmt.Fprintf(stderr, "redistribution load test failed: %v\n", err)
return 1
}
return 0
}
func resolveAssetID(value int64, input io.Reader, output io.Writer) (int64, error) {
if value > 0 {
return value, nil
}
if value < 0 {
return 0, fmt.Errorf("must be positive")
}
fmt.Fprint(output, "Asset number: ")
var text string
if _, err := fmt.Fscan(input, &text); err != nil {
return 0, fmt.Errorf("read input: %w", err)
}
assetID, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64)
if err != nil || assetID <= 0 {
return 0, fmt.Errorf("must be a positive integer")
}
return assetID, nil
}
func execute(ctx context.Context, ledger client, cfg config, output io.Writer) error {
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
health, err := ledger.Health(callCtx, &basev1.Empty{})
cancel()
if err != nil {
return fmt.Errorf("health check: %w", err)
}
if !health.GetServing() || !health.GetDatabaseReady() {
return fmt.Errorf("GL is not ready: %v", health)
}
people := personIDs(cfg.RunID)
startingBalances, err := readBalances(ctx, ledger, cfg, people)
if err != nil {
return fmt.Errorf("read unfunded balances: %w", err)
}
if err := verifyUnfunded(startingBalances); err != nil {
return err
}
if err := fundFirstPerson(ctx, ledger, cfg, people[0]); err != nil {
return fmt.Errorf("fund first person: %w", err)
}
initialBalances, err := readBalances(ctx, ledger, cfg, people)
if err != nil {
return fmt.Errorf("read initial balances: %w", err)
}
if err := verifyInitial(initialBalances); err != nil {
return err
}
fmt.Fprintf(output, "GL redistribution load test: target users=%d asset=%d transfers=%d workers=%d seed=%d run=%s\n",
personCount, cfg.AssetID, cfg.Transactions, cfg.Concurrency, cfg.RandomSeed, cfg.RunID)
fmt.Fprintln(output, "initial balances: [10000 0 0 0 0 0 0 0 0 0]")
stats := &metrics{}
started := time.Now()
balances := initialBalances
for person := 1; person < personCount; person++ {
value := transfer{Sender: 0, Receiver: person, Amount: minimumBalance}
startedCall := time.Now()
err := appendTransfer(ctx, ledger, cfg, uint64(person), people, value)
stats.observe(time.Since(startedCall), err)
if err != nil {
return fmt.Errorf("seed person %d: %w", person+1, err)
}
balances[0] -= minimumBalance
balances[person] += minimumBalance
}
state := &balanceState{balances: balances}
if err := runRandomTransfers(ctx, ledger, cfg, people, state, stats, output); err != nil {
printSummary(output, stats, time.Since(started))
return err
}
elapsed := time.Since(started)
printSummary(output, stats, elapsed)
finalBalances, err := readBalances(ctx, ledger, cfg, people)
if err != nil {
return fmt.Errorf("read final balances: %w", err)
}
if err := verifyFinal(finalBalances); err != nil {
return err
}
fmt.Fprintf(output, "final balances: %v\n", finalBalances)
fmt.Fprintf(output, "PASS: all %d people have at least %d; total=%d\n", personCount, minimumBalance, initialSupply)
return nil
}
func personIDs(runID string) [personCount]string {
var people [personCount]string
for index := range people {
people[index] = fmt.Sprintf("%s-person-%02d", runID, index+1)
}
return people
}
func readBalances(ctx context.Context, ledger client, cfg config, people [personCount]string) ([personCount]int64, error) {
var balances [personCount]int64
for index, person := range people {
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
response, err := ledger.GetBalance(callCtx, &ledgerv1.GetBalanceRequest{Account: personAccount(person, cfg.AssetID)})
cancel()
if err != nil {
return balances, fmt.Errorf("person %d: %w", index+1, err)
}
if response == nil {
return balances, fmt.Errorf("person %d: empty response", index+1)
}
balance, err := strconv.ParseInt(response.GetBalance(), 10, 64)
if err != nil {
return balances, fmt.Errorf("person %d: invalid integer balance %q", index+1, response.GetBalance())
}
balances[index] = balance
}
return balances, nil
}
func verifyUnfunded(balances [personCount]int64) error {
for index, balance := range balances {
if balance != 0 {
return fmt.Errorf("person %d must start unfunded; got %d (use a fresh run-id)", index+1, balance)
}
}
return nil
}
func verifyInitial(balances [personCount]int64) error {
if balances[0] != initialSupply {
return fmt.Errorf("person 1 initial balance: got %d, want %d", balances[0], initialSupply)
}
for index := 1; index < personCount; index++ {
if balances[index] != 0 {
return fmt.Errorf("person %d initial balance: got %d, want 0", index+1, balances[index])
}
}
return nil
}
func verifyFinal(balances [personCount]int64) error {
var total int64
for index, balance := range balances {
if balance < minimumBalance {
return fmt.Errorf("person %d final balance %d is below %d", index+1, balance, minimumBalance)
}
total += balance
}
if total != initialSupply {
return fmt.Errorf("final balance sum: got %d, want %d", total, initialSupply)
}
return nil
}
func fundFirstPerson(ctx context.Context, ledger client, cfg config, firstPerson string) error {
request := &ledgerv1.AppendJournalRequest{
SourceService: "gl-redistribution-loadtest",
IdempotencyKey: "redistribution:" + cfg.RunID + ":fund:v1",
SourceTransactionId: cfg.RunID + "-fund",
TrackingCode: cfg.RunID + "-fund",
EffectKind: "load-test-funding",
EventVersion: 1,
OccurredAt: timestamppb.Now(),
ActorId: cfg.RunID,
Metadata: map[string]string{"load_test": cfg.RunID, "phase": "funding"},
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: treasuryAccount(cfg.RunID, cfg.AssetID), Amount: "-10000", Description: "load-test source"},
{LineNumber: 2, Account: personAccount(firstPerson, cfg.AssetID), Amount: "10000", Description: "initial person balance"},
},
}
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
defer cancel()
_, err := ledger.AppendJournal(callCtx, request)
return err
}
func runRandomTransfers(ctx context.Context, ledger client, cfg config, people [personCount]string, state *balanceState, stats *metrics, output io.Writer) error {
if cfg.Transactions == seedTransactions {
return nil
}
workerCtx, cancel := context.WithCancel(ctx)
defer cancel()
var sequence atomic.Uint64
sequence.Store(seedTransactions)
var workers sync.WaitGroup
var firstErr error
var errorOnce sync.Once
workers.Add(cfg.Concurrency)
for worker := range cfg.Concurrency {
go func(worker int) {
defer workers.Done()
random := mathrand.New(mathrand.NewSource(cfg.RandomSeed + int64(worker+1)*7919))
for workerCtx.Err() == nil {
number := sequence.Add(1)
if number > cfg.Transactions {
return
}
value := state.reserve(random)
started := time.Now()
err := appendTransfer(workerCtx, ledger, cfg, number, people, value)
stats.observe(time.Since(started), err)
if err != nil {
state.rollback(value)
errorOnce.Do(func() {
firstErr = fmt.Errorf("transfer %d: %w", number, err)
cancel()
})
return
}
succeeded := stats.succeeded.Load()
if succeeded%1_000_000 == 0 {
fmt.Fprintf(output, "progress: %d/%d transfers\n", succeeded, cfg.Transactions)
}
}
}(worker)
}
workers.Wait()
if firstErr != nil {
return firstErr
}
if err := ctx.Err(); err != nil {
return err
}
if succeeded := stats.succeeded.Load(); succeeded != cfg.Transactions {
return fmt.Errorf("completed %d transfers, want %d", succeeded, cfg.Transactions)
}
return nil
}
func (s *balanceState) reserve(random *mathrand.Rand) transfer {
s.mu.Lock()
defer s.mu.Unlock()
eligible := make([]int, 0, personCount)
for index, balance := range s.balances {
if balance > minimumBalance {
eligible = append(eligible, index)
}
}
sender := eligible[random.Intn(len(eligible))]
receiver := random.Intn(personCount - 1)
if receiver >= sender {
receiver++
}
spendable := s.balances[sender] - minimumBalance
amount := random.Int63n(spendable) + 1
s.balances[sender] -= amount
s.balances[receiver] += amount
return transfer{Sender: sender, Receiver: receiver, Amount: amount}
}
func (s *balanceState) rollback(value transfer) {
s.mu.Lock()
defer s.mu.Unlock()
s.balances[value.Sender] += value.Amount
s.balances[value.Receiver] -= value.Amount
}
func appendTransfer(ctx context.Context, ledger client, cfg config, sequence uint64, people [personCount]string, value transfer) error {
number := strconv.FormatUint(sequence, 10)
amount := strconv.FormatInt(value.Amount, 10)
transactionID := cfg.RunID + "-transfer-" + number
request := &ledgerv1.AppendJournalRequest{
SourceService: "gl-redistribution-loadtest",
IdempotencyKey: "redistribution:" + cfg.RunID + ":transfer:" + number + ":v1",
SourceTransactionId: transactionID,
TrackingCode: transactionID,
EffectKind: "random-internal-transfer",
EventVersion: 1,
OccurredAt: timestamppb.Now(),
CorrelationId: cfg.RunID,
ActorId: people[value.Sender],
Metadata: map[string]string{"load_test": cfg.RunID, "sequence": number},
Entries: []*ledgerv1.JournalEntry{
{LineNumber: 1, Account: personAccount(people[value.Sender], cfg.AssetID), Amount: "-" + amount, Description: "random transfer debit"},
{LineNumber: 2, Account: personAccount(people[value.Receiver], cfg.AssetID), Amount: amount, Description: "random transfer credit"},
},
}
callCtx, cancel := context.WithTimeout(ctx, cfg.RequestTimeout)
defer cancel()
_, err := ledger.AppendJournal(callCtx, request)
return err
}
func personAccount(ownerID string, assetID int64) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: ownerID,
AssetId: assetID,
}
}
func treasuryAccount(runID string, assetID int64) *ledgerv1.AccountReference {
return &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_TREASURY,
OwnerType: "load-test",
OwnerId: runID,
AssetId: assetID,
}
}
func (m *metrics) observe(latency time.Duration, err error) {
m.attempted.Add(1)
m.latencyNanos.Add(latency.Nanoseconds())
for {
current := m.maxNanos.Load()
if latency.Nanoseconds() <= current || m.maxNanos.CompareAndSwap(current, latency.Nanoseconds()) {
break
}
}
if err != nil {
m.errors.Add(1)
return
}
m.succeeded.Add(1)
}
func printSummary(output io.Writer, stats *metrics, elapsed time.Duration) {
attempted := stats.attempted.Load()
average := time.Duration(0)
if attempted > 0 {
average = time.Duration(stats.latencyNanos.Load() / int64(attempted))
}
rate := float64(0)
if elapsed > 0 {
rate = float64(stats.succeeded.Load()) / elapsed.Seconds()
}
fmt.Fprintf(output, "transfers: attempted=%d succeeded=%d errors=%d elapsed=%s rate=%.1f tx/s avg=%s max=%s\n",
attempted, stats.succeeded.Load(), stats.errors.Load(), elapsed.Round(time.Millisecond), rate, average, time.Duration(stats.maxNanos.Load()))
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"bytes"
"context"
"fmt"
mathrand "math/rand"
"strconv"
"strings"
"sync"
"testing"
"time"
basev1 "gl/gen/base/v1"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc"
)
type memoryClient struct {
mu sync.Mutex
balances map[string]int64
journals int
}
func newMemoryClient() *memoryClient {
return &memoryClient{balances: make(map[string]int64)}
}
func (c *memoryClient) Health(context.Context, *basev1.Empty, ...grpc.CallOption) (*ledgerv1.HealthResponse, error) {
return &ledgerv1.HealthResponse{Serving: true, DatabaseReady: true}, nil
}
func (c *memoryClient) AppendJournal(_ context.Context, request *ledgerv1.AppendJournalRequest, _ ...grpc.CallOption) (*ledgerv1.AppendJournalResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
for _, entry := range request.GetEntries() {
amount, err := strconv.ParseInt(entry.GetAmount(), 10, 64)
if err != nil {
return nil, err
}
c.balances[accountKey(entry.GetAccount())] += amount
}
c.journals++
return &ledgerv1.AppendJournalResponse{Journal: &ledgerv1.Journal{JournalId: fmt.Sprintf("journal-%d", c.journals)}}, nil
}
func (c *memoryClient) GetBalance(_ context.Context, request *ledgerv1.GetBalanceRequest, _ ...grpc.CallOption) (*ledgerv1.GetBalanceResponse, error) {
c.mu.Lock()
defer c.mu.Unlock()
return &ledgerv1.GetBalanceResponse{Account: request.GetAccount(), Balance: strconv.FormatInt(c.balances[accountKey(request.GetAccount())], 10)}, nil
}
func accountKey(account *ledgerv1.AccountReference) string {
return fmt.Sprintf("%d/%s/%s/%d", account.GetAccountClass(), account.GetOwnerType(), account.GetOwnerId(), account.GetAssetId())
}
func TestResolveAssetIDPromptsWhenMissing(t *testing.T) {
var output bytes.Buffer
assetID, err := resolveAssetID(0, strings.NewReader("42\n"), &output)
if err != nil {
t.Fatal(err)
}
if assetID != 42 || output.String() != "Asset number: " {
t.Fatalf("asset=%d prompt=%q", assetID, output.String())
}
}
func TestResolveAssetIDUsesFlagWithoutPrompt(t *testing.T) {
var output bytes.Buffer
assetID, err := resolveAssetID(7, strings.NewReader(""), &output)
if err != nil {
t.Fatal(err)
}
if assetID != 7 || output.Len() != 0 {
t.Fatalf("asset=%d prompt=%q", assetID, output.String())
}
}
func TestRandomReservationsPreserveSupplyAndMinimum(t *testing.T) {
state := &balanceState{balances: [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45}}
random := mathrand.New(mathrand.NewSource(1234))
distinctAmounts := make(map[int64]struct{})
for range 100_000 {
value := state.reserve(random)
distinctAmounts[value.Amount] = struct{}{}
if value.Sender == value.Receiver || value.Amount <= 0 {
t.Fatalf("invalid transfer: %+v", value)
}
}
var total int64
for index, balance := range state.balances {
if balance < minimumBalance {
t.Fatalf("person %d balance %d is below minimum", index+1, balance)
}
total += balance
}
if total != initialSupply {
t.Fatalf("total=%d, want %d", total, initialSupply)
}
if len(distinctAmounts) < 2 {
t.Fatalf("expected random values, got %v", distinctAmounts)
}
}
func TestVerifyInitialAndFinalBalances(t *testing.T) {
initial := [personCount]int64{10_000}
if err := verifyInitial(initial); err != nil {
t.Fatal(err)
}
final := [personCount]int64{9595, 45, 45, 45, 45, 45, 45, 45, 45, 45}
if err := verifyFinal(final); err != nil {
t.Fatal(err)
}
final[9] = 44
if err := verifyFinal(final); err == nil {
t.Fatal("expected minimum-balance failure")
}
}
func TestPersonIDsAreIsolatedByRun(t *testing.T) {
first := personIDs("run-a")
second := personIDs("run-b")
if first[0] == second[0] || first[0] == first[1] {
t.Fatalf("IDs are not isolated: %q %q %q", first[0], second[0], first[1])
}
}
func TestExecuteFundsRedistributesAndVerifiesConservation(t *testing.T) {
ledger := newMemoryClient()
var output bytes.Buffer
err := execute(context.Background(), ledger, config{
RunID: "test-run",
AssetID: 17,
Transactions: 1000,
Concurrency: 4,
RequestTimeout: time.Second,
RandomSeed: 99,
}, &output)
if err != nil {
t.Fatal(err)
}
if ledger.journals != 1001 {
t.Fatalf("journals=%d, want one funding journal and 1000 transfers", ledger.journals)
}
if !strings.Contains(output.String(), "PASS: all 10 people have at least 45; total=10000") {
t.Fatalf("missing successful verification in output:\n%s", output.String())
}
}
+54
View File
@@ -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)
}
}
+15
View File
@@ -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"
+115
View File
@@ -0,0 +1,115 @@
// Package ledger contains GL's framework-independent financial model.
package ledger
import (
"fmt"
"math/big"
"strings"
"unicode"
)
const (
AmountPrecision = 38
AmountScale = 18
)
var amountFactor = new(big.Int).Exp(big.NewInt(10), big.NewInt(AmountScale), nil)
// Amount is an exact fixed-point decimal with PostgreSQL numeric(38,18)
// semantics. Its zero value is a valid zero amount.
type Amount struct {
units big.Int
}
func ParseAmount(value string) (Amount, error) {
if value == "" {
return Amount{}, fmt.Errorf("amount is required")
}
negative := value[0] == '-'
unsigned := value
if negative {
unsigned = value[1:]
}
if unsigned == "" || strings.HasPrefix(unsigned, "+") {
return Amount{}, fmt.Errorf("invalid amount %q", value)
}
parts := strings.Split(unsigned, ".")
if len(parts) > 2 || parts[0] == "" {
return Amount{}, fmt.Errorf("invalid amount %q", value)
}
for _, part := range parts {
if part == "" || strings.IndexFunc(part, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
return Amount{}, fmt.Errorf("invalid amount %q", value)
}
}
if len(parts[0]) > 1 && parts[0][0] == '0' {
return Amount{}, fmt.Errorf("amount must not contain leading zeroes")
}
if len(strings.TrimLeft(parts[0], "0")) > AmountPrecision-AmountScale {
return Amount{}, fmt.Errorf("amount exceeds integer precision %d", AmountPrecision-AmountScale)
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
if len(fraction) > AmountScale {
return Amount{}, fmt.Errorf("amount exceeds scale %d", AmountScale)
}
}
digits := strings.TrimLeft(parts[0]+fraction, "0")
if len(digits) > AmountPrecision {
return Amount{}, fmt.Errorf("amount exceeds precision %d", AmountPrecision)
}
whole := new(big.Int)
whole.SetString(parts[0], 10)
units := new(big.Int).Mul(whole, amountFactor)
if fraction != "" {
padded := fraction + strings.Repeat("0", AmountScale-len(fraction))
fractional := new(big.Int)
fractional.SetString(padded, 10)
units.Add(units, fractional)
}
if negative {
units.Neg(units)
}
return Amount{units: *units}, nil
}
func (a Amount) IsZero() bool {
return a.units.Sign() == 0
}
func (a Amount) Add(other Amount) Amount {
var result big.Int
result.Add(&a.units, &other.units)
return Amount{units: result}
}
func (a Amount) Negate() Amount {
var result big.Int
result.Neg(&a.units)
return Amount{units: result}
}
func (a Amount) String() string {
if a.units.Sign() == 0 {
return "0"
}
abs := new(big.Int).Abs(new(big.Int).Set(&a.units))
whole, fraction := new(big.Int), new(big.Int)
whole.QuoRem(abs, amountFactor, fraction)
value := whole.String()
if fraction.Sign() != 0 {
fractionText := fmt.Sprintf("%018s", fraction.String())
value += "." + strings.TrimRight(fractionText, "0")
}
if a.units.Sign() < 0 {
return "-" + value
}
return value
}
+38
View File
@@ -0,0 +1,38 @@
package ledger
import "testing"
func TestParseAmountCanonicalizesExactValues(t *testing.T) {
for input, expected := range map[string]string{
"0": "0",
"1": "1",
"1.2300": "1.23",
"-0.000000000000000001": "-0.000000000000000001",
"99999999999999999999": "99999999999999999999",
} {
amount, err := ParseAmount(input)
if err != nil {
t.Fatalf("ParseAmount(%q): %v", input, err)
}
if got := amount.String(); got != expected {
t.Fatalf("ParseAmount(%q) = %q, want %q", input, got, expected)
}
}
}
func TestParseAmountRejectsInvalidValues(t *testing.T) {
for _, input := range []string{"", "+1", "01", ".1", "1.", "1e2", "1.0000000000000000001", "123456789012345678901234567890123456789"} {
if _, err := ParseAmount(input); err == nil {
t.Fatalf("ParseAmount(%q) succeeded", input)
}
}
}
func TestAmountAdditionIsExact(t *testing.T) {
one, _ := ParseAmount("0.1")
two, _ := ParseAmount("0.2")
minusThree, _ := ParseAmount("-0.3")
if got := one.Add(two).Add(minusThree); !got.IsZero() {
t.Fatalf("expected exact zero, got %s", got.String())
}
}
+10
View File
@@ -0,0 +1,10 @@
package ledger
import "errors"
var (
ErrNotFound = errors.New("ledger record not found")
ErrIdempotencyConflict = errors.New("idempotency key belongs to a different payload")
ErrIncompleteJournal = errors.New("idempotency key belongs to an unsealed journal")
ErrAlreadyReversed = errors.New("journal already has a reversal")
)
+74
View File
@@ -0,0 +1,74 @@
package ledger
import (
"encoding/hex"
"fmt"
"strings"
"time"
)
type TransactionState string
const (
TransactionStateCreated TransactionState = "CREATED"
TransactionStatePendingTransaction TransactionState = "PENDING_TRANSACTION"
TransactionStatePendingAdmin TransactionState = "PENDING_ADMIN"
TransactionStateSuccessful TransactionState = "SUCCESSFUL"
TransactionStateFailed TransactionState = "FAILED"
TransactionStateSuspended TransactionState = "SUSPENDED"
)
func (s TransactionState) Valid() bool {
switch s {
case TransactionStateCreated,
TransactionStatePendingTransaction,
TransactionStatePendingAdmin,
TransactionStateSuccessful,
TransactionStateFailed,
TransactionStateSuspended:
return true
default:
return false
}
}
type TransactionEvent struct {
ID string
SourceService string
IdempotencyKey string
SourceTransactionID string
TrackingCode string
EventVersion uint32
State TransactionState
ErrorCode string
ErrorMessage string
OccurredAt time.Time
RecordedAt time.Time
CorrelationID string
ActorID string
Blockchain BlockchainReference
Metadata map[string]string
PayloadHash string
}
func (e TransactionEvent) Validate() error {
if e.ID == "" || e.SourceService == "" || e.IdempotencyKey == "" || e.SourceTransactionID == "" {
return fmt.Errorf("transaction event identity fields are required")
}
if e.EventVersion == 0 {
return fmt.Errorf("event version must be positive")
}
if !e.State.Valid() {
return fmt.Errorf("invalid transaction state %q", e.State)
}
if e.OccurredAt.IsZero() {
return fmt.Errorf("occurred time is required")
}
if len(e.PayloadHash) != 64 || e.PayloadHash != strings.ToLower(e.PayloadHash) {
return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string")
}
if _, err := hex.DecodeString(e.PayloadHash); err != nil {
return fmt.Errorf("payload hash must be a lowercase SHA-256 hex string")
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
package ledger
import (
"encoding/hex"
"fmt"
"strings"
"time"
)
type AccountClass string
const (
AccountClassUserAvailable AccountClass = "USER_AVAILABLE"
AccountClassUserFrozen AccountClass = "USER_FROZEN"
AccountClassExternalBlockchain AccountClass = "EXTERNAL_BLOCKCHAIN"
AccountClassTreasury AccountClass = "TREASURY"
AccountClassMarketClearing AccountClass = "MARKET_CLEARING"
AccountClassIPGClearing AccountClass = "IPG_CLEARING"
AccountClassCommissionRevenue AccountClass = "COMMISSION_REVENUE"
)
func (c AccountClass) Valid() bool {
switch c {
case AccountClassUserAvailable,
AccountClassUserFrozen,
AccountClassExternalBlockchain,
AccountClassTreasury,
AccountClassMarketClearing,
AccountClassIPGClearing,
AccountClassCommissionRevenue:
return true
default:
return false
}
}
type AccountReference struct {
Class AccountClass
OwnerType string
OwnerID string
AssetID int64
}
func (a AccountReference) Validate() error {
if !a.Class.Valid() {
return fmt.Errorf("invalid account class %q", a.Class)
}
if a.AssetID <= 0 {
return fmt.Errorf("asset id must be positive")
}
if a.Class == AccountClassUserAvailable || a.Class == AccountClassUserFrozen {
if a.OwnerType == "" || a.OwnerID == "" {
return fmt.Errorf("user account requires owner type and id")
}
}
return nil
}
type Entry struct {
LineNumber uint32
Account AccountReference
Amount Amount
Description string
}
type BlockchainReference struct {
Network string
TransactionHash string
LedgerSequence string
}
type Journal struct {
ID string
SourceService string
IdempotencyKey string
SourceTransactionID string
TrackingCode string
EffectKind string
EventVersion uint32
Entries []Entry
ReversalOfJournalID string
OccurredAt time.Time
RecordedAt time.Time
CorrelationID string
ActorID string
Blockchain BlockchainReference
Metadata map[string]string
PayloadHash string
}
type AppendResult struct {
JournalID string
AlreadyExists bool
}
type JournalFilter struct {
Account *AccountReference
AssetID *int64
OwnerID *string
EffectKind *string
RecordedFrom *time.Time
RecordedTo *time.Time
Limit int
Offset int
}
func (j Journal) Validate() error {
if j.ID == "" || j.SourceService == "" || j.IdempotencyKey == "" || j.SourceTransactionID == "" || j.EffectKind == "" {
return fmt.Errorf("journal identity fields are required")
}
if j.EventVersion == 0 {
return fmt.Errorf("event version must be positive")
}
if j.OccurredAt.IsZero() {
return fmt.Errorf("occurred time is required")
}
if len(j.PayloadHash) != 64 {
return fmt.Errorf("payload hash must be a SHA-256 hex string")
}
if _, err := hex.DecodeString(j.PayloadHash); err != nil {
return fmt.Errorf("payload hash must be a SHA-256 hex string")
}
if j.PayloadHash != strings.ToLower(j.PayloadHash) {
return fmt.Errorf("payload hash must use lowercase hexadecimal")
}
if len(j.Entries) < 2 {
return fmt.Errorf("journal requires at least two entries")
}
lines := make(map[uint32]struct{}, len(j.Entries))
balances := make(map[int64]Amount)
for _, entry := range j.Entries {
if entry.LineNumber == 0 {
return fmt.Errorf("entry line number must be positive")
}
if _, exists := lines[entry.LineNumber]; exists {
return fmt.Errorf("duplicate entry line number %d", entry.LineNumber)
}
lines[entry.LineNumber] = struct{}{}
if err := entry.Account.Validate(); err != nil {
return fmt.Errorf("entry %d: %w", entry.LineNumber, err)
}
if entry.Amount.IsZero() {
return fmt.Errorf("entry %d amount must not be zero", entry.LineNumber)
}
balances[entry.Account.AssetID] = balances[entry.Account.AssetID].Add(entry.Amount)
}
for assetID, balance := range balances {
if !balance.IsZero() {
return fmt.Errorf("asset %d entries are unbalanced by %s", assetID, balance.String())
}
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package ledger
import (
"strings"
"testing"
"time"
)
func TestJournalValidationBalancesEachAsset(t *testing.T) {
journal := validJournal(t)
if err := journal.Validate(); err != nil {
t.Fatal(err)
}
journal.Entries[1].Amount, _ = ParseAmount("9")
if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "unbalanced") {
t.Fatalf("expected unbalanced error, got %v", err)
}
}
func TestJournalValidationRejectsDuplicateLines(t *testing.T) {
journal := validJournal(t)
journal.Entries[1].LineNumber = journal.Entries[0].LineNumber
if err := journal.Validate(); err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Fatalf("expected duplicate line error, got %v", err)
}
}
func validJournal(t *testing.T) Journal {
t.Helper()
debit, err := ParseAmount("-10")
if err != nil {
t.Fatal(err)
}
credit := debit.Negate()
return Journal{
ID: "11111111-1111-4111-8111-111111111111",
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionID: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: time.Unix(1, 0).UTC(),
PayloadHash: strings.Repeat("a", 64),
Entries: []Entry{
{LineNumber: 1, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "1", AssetID: 5}, Amount: debit},
{LineNumber: 2, Account: AccountReference{Class: AccountClassUserAvailable, OwnerType: "user", OwnerID: "2", AssetID: 5}, Amount: credit},
},
}
}
+368
View File
@@ -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
+102
View File
@@ -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
}
+348
View File
@@ -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
View File
@@ -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"
+43
View File
@@ -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
+83
View File
@@ -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=
+142
View File
@@ -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
}
+66
View File
@@ -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")
}
}
+100
View File
@@ -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...)
}
+106
View File
@@ -0,0 +1,106 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
)
func (r *JournalRepository) AppendEvent(ctx context.Context, event ledger.TransactionEvent) (ledger.TransactionEvent, bool, error) {
if err := event.Validate(); err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("validate transaction event: %w", err)
}
metadataValues := event.Metadata
if metadataValues == nil {
metadataValues = map[string]string{}
}
metadata, err := json.Marshal(metadataValues)
if err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("encode event metadata: %w", err)
}
err = r.database.QueryRow(ctx, insertEventSQL,
event.ID,
event.SourceService,
event.IdempotencyKey,
event.SourceTransactionID,
event.TrackingCode,
event.EventVersion,
event.State,
event.ErrorCode,
event.ErrorMessage,
event.OccurredAt,
event.CorrelationID,
event.ActorID,
event.Blockchain.Network,
event.Blockchain.TransactionHash,
event.Blockchain.LedgerSequence,
metadata,
event.PayloadHash,
).Scan(&event.RecordedAt)
if !errors.Is(err, pgx.ErrNoRows) {
if err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("insert transaction event: %w", err)
}
return event, false, nil
}
var stored ledger.TransactionEvent
var storedMetadata []byte
err = r.database.QueryRow(ctx, selectEventByIdempotencySQL, event.IdempotencyKey).Scan(
&stored.ID,
&stored.SourceService,
&stored.IdempotencyKey,
&stored.SourceTransactionID,
&stored.TrackingCode,
&stored.EventVersion,
&stored.State,
&stored.ErrorCode,
&stored.ErrorMessage,
&stored.OccurredAt,
&stored.RecordedAt,
&stored.CorrelationID,
&stored.ActorID,
&stored.Blockchain.Network,
&stored.Blockchain.TransactionHash,
&stored.Blockchain.LedgerSequence,
&storedMetadata,
&stored.PayloadHash,
)
if err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("read idempotent transaction event: %w", err)
}
if stored.PayloadHash != event.PayloadHash {
return ledger.TransactionEvent{}, false, ErrIdempotencyConflict
}
if err := json.Unmarshal(storedMetadata, &stored.Metadata); err != nil {
return ledger.TransactionEvent{}, false, fmt.Errorf("decode event metadata: %w", err)
}
return stored, true, nil
}
const insertEventSQL = `
INSERT INTO transaction_events (
id, source_service, idempotency_key, source_transaction_id, tracking_code,
event_version, state, error_code, error_message, occurred_at, correlation_id,
actor_id, blockchain_network, blockchain_transaction_hash,
blockchain_ledger_sequence, metadata, payload_hash
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING recorded_at`
const selectEventByIdempotencySQL = `
SELECT id, source_service, idempotency_key, source_transaction_id,
tracking_code, event_version, state, error_code, error_message,
occurred_at, recorded_at, correlation_id, actor_id, blockchain_network,
blockchain_transaction_hash, blockchain_ledger_sequence, metadata,
payload_hash
FROM transaction_events
WHERE idempotency_key = $1`
@@ -0,0 +1,73 @@
package postgres
import (
"context"
"errors"
"strings"
"testing"
"time"
"gl/domain/ledger"
"github.com/jackc/pgx/v5"
)
func TestAppendEventStoresAndReturnsRecordingTime(t *testing.T) {
event := repositoryEvent()
recordedAt := time.Unix(2, 0).UTC()
database := &fakeDatabase{directRows: []Row{fakeRow{values: []any{recordedAt}}}}
stored, existed, err := NewJournalRepository(database).AppendEvent(context.Background(), event)
if err != nil {
t.Fatal(err)
}
if existed || !stored.RecordedAt.Equal(recordedAt) {
t.Fatalf("unexpected append result: existed=%v event=%+v", existed, stored)
}
}
func TestAppendEventRejectsConflictingIdempotencyPayload(t *testing.T) {
event := repositoryEvent()
storedHash := strings.Repeat("b", 64)
database := &fakeDatabase{directRows: []Row{
fakeRow{err: pgx.ErrNoRows},
fakeRow{values: []any{
event.ID,
event.SourceService,
event.IdempotencyKey,
event.SourceTransactionID,
event.TrackingCode,
event.EventVersion,
event.State,
event.ErrorCode,
event.ErrorMessage,
event.OccurredAt,
time.Unix(2, 0).UTC(),
event.CorrelationID,
event.ActorID,
event.Blockchain.Network,
event.Blockchain.TransactionHash,
event.Blockchain.LedgerSequence,
[]byte(`{}`),
storedHash,
}},
}}
_, _, err := NewJournalRepository(database).AppendEvent(context.Background(), event)
if !errors.Is(err, ErrIdempotencyConflict) {
t.Fatalf("expected idempotency conflict, got %v", err)
}
}
func repositoryEvent() ledger.TransactionEvent {
return ledger.TransactionEvent{
ID: "11111111-1111-4111-8111-111111111111",
SourceService: "wallet",
IdempotencyKey: "wallet:1:status:v1",
SourceTransactionID: "1",
EventVersion: 1,
State: ledger.TransactionStateCreated,
OccurredAt: time.Unix(1, 0).UTC(),
PayloadHash: strings.Repeat("a", 64),
}
}
@@ -0,0 +1,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(),
},
},
}
}
+75
View File
@@ -0,0 +1,75 @@
package postgres
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
//go:embed migrations/*.up.sql
var migrationFiles embed.FS
const migrationLockID int64 = 674301
func Migrate(ctx context.Context, database Database) (err error) {
tx, err := database.Begin(ctx)
if err != nil {
return fmt.Errorf("begin migrations: %w", err)
}
defer func() {
if err != nil {
_ = tx.Rollback(ctx)
}
}()
if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1)", migrationLockID); err != nil {
return fmt.Errorf("lock migrations: %w", err)
}
if _, err = tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
)`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
files, err := fs.Glob(migrationFiles, "migrations/*.up.sql")
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(files)
for _, name := range files {
versionText := strings.SplitN(strings.TrimPrefix(name, "migrations/"), "_", 2)[0]
version, parseErr := strconv.ParseInt(versionText, 10, 64)
if parseErr != nil {
return fmt.Errorf("parse migration %s: %w", name, parseErr)
}
var applied bool
if err = tx.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM ledger_schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", name, err)
}
if applied {
continue
}
contents, readErr := migrationFiles.ReadFile(name)
if readErr != nil {
return fmt.Errorf("read migration %s: %w", name, readErr)
}
if _, err = tx.Exec(ctx, string(contents)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err = tx.Exec(ctx, "INSERT INTO ledger_schema_migrations (version) VALUES ($1) ON CONFLICT DO NOTHING", version); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
}
if err = tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migrations: %w", err)
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package postgres
import (
"strings"
"testing"
)
func TestInitialMigrationContainsLedgerSafetyGuards(t *testing.T) {
contents, err := migrationFiles.ReadFile("migrations/000001_init.up.sql")
if err != nil {
t.Fatal(err)
}
schema := string(contents)
for _, required := range []string{
"amount numeric(38, 18)",
"idempotency_key text NOT NULL UNIQUE",
"guard_journal_seal",
"journal is not balanced per asset",
"cannot append to a sealed journal",
"reject_ledger_mutation",
"journals_one_reversal_idx",
} {
if !strings.Contains(schema, required) {
t.Fatalf("migration is missing %q", required)
}
}
}
func TestExplorerFilterMigrationAddsSupportingIndexes(t *testing.T) {
contents, err := migrationFiles.ReadFile("migrations/000002_explorer_filters.up.sql")
if err != nil {
t.Fatal(err)
}
schema := string(contents)
for _, required := range []string{"journals_effect_recorded_idx", "lower(effect_kind)", "ledger_accounts_user_owner_idx", "USER_AVAILABLE", "USER_FROZEN"} {
if !strings.Contains(schema, required) {
t.Fatalf("explorer filter migration is missing %q", required)
}
}
}
@@ -0,0 +1,14 @@
DROP TRIGGER IF EXISTS transaction_events_reject_update_or_delete ON transaction_events;
DROP TRIGGER IF EXISTS accounts_reject_update_or_delete ON ledger_accounts;
DROP TRIGGER IF EXISTS entries_reject_update_or_delete ON journal_entries;
DROP TRIGGER IF EXISTS entries_guard_insert ON journal_entries;
DROP TRIGGER IF EXISTS journals_reject_delete ON journals;
DROP TRIGGER IF EXISTS journals_guard_update ON journals;
DROP FUNCTION IF EXISTS guard_entry_insert();
DROP FUNCTION IF EXISTS guard_journal_seal();
DROP FUNCTION IF EXISTS reject_ledger_mutation();
DROP TABLE IF EXISTS transaction_events;
DROP TABLE IF EXISTS journal_entries;
DROP TABLE IF EXISTS journals;
DROP TABLE IF EXISTS ledger_accounts;
DROP TABLE IF EXISTS ledger_schema_migrations;
@@ -0,0 +1,176 @@
CREATE TABLE IF NOT EXISTS ledger_schema_migrations (
version bigint PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT clock_timestamp()
);
CREATE TABLE ledger_accounts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
class text NOT NULL CHECK (class IN (
'USER_AVAILABLE',
'USER_FROZEN',
'EXTERNAL_BLOCKCHAIN',
'TREASURY',
'MARKET_CLEARING',
'IPG_CLEARING',
'COMMISSION_REVENUE'
)),
owner_type text NOT NULL DEFAULT '',
owner_id text NOT NULL DEFAULT '',
asset_id bigint NOT NULL CHECK (asset_id > 0),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE (class, owner_type, owner_id, asset_id),
UNIQUE (id, asset_id),
CHECK (
class NOT IN ('USER_AVAILABLE', 'USER_FROZEN')
OR (owner_type <> '' AND owner_id <> '')
)
);
CREATE TABLE journals (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
tracking_code text NOT NULL DEFAULT '',
effect_kind text NOT NULL CHECK (effect_kind <> ''),
event_version integer NOT NULL CHECK (event_version > 0),
reversal_of_journal_id uuid REFERENCES journals (id),
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
sealed_at timestamptz,
correlation_id text NOT NULL DEFAULT '',
actor_id text NOT NULL DEFAULT '',
blockchain_network text NOT NULL DEFAULT '',
blockchain_transaction_hash text NOT NULL DEFAULT '',
blockchain_ledger_sequence text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
CHECK (reversal_of_journal_id IS NULL OR reversal_of_journal_id <> id)
);
CREATE INDEX journals_source_transaction_idx
ON journals (source_service, source_transaction_id);
CREATE INDEX journals_recorded_at_idx ON journals (recorded_at, id);
CREATE UNIQUE INDEX journals_one_reversal_idx
ON journals (reversal_of_journal_id)
WHERE reversal_of_journal_id IS NOT NULL;
CREATE TABLE journal_entries (
journal_id uuid NOT NULL REFERENCES journals (id),
line_number integer NOT NULL CHECK (line_number > 0),
account_id bigint NOT NULL,
asset_id bigint NOT NULL CHECK (asset_id > 0),
amount numeric(38, 18) NOT NULL CHECK (amount <> 0),
description text NOT NULL DEFAULT '',
PRIMARY KEY (journal_id, line_number),
FOREIGN KEY (account_id, asset_id) REFERENCES ledger_accounts (id, asset_id)
);
CREATE INDEX journal_entries_account_idx
ON journal_entries (account_id, journal_id, line_number);
CREATE TABLE transaction_events (
id uuid PRIMARY KEY,
source_service text NOT NULL CHECK (source_service <> ''),
idempotency_key text NOT NULL UNIQUE CHECK (idempotency_key <> ''),
source_transaction_id text NOT NULL CHECK (source_transaction_id <> ''),
tracking_code text NOT NULL DEFAULT '',
event_version integer NOT NULL CHECK (event_version > 0),
state text NOT NULL CHECK (state IN (
'CREATED',
'PENDING_TRANSACTION',
'PENDING_ADMIN',
'SUCCESSFUL',
'FAILED',
'SUSPENDED'
)),
error_code text NOT NULL DEFAULT '',
error_message text NOT NULL DEFAULT '',
occurred_at timestamptz NOT NULL,
recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(),
correlation_id text NOT NULL DEFAULT '',
actor_id text NOT NULL DEFAULT '',
blockchain_network text NOT NULL DEFAULT '',
blockchain_transaction_hash text NOT NULL DEFAULT '',
blockchain_ledger_sequence text NOT NULL DEFAULT '',
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
payload_hash char(64) NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$')
);
CREATE INDEX transaction_events_source_idx
ON transaction_events (source_service, source_transaction_id, event_version);
CREATE FUNCTION reject_ledger_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION '% is append-only', TG_TABLE_NAME
USING ERRCODE = '55000';
END;
$$;
CREATE FUNCTION guard_journal_seal() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF OLD.sealed_at IS NOT NULL
OR NEW.sealed_at IS NULL
OR (to_jsonb(NEW) - 'sealed_at') IS DISTINCT FROM (to_jsonb(OLD) - 'sealed_at') THEN
RAISE EXCEPTION 'journals are immutable except for initial sealing'
USING ERRCODE = '55000';
END IF;
IF (SELECT count(*) FROM journal_entries WHERE journal_id = NEW.id) < 2 THEN
RAISE EXCEPTION 'journal requires at least two entries'
USING ERRCODE = '23514';
END IF;
IF EXISTS (
SELECT asset_id
FROM journal_entries
WHERE journal_id = NEW.id
GROUP BY asset_id
HAVING sum(amount) <> 0
) THEN
RAISE EXCEPTION 'journal is not balanced per asset'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE FUNCTION guard_entry_insert() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (SELECT sealed_at IS NOT NULL FROM journals WHERE id = NEW.journal_id) THEN
RAISE EXCEPTION 'cannot append to a sealed journal'
USING ERRCODE = '55000';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER journals_guard_update
BEFORE UPDATE ON journals
FOR EACH ROW EXECUTE FUNCTION guard_journal_seal();
CREATE TRIGGER journals_reject_delete
BEFORE DELETE ON journals
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER entries_guard_insert
BEFORE INSERT ON journal_entries
FOR EACH ROW EXECUTE FUNCTION guard_entry_insert();
CREATE TRIGGER entries_reject_update_or_delete
BEFORE UPDATE OR DELETE ON journal_entries
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER accounts_reject_update_or_delete
BEFORE UPDATE OR DELETE ON ledger_accounts
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
CREATE TRIGGER transaction_events_reject_update_or_delete
BEFORE UPDATE OR DELETE ON transaction_events
FOR EACH ROW EXECUTE FUNCTION reject_ledger_mutation();
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS ledger_accounts_user_owner_idx;
DROP INDEX IF EXISTS journals_effect_recorded_idx;
@@ -0,0 +1,7 @@
CREATE INDEX journals_effect_recorded_idx
ON journals (lower(effect_kind), recorded_at DESC, id DESC)
WHERE sealed_at IS NOT NULL;
CREATE INDEX ledger_accounts_user_owner_idx
ON ledger_accounts (owner_id, id)
WHERE class IN ('USER_AVAILABLE', 'USER_FROZEN');
+342
View File
@@ -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)`
+32
View File
@@ -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
}
+19
View File
@@ -0,0 +1,19 @@
package grpcadapter
import (
"context"
"testing"
"gl/application/health"
basev1 "gl/gen/base/v1"
)
func TestHealth(t *testing.T) {
response, err := NewHealthHandler(health.NewService(nil)).Health(context.Background(), &basev1.Empty{})
if err != nil {
t.Fatal(err)
}
if !response.Serving || response.DatabaseReady {
t.Fatalf("unexpected response: %+v", response)
}
}
+408
View File
@@ -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")
}
}
+123
View File
@@ -0,0 +1,123 @@
package grpcadapter
import (
"context"
"errors"
"testing"
"time"
applicationledger "gl/application/ledger"
domain "gl/domain/ledger"
ledgerv1 "gl/gen/ledger/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
type ledgerRepositoryStub struct {
journal domain.Journal
}
func (r *ledgerRepositoryStub) Append(_ context.Context, journal domain.Journal) (domain.AppendResult, error) {
journal.RecordedAt = time.Unix(2, 0).UTC()
r.journal = journal
return domain.AppendResult{JournalID: journal.ID}, nil
}
func (r *ledgerRepositoryStub) AppendEvent(_ context.Context, event domain.TransactionEvent) (domain.TransactionEvent, bool, error) {
event.RecordedAt = time.Unix(2, 0).UTC()
return event, false, nil
}
func (r *ledgerRepositoryStub) GetByID(context.Context, string) (domain.Journal, error) {
return r.journal, nil
}
func (r *ledgerRepositoryStub) GetByIdempotencyKey(context.Context, string) (domain.Journal, error) {
return r.journal, nil
}
func (r *ledgerRepositoryStub) List(context.Context, domain.JournalFilter) ([]domain.Journal, error) {
return []domain.Journal{r.journal}, nil
}
func (r *ledgerRepositoryStub) Balance(context.Context, domain.AccountReference, time.Time) (domain.Amount, error) {
return domain.ParseAmount("12.5")
}
func TestAppendJournalMapsProtoToExactDomainAndBack(t *testing.T) {
repository := &ledgerRepositoryStub{}
service := applicationledger.NewService(repository, func() (string, error) {
return "11111111-1111-4111-8111-111111111111", nil
})
handler := NewHandler(nil, service)
response, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{
SourceService: "wallet",
IdempotencyKey: "wallet:1:internal-transfer:v1",
SourceTransactionId: "1",
EffectKind: "internal-transfer",
EventVersion: 1,
OccurredAt: timestamppb.New(time.Unix(1, 0).UTC()),
Entries: []*ledgerv1.JournalEntry{
{
LineNumber: 1,
Account: &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: "1",
AssetId: 5,
},
Amount: "-1.2500",
},
{
LineNumber: 2,
Account: &ledgerv1.AccountReference{
AccountClass: ledgerv1.AccountClass_ACCOUNT_CLASS_USER_AVAILABLE,
OwnerType: "user",
OwnerId: "2",
AssetId: 5,
},
Amount: "1.25",
},
},
})
if err != nil {
t.Fatal(err)
}
if got := response.GetJournal().GetEntries()[0].GetAmount(); got != "-1.25" {
t.Fatalf("unexpected canonical amount: %q", got)
}
if response.GetJournal().GetPayloadHash() == "" {
t.Fatal("payload hash is missing")
}
}
func TestAppendJournalRejectsMissingTimestamp(t *testing.T) {
handler := NewHandler(nil, nil)
_, err := handler.AppendJournal(context.Background(), &ledgerv1.AppendJournalRequest{})
if status.Code(err) != codes.InvalidArgument {
t.Fatalf("expected InvalidArgument, got %v", err)
}
}
func TestRPCErrorMapping(t *testing.T) {
for _, testCase := range []struct {
err error
code codes.Code
}{
{err: applicationledger.ErrInvalidArgument, code: codes.InvalidArgument},
{err: domain.ErrNotFound, code: codes.NotFound},
{err: domain.ErrIdempotencyConflict, code: codes.AlreadyExists},
{err: domain.ErrIncompleteJournal, code: codes.FailedPrecondition},
{err: domain.ErrAlreadyReversed, code: codes.FailedPrecondition},
{err: context.Canceled, code: codes.Canceled},
{err: context.DeadlineExceeded, code: codes.DeadlineExceeded},
{err: errors.New("database details must not escape"), code: codes.Internal},
} {
if got := status.Code(rpcError(testCase.err)); got != testCase.code {
t.Fatalf("rpcError(%v) = %v, want %v", testCase.err, got, testCase.code)
}
}
}
+69
View File
@@ -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
}
+26
View File
@@ -0,0 +1,26 @@
package grpcadapter
import (
"context"
"testing"
"time"
"gl/application/health"
"google.golang.org/grpc/test/bufconn"
)
func TestRunStopsWhenContextIsCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := runWithListener(
ctx,
time.Second,
bufconn.Listen(1024),
NewHealthHandler(health.NewService(nil)),
)
if err != nil {
t.Fatal(err)
}
}
+25
View File
@@ -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

+54
View File
@@ -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());
});
})();
+318
View File
@@ -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)
}
+299
View File
@@ -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&amp;owner_id=holder-42&amp;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&amp;page=1&amp;wallet=wallet-42", "/transactions?effect=transfer&amp;page=3&amp;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)
}
}
+290
View File
@@ -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": "حساب دارنده",
}
+23
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+426
View File
@@ -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
+23
View File
@@ -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"