Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ services:
- ${ANVIL_BALANCE}
- --mnemonic
- ${ANVIL_MNEMONIC}
- --block-time
- ${ANVIL_BLOCK_TIME:-2}

# 自动加载/保存区块链状态
- --state
Expand Down Expand Up @@ -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
32 changes: 32 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ services:
- ${ANVIL_BALANCE}
- --mnemonic
- ${ANVIL_MNEMONIC}
- --block-time
- ${ANVIL_BLOCK_TIME:-2}

# 自动加载/保存区块链状态
- --state
Expand Down Expand Up @@ -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
29 changes: 27 additions & 2 deletions keeper/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:

Expand Down
141 changes: 141 additions & 0 deletions keeper/auto-window.sh
Original file line number Diff line number Diff line change
@@ -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
Loading