asset-sync-service is a backend MVP for synchronizing public account, watched-address, and observed transaction lifecycle state. It accepts observed chain events through a REST API or a fake provider sync path, applies an idempotent domain state machine, stores the result in PostgreSQL, and emits lifecycle changes through a transactional outbox with a local structured-log publisher.
| Area | Current MVP |
|---|---|
| Runtime | Kotlin, Java 21, Spring Boot 3.x, blocking Spring MVC |
| Persistence | PostgreSQL 17, Liquibase migrations, jOOQ repositories |
| API | Versioned REST API under /api/v1, Spring ProblemDetail, OpenAPI |
| Reliability | Natural keys, PostgreSQL constraints, row locks, transactional outbox, retry/backoff |
| Observability | Actuator health/readiness/metrics, structured domain logs |
| Testing | Unit tests plus Testcontainers PostgreSQL integration tests |
| External systems | Docker Compose PostgreSQL only; fake in-process chain provider |
- Account creation and lookup.
- Watched address registration and account-level address listing.
- Observed event ingestion for
local-evm. - Idempotent transaction lifecycle transitions:
SEEN,CONFIRMED, andREVERTED. - Outbox event creation for meaningful transaction state changes.
- Manual sync by watched address or account through the fake chain provider.
- Sync run inspection.
- Scheduled outbox publishing to structured logs.
- Liveness, readiness, metrics, Swagger UI, and OpenAPI JSON.
PostgreSQL is the source of truth for accounts, watched addresses, observed transactions, sync runs, and outbox events. Application services orchestrate use cases, the domain state machine evaluates lifecycle changes, and jOOQ repositories keep database-specific reliability behavior explicit.
flowchart LR
client["REST clients"] --> api["REST API<br/>Spring MVC controllers"]
scheduler["Scheduler"] --> services["Application services"]
api --> services
services --> state["Domain state machine"]
services --> providerPort["ChainProviderPort"]
providerPort --> fakeProvider["Fake chain provider"]
services --> repos["jOOQ repositories"]
repos --> db[("PostgreSQL")]
db --> outbox["Transactional outbox"]
outbox --> poller["Outbox poller<br/>FOR UPDATE SKIP LOCKED"]
poller --> publisher["Local structured log publisher"]
db --> actuator["Actuator health / metrics"]
Detailed documentation:
Swagger UI shows the generated OpenAPI surface exposed by the running service.
Actuator health captures show the service and readiness endpoints returning UP.
The outbox smoke capture shows a real observed event reaching a PUBLISHED outbox row.
When the app is running:
- Swagger UI: http://localhost:18080/swagger-ui.html
- OpenAPI JSON: http://localhost:18080/v3/api-docs
Current public endpoints:
POST /api/v1/accounts
GET /api/v1/accounts/{accountId}
POST /api/v1/accounts/{accountId}/addresses
GET /api/v1/accounts/{accountId}/addresses
POST /api/v1/observed-events
POST /api/v1/addresses/{addressId}/sync
POST /api/v1/accounts/{accountId}/sync
GET /api/v1/sync-runs/{id}
Transaction read/list endpoints are intentionally deferred and are not exposed by this MVP.
- Java 21
- Docker and Docker Compose
Gradle commands that compile the service also run generateJooq, which starts a temporary PostgreSQL container through Testcontainers. Generated jOOQ sources are written to build/generated/sources/jooq/main/kotlin and are not committed.
Start PostgreSQL on an alternate host port:
ASSET_SYNC_DB_PORT=55432 docker compose up -d postgresRun the app locally against that database in another terminal:
ASSET_SYNC_DB_PORT=55432 SERVER_PORT=18080 ./gradlew bootRunCheck readiness:
curl -s http://localhost:18080/actuator/health/readinessStop containers when done:
docker compose down -vThe commands below assume PostgreSQL is running on 55432 and the app is running on 18080 as shown in the quickstart. They use python3 only to extract JSON ids into shell variables; if you prefer no parser, run each curl, copy the returned id, and replace the variables manually.
Create an account:
ACCOUNT_JSON=$(curl -s -X POST http://localhost:18080/api/v1/accounts \
-H 'Content-Type: application/json' \
-d '{"externalRef":"customer-local-001"}')
printf '%s\n' "$ACCOUNT_JSON"
ACCOUNT_ID=$(printf '%s' "$ACCOUNT_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')Register a watched address on local-evm:
ADDRESS_JSON=$(curl -s -X POST "http://localhost:18080/api/v1/accounts/${ACCOUNT_ID}/addresses" \
-H 'Content-Type: application/json' \
-d '{
"chainId": "local-evm",
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"asset": "USDC",
"label": "primary settlement address"
}')
printf '%s\n' "$ADDRESS_JSON"
ADDRESS_ID=$(printf '%s' "$ADDRESS_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')Ingest an observed transaction event:
EVENT_JSON=$(curl -s -X POST http://localhost:18080/api/v1/observed-events \
-H 'Content-Type: application/json' \
-d '{
"chainId": "local-evm",
"txHash": "0x9f1c2d3e4f5061728394a5b6c7d8e9f00112233445566778899aabbccddeeff0",
"eventIndex": 0,
"address": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"asset": "USDC",
"amount": "12.340000000000000000",
"blockHeight": 9123456,
"confirmations": 1,
"direction": "INBOUND",
"status": "SEEN"
}')
printf '%s\n' "$EVENT_JSON"Check health, readiness, and metrics:
curl -s http://localhost:18080/actuator/health
curl -s http://localhost:18080/actuator/health/readiness
curl -s http://localhost:18080/actuator/metrics
curl -s http://localhost:18080/actuator/metrics/asset.sync.outbox.backlog.totalOptionally trigger sync with the fake provider. With no scripted fake-provider events in a normal local run, this should complete successfully with zero provider events.
SYNC_JSON=$(curl -s -X POST "http://localhost:18080/api/v1/addresses/${ADDRESS_ID}/sync")
printf '%s\n' "$SYNC_JSON"
SYNC_ID=$(printf '%s' "$SYNC_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -s "http://localhost:18080/api/v1/sync-runs/${SYNC_ID}"
curl -s -X POST "http://localhost:18080/api/v1/accounts/${ACCOUNT_ID}/sync"Stop local containers:
docker compose down -vBuild the application jar first. This keeps jOOQ generation and its Testcontainers PostgreSQL dependency on the host, while the Docker image only packages the resulting Spring Boot jar.
./gradlew clean bootJarBuild and start PostgreSQL plus the application on alternate host ports:
ASSET_SYNC_DB_PORT=55433 ASSET_SYNC_HTTP_PORT=18081 docker compose up --build -dCheck the app:
curl -s http://localhost:18081/actuator/healthStop containers:
docker compose down -vDatabase configuration for the local profile:
| Variable | Default | Purpose |
|---|---|---|
ASSET_SYNC_DB_HOST |
localhost |
PostgreSQL host |
ASSET_SYNC_DB_PORT |
5432 |
PostgreSQL port |
ASSET_SYNC_DB_NAME |
asset_sync |
Database name |
ASSET_SYNC_DB_USER |
asset_sync |
Database user |
ASSET_SYNC_DB_PASSWORD |
asset_sync |
Database password |
ASSET_SYNC_DB_MAX_POOL_SIZE |
10 |
Hikari max pool size |
ASSET_SYNC_DB_MIN_IDLE |
1 |
Hikari minimum idle connections |
Runtime configuration:
| Variable | Default | Purpose |
|---|---|---|
SERVER_PORT |
8080 |
HTTP port used by the Spring Boot app |
ASSET_SYNC_OUTBOX_BATCH_SIZE |
50 |
Due outbox rows claimed per poll |
ASSET_SYNC_OUTBOX_RETRY_BACKOFF_BASE_DELAY |
30s |
Linear retry backoff base delay |
ASSET_SYNC_OUTBOX_MAX_ERROR_LENGTH |
1024 |
Stored publisher error limit |
ASSET_SYNC_OUTBOX_SCHEDULER_ENABLED |
true |
Enables the scheduled outbox poller |
ASSET_SYNC_OUTBOX_SCHEDULER_FIXED_DELAY |
5s |
Delay between poller runs |
ASSET_SYNC_OUTBOX_SCHEDULER_INITIAL_DELAY |
10s |
Initial delay before first poll |
- Natural idempotency keys: watched addresses use
chainId + address + asset; observed transactions usechainId + txHash + eventIndex + address + asset. - PostgreSQL constraints enforce uniqueness, enum-like values, non-negative amounts/counts, and foreign keys.
- Observed transaction ingestion locks existing rows with row-level
FOR UPDATEbefore evaluating transitions. - jOOQ uses
INSERT ... ON CONFLICTfor idempotent observed-transaction and outbox writes. - Transactional outbox rows are inserted in the same database transaction as lifecycle state changes.
- The outbox poller claims due rows with
FOR UPDATE SKIP LOCKED. - Publishing is at-least-once; downstream consumers should deduplicate by event id or idempotency key.
- Failed publishes store a bounded error message and use linear retry backoff.
- Actuator endpoints:
/actuator/health,/actuator/health/liveness,/actuator/health/readiness,/actuator/info, and/actuator/metrics. - Readiness includes PostgreSQL connectivity.
- The fake provider has an Actuator health contributor.
- Structured logs include account, watched-address, transaction, sync-run, provider, and outbox identifiers.
- Micrometer meters cover observed event ingestion, transaction transitions, immutable conflicts, sync runs, provider fetches and latency, outbox batches, outbox events, and outbox backlog.
Coverage highlights:
- Unit tests for the Spring-independent domain state machine.
- Testcontainers PostgreSQL integration tests for jOOQ repositories and transactional behavior.
- Liquibase migration tests.
- API tests for controllers, DTO validation, and
ProblemDetailresponses. - Outbox retry and concurrency tests, including
FOR UPDATE SKIP LOCKED. - Observability tests for health and metrics.
- GitHub Actions CI runs Gradle checks,
bootJar,docker compose config, and a generated jOOQ tracking guard.
Verification commands:
./gradlew clean test
./gradlew clean check
./gradlew clean bootJar
docker compose config./gradlew test
./gradlew check
./gradlew bootRun
./gradlew generateJooqThis service does not provide custody, signing, private key storage, wallet functionality, or real funds movement. The MVP also does not include a real blockchain node/provider, Kafka, SQS, Redis, balance projection, auth, multitenancy, CD/deployment automation, release automation, or production metrics export.
Future extensions, not implemented in v0.1.0:
- Transaction read/list endpoints.
- Real provider integration.
- Provider cursors and block-range scans.
- External broker adapter for the outbox.
- Balance projection read models.
- Auth and multitenancy.
- CD, deployment automation, and release automation.
- Production observability export.
.
Dockerfile
docker-compose.yml
build.gradle.kts
settings.gradle.kts
docs/
src/jooqCodegen/
src/main/kotlin/com/example/assetsync/
src/main/resources/
src/test/kotlin/com/example/assetsync/
src/test/resources/


