From 71ea9d997c211d2a692320d8086fb1dd4830b0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E7=AD=92?= Date: Sun, 23 Aug 2026 10:49:47 +0800 Subject: [PATCH] feat: add Docker-native automatic window keeper --- .env.example | 13 ++++ README.md | 7 ++ docker-compose.prod.yml | 30 +++++++++ docker-compose.yml | 32 +++++++++ keeper/README.md | 29 ++++++++- keeper/auto-window.sh | 141 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 keeper/auto-window.sh diff --git a/.env.example b/.env.example index 38f6f71..9ca27e5 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ ANVIL_PORT=8545 ANVIL_CHAIN_ID=31337 ANVIL_ACCOUNTS=10 ANVIL_BALANCE=10000 +ANVIL_BLOCK_TIME=2 # Development only - DO NOT use in production ANVIL_MNEMONIC="test test test test test test test test test test test junk" @@ -31,3 +32,15 @@ MOCK_BOND_ADDRESS= ANVIL_CONTAINER_NAME=nettedx-anvil DEPLOYER_CONTAINER_NAME=nettedx-deployer +KEEPER_CONTAINER_NAME=nettedx-keeper + +# ========================= +# Automatic Window Keeper +# ========================= + +# NETTING_ADDRESS may be left empty when Docker Compose is used. The keeper +# reads the latest deployed address from address-data/addresses.json. +NETTING_ADDRESS= +KEEPER_POLL_SECONDS=1 +KEEPER_RETRY_SECONDS=3 +KEEPER_MAX_CATCH_UP_ACTIONS=20 diff --git a/README.md b/README.md index 530b12c..8f427e5 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,13 @@ tokens. docker-compose up --build ``` +This also starts the automatic window keeper. Anvil mines at the configured +`ANVIL_BLOCK_TIME` interval, and the keeper calls `freezeWindow()` and +`executeWindow()` as each settlement window becomes ready. The keeper uses the +`cast` binary already included in the blockchain image; no separate language +runtime is required. See [`keeper/README.md`](keeper/README.md) for operation +and configuration details. + ## Available Commands ### Format diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 35b43a7..28e8f14 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,6 +20,8 @@ services: - ${ANVIL_BALANCE} - --mnemonic - ${ANVIL_MNEMONIC} + - --block-time + - ${ANVIL_BLOCK_TIME:-2} # 自动加载/保存区块链状态 - --state @@ -76,3 +78,31 @@ services: volumes: - ./broadcast:/app/broadcast - ./address-data:/app/address-data + + keeper: + image: ${IMAGE:-ghcr.io/nettedx/nettedx-blockchain:latest} + + container_name: ${KEEPER_CONTAINER_NAME:-nettedx-keeper} + + depends_on: + deployer: + condition: service_completed_successfully + + restart: unless-stopped + + environment: + RPC_URL: ${RPC_URL} + PRIVATE_KEY: ${PRIVATE_KEY} + NETTING_ADDRESS: ${NETTING_ADDRESS:-} + POLL_SECONDS: ${KEEPER_POLL_SECONDS:-1} + RETRY_SECONDS: ${KEEPER_RETRY_SECONDS:-3} + MAX_CATCH_UP_ACTIONS: ${KEEPER_MAX_CATCH_UP_ACTIONS:-20} + + entrypoint: + - /bin/sh + + command: + - /app/keeper/auto-window.sh + + volumes: + - ./address-data:/app/address-data:ro diff --git a/docker-compose.yml b/docker-compose.yml index 3d3d102..3d356b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: - ${ANVIL_BALANCE} - --mnemonic - ${ANVIL_MNEMONIC} + - --block-time + - ${ANVIL_BLOCK_TIME:-2} # 自动加载/保存区块链状态 - --state @@ -77,3 +79,33 @@ services: volumes: - ./broadcast:/app/broadcast - ./address-data:/app/address-data + + keeper: + build: + context: . + dockerfile: Dockerfile + + container_name: ${KEEPER_CONTAINER_NAME:-nettedx-keeper} + + depends_on: + deployer: + condition: service_completed_successfully + + restart: unless-stopped + + environment: + RPC_URL: ${RPC_URL} + PRIVATE_KEY: ${PRIVATE_KEY} + NETTING_ADDRESS: ${NETTING_ADDRESS:-} + POLL_SECONDS: ${KEEPER_POLL_SECONDS:-1} + RETRY_SECONDS: ${KEEPER_RETRY_SECONDS:-3} + MAX_CATCH_UP_ACTIONS: ${KEEPER_MAX_CATCH_UP_ACTIONS:-20} + + entrypoint: + - /bin/sh + + command: + - /app/keeper/auto-window.sh + + volumes: + - ./address-data:/app/address-data:ro diff --git a/keeper/README.md b/keeper/README.md index 17d22cd..749fb99 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -1,6 +1,8 @@ # NettedX Automatic Window Operator -This operator removes the need to manually freeze and settle every window. +This operator removes the need to manually freeze and settle every window. The +Docker implementation uses POSIX shell and Foundry `cast`, so it runs directly +in the existing blockchain image without Python, Java, or PowerShell. ## Timeline @@ -11,7 +13,30 @@ This operator removes the need to manually freeze and settle every window. New trading windows start every 10 blocks. Each window still settles on its own 14th block. -## Run +## Run with Docker Compose + +The `keeper` service starts automatically after a successful contract +deployment. Anvil mines a block every `ANVIL_BLOCK_TIME` seconds, even when no +transactions arrive, so settlement windows continue advancing. + +```sh +cp .env.example .env +docker compose up --build -d +docker compose logs -f keeper +``` + +The keeper reads the Netting address generated by the deployer from +`address-data/addresses.json`. Set `NETTING_ADDRESS` only when you need to +override that address. + +It checks the on-chain `automationState()` view, sends only due owner actions, +waits for each transaction to be confirmed, and catches up after a restart or +RPC interruption. + +Optional settings are `KEEPER_POLL_SECONDS`, `KEEPER_RETRY_SECONDS`, and +`KEEPER_MAX_CATCH_UP_ACTIONS`. + +## Run outside Docker (PowerShell legacy version) Start Anvil with interval mining so blocks continue even when no user sends a transaction: diff --git a/keeper/auto-window.sh b/keeper/auto-window.sh new file mode 100644 index 0000000..fe56680 --- /dev/null +++ b/keeper/auto-window.sh @@ -0,0 +1,141 @@ +#!/bin/sh + +# NettedX window operator. It intentionally uses only POSIX shell and Foundry's +# cast binary, both of which are already present in the blockchain image. + +set -u + +RPC_URL=${RPC_URL:-http://anvil:8545} +NETTING_ADDRESS=${NETTING_ADDRESS:-} +NETTING_ADDRESS_FILE=${NETTING_ADDRESS_FILE:-/app/address-data/addresses.json} +PRIVATE_KEY=${PRIVATE_KEY:-} +POLL_SECONDS=${POLL_SECONDS:-1} +RETRY_SECONDS=${RETRY_SECONDS:-3} +MAX_CATCH_UP_ACTIONS=${MAX_CATCH_UP_ACTIONS:-20} + +timestamp() { + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +log() { + printf '%s %s\n' "$(timestamp)" "$*" +} + +fatal() { + log "ERROR: $*" >&2 + exit 2 +} + +is_positive_integer() { + case "$1" in + ''|*[!0-9]*|0) return 1 ;; + *) return 0 ;; + esac +} + +read_netting_address() { + if [ -n "$NETTING_ADDRESS" ]; then + return 0 + fi + + [ -r "$NETTING_ADDRESS_FILE" ] || return 1 + + NETTING_ADDRESS=$(sed -n \ + 's/.*"NETTEDX_NETTING_CONTRACT_ADDRESS"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + "$NETTING_ADDRESS_FILE" | sed -n '1p') + + [ -n "$NETTING_ADDRESS" ] +} + +send_action() { + action=$1 + output=$(cast send "$NETTING_ADDRESS" "$action()" \ + --private-key "$PRIVATE_KEY" \ + --rpc-url "$RPC_URL" \ + --json 2>&1) + status=$? + + if [ "$status" -ne 0 ]; then + log "WARN: $action failed: $output" >&2 + return 1 + fi + + log "$action confirmed" +} + +process_due_actions() { + action_count=0 + + while [ "$action_count" -lt "$MAX_CATCH_UP_ACTIONS" ]; do + state=$(cast call "$NETTING_ADDRESS" \ + 'automationState()(bool,bool)' \ + --rpc-url "$RPC_URL" 2>&1) + status=$? + + if [ "$status" -ne 0 ]; then + log "WARN: automationState call failed: $state" >&2 + return 1 + fi + + # cast prints tuple values separated by whitespace/newlines. + set -- $state + freeze_needed=${1:-} + settlement_needed=${2:-} + + case "$freeze_needed:$settlement_needed" in + true:true|true:false|false:true|false:false) ;; + *) + log "WARN: could not decode automationState response: $state" >&2 + return 1 + ;; + esac + + if [ "$freeze_needed" = true ]; then + send_action freezeWindow || return 1 + action_count=$((action_count + 1)) + continue + fi + + if [ "$settlement_needed" = true ]; then + send_action executeWindow || return 1 + action_count=$((action_count + 1)) + continue + fi + + return 0 + done + + log "WARN: reached MAX_CATCH_UP_ACTIONS=$MAX_CATCH_UP_ACTIONS; catch-up will continue next poll" >&2 +} + +command -v cast >/dev/null 2>&1 || fatal 'cast was not found in PATH' +[ -n "$PRIVATE_KEY" ] || fatal 'PRIVATE_KEY is required' +is_positive_integer "$POLL_SECONDS" || fatal 'POLL_SECONDS must be a positive integer' +is_positive_integer "$RETRY_SECONDS" || fatal 'RETRY_SECONDS must be a positive integer' +is_positive_integer "$MAX_CATCH_UP_ACTIONS" || fatal 'MAX_CATCH_UP_ACTIONS must be a positive integer' + +if ! read_netting_address; then + fatal "NETTING_ADDRESS is unset and no address was found in $NETTING_ADDRESS_FILE" +fi + +signer=$(cast wallet address --private-key "$PRIVATE_KEY" 2>&1) +status=$? +[ "$status" -eq 0 ] || fatal "PRIVATE_KEY is invalid: $signer" + +owner=$(cast call "$NETTING_ADDRESS" 'owner()(address)' --rpc-url "$RPC_URL" 2>&1) +status=$? +[ "$status" -eq 0 ] || fatal "cannot read Netting owner: $owner" + +signer_lower=$(printf '%s' "$signer" | tr '[:upper:]' '[:lower:]') +owner_lower=$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]') +[ "$signer_lower" = "$owner_lower" ] || fatal "configured signer $signer is not Netting owner $owner" + +log "NettedX keeper started; rpc=$RPC_URL netting=$NETTING_ADDRESS signer=$signer" + +while :; do + if process_due_actions; then + sleep "$POLL_SECONDS" + else + sleep "$RETRY_SECONDS" + fi +done