Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
17 changes: 17 additions & 0 deletions .github/workflows/main-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: PR on master

on:
pull_request:
branches: [master]

permissions:
contents: read

jobs:
unit:
name: Unit
uses: ./.github/workflows/test-go.yml

integration:
name: Integration
uses: ./.github/workflows/test-integration.yml
17 changes: 17 additions & 0 deletions .github/workflows/main-push.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Push on master

on:
push:
branches: [master]

permissions:
contents: read

jobs:
unit:
name: Unit
uses: ./.github/workflows/test-go.yml

integration:
name: Integration
uses: ./.github/workflows/test-integration.yml
25 changes: 25 additions & 0 deletions .github/workflows/test-go.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Test (Unit)

# Reusable: runs the unit suite (go test -race ./...). Called by the per-event
# workflows (PR, push to master).
on:
workflow_call:

permissions:
contents: read

jobs:
unit:
name: Unit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Test
run: make test
35 changes: 35 additions & 0 deletions .github/workflows/test-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Test (Integration)

# Reusable: brings up the devnet (anvil + bitcoind + rippled + solana) and runs
# the integration suite against it. Called by the per-event workflows.
on:
workflow_call:

permissions:
contents: read

jobs:
integration:
name: Integration
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6

# setup-go precedes `make devnet`: the readiness gate (devnet/wait) runs
# via `go run`, and the integration tests need the toolchain too.
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Bring up devnet
run: make devnet

- name: Integration tests
run: make integration

- name: Tear down devnet
if: always()
run: make devnet-down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Stray `go build` output for the refresher command
/abi_refresher

# Go workspace files
go.work
go.work.sum
32 changes: 32 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
.PHONY: build lint test generate devnet devnet-down integration

build:
go build ./...

lint:
go vet ./...

test:
go test -race ./...

# Regenerate all generated code:
# - pkg/blockchain/evm/*_abi.go from the vendored ABI/bytecode in
# pkg/blockchain/evm/artifacts (see that dir's README to refresh them)
# - pkg/core/cbor_gen.go from pkg/core/gen
# Requires go-ethereum's abigen logic (vendored via the module — no external
# binary needed); commit the result.
generate:
go generate ./...

# Bring the local devnet up and block until every node answers RPC.
devnet:
docker compose -f devnet/docker-compose.yml up -d
go run ./devnet/wait

devnet-down:
docker compose -f devnet/docker-compose.yml down -v

# Build-tagged blockchain flow tests (deposit + withdrawal per chain). Every
# test self-provisions against the devnet — no setup, no env. See devnet/README.md.
integration:
go test -tags integration ./pkg/blockchain/... -v
65 changes: 65 additions & 0 deletions devnet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Devnet & blockchain integration tests

Full **deposit** and **withdrawal** flows per chain, exercised end-to-end
against real nodes. The tests are build-tagged `integration` and live next to
each adapter (`pkg/blockchain/<chain>/vault_integration_test.go`). Each
withdrawal test runs the whole *k-of-n* quorum in-process — it holds N local
`sign.KeySigner`s and drives `Pack → Validate → Sign → Merge → Submit →
VerifyExecution` itself, so no p2p mesh is needed.

## Run

```sh
make devnet # anvil + bitcoind + rippled + solana-test-validator; blocks until all answer RPC
make integration # go test -tags integration ./pkg/blockchain/...
make devnet-down
```

`make devnet` returns only once every node answers (the `devnet/wait` probe).
`make integration` then needs **no setup and no env** — every test
self-provisions against the devnet and is **idempotent**: each run uses fresh
keys / accounts / a freshly-deployed contract, so re-running is a clean run.
Only each node's funder persists (anvil account 0, the bitcoind coinbase
wallet, the XRPL genesis master).

## What each test provisions

- **EVM** — deploys a fresh `Custody` vault over N freshly-generated signer
keys (funded from anvil account 0), deposits native ETH, then runs the quorum
withdrawal.
- **BTC** — creates a legacy wallet, mines to maturity, generates a fresh vault
+ depositor, watch-imports their addresses, funds the depositor, deposits to
the per-account P2WSH address, then runs the quorum withdrawal (mining to
confirm between steps).
- **XRPL** — funds a fresh vault + depositor from the genesis master,
`SignerListSet`s the vault over fresh signer keys, `TicketCreate`s a ticket,
then deposits and runs the quorum withdrawal. Standalone rippled does not
auto-close ledgers, so the test calls `ledger_accept` after each submit.
- **Solana** — the validator preloads the custody program **upgradeable** at its
fixed id (`--upgradeable-program`), upgrade authority = the vendored
`devnet/sol-upgrade-authority.json`. The test airdrop-funds the authority +
depositor, `Initialize`s the Config once (idempotent; gated on the upgrade
authority), deposits native SOL, then runs the quorum withdrawal. The Config
PDA is a singleton, so the signer set is **fixed** across runs and only the
withdrawalID is fresh — re-runs stay clean without a validator restart. The
validator image is multi-arch (no `platform:` pin — the Agave validator needs
AVX, which isn't emulable on Apple silicon).

## Optional overrides

Defaults target the devnet; override the endpoints if pointing elsewhere:

| env | default |
|---|---|
| `EVM_RPC_URL` / `EVM_DEPLOYER_KEY` | `http://127.0.0.1:8545` / anvil account 0 |
| `BTC_RPC_URL` / `BTC_RPC_USER` / `BTC_RPC_PASS` | `http://127.0.0.1:18443` / `sdk` / `sdk` |
| `XRPL_RPC_URL` | `http://127.0.0.1:5005` |

## Notes

- The tests double as the **executable spec** for the adapter interfaces: the
withdrawal flow shows exactly how a custody node calls the SDK
(`Pack`/`Validate`/`Sign`/`Merge`/`Submit`/`VerifyExecution`); the only piece
left to the caller is collecting the quorum's signatures over its mesh.
- The rippled image is `linux/amd64` (pinned via `platform:`); on Apple
silicon it runs under emulation — works, just slower to start.
68 changes: 68 additions & 0 deletions devnet/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Local devnet for the blockchain integration tests: one node per supported
# chain. Bring up with `make devnet` (or `docker compose -f devnet/docker-compose.yml up -d`).
#
# anvil — EVM JSON-RPC at :8545 (the EVM test self-deploys Custody here)
# bitcoind — regtest JSON-RPC at :18443 (user/pass: sdk/sdk)
# rippled — standalone JSON-RPC at :5005
#
# No per-chain setup needed: every integration test self-provisions against
# these nodes (fresh keys/accounts/contract each run). See devnet/README.md.
services:
anvil:
image: ghcr.io/foundry-rs/foundry:latest@sha256:8347b728d5d393dac1c018691b36f506d23b9dcd78341d40ea0fcb11c3a19cdd
entrypoint: ["anvil", "--host", "0.0.0.0", "--chain-id", "31337"]
ports:
- "8545:8545"

bitcoind:
image: ruimarinho/bitcoin-core:24@sha256:79dd32455cf8c268c63e5d0114cc9882a8857e942b1d17a6b8ec40a6d44e3981
command:
- -regtest=1
- -server=1
- -rpcbind=0.0.0.0
- -rpcallowip=0.0.0.0/0
- -rpcuser=sdk
- -rpcpassword=sdk
- -fallbackfee=0.0002
- -txindex=1
ports:
- "18443:18443"

# Solana validator with the custody program preloaded upgradeable at its fixed
# id, upgrade authority = the vendored devnet key (so the test's Initialize,
# gated on the upgrade authority, succeeds). NO platform pin: the Agave
# validator needs AVX (not emulable on Apple silicon); beeman's image is
# multi-arch and pulls the native arm64 build.
solana:
image: beeman/solana-test-validator:3.1.14@sha256:92350b9cda62938aaca12b63ca81d5db217f6a96ff415215e0346904957cd5db
# The Agave validator uses io_uring; Docker Desktop's default seccomp
# profile blocks io_uring_setup (EPERM) even though the VM kernel supports
# it, so the validator aborts. Allow it.
# TODO: replace `unconfined` with a scoped profile (Docker default +
# io_uring_setup/enter/register) committed at devnet/seccomp.json, so we
# don't drop all syscall filtering for the validator container.
security_opt:
- seccomp=unconfined
command:
- solana-test-validator
- --reset
- --quiet
- --upgradeable-program
- "98eVpih8X9CAcgU9bzNB9V7VtkRrnFZUmqzEnsq7cfmg"
- /artifacts/custody.so
- "FdnkxpbVrjX9LGTXhQv5z1wkKc1sppJEKLaaVhQc2Z1f"
volumes:
- ../pkg/blockchain/sol/artifacts/custody.so:/artifacts/custody.so:ro
ports:
- "8899:8899"
- "8900:8900"

rippled:
image: xrpllabsofficial/xrpld:latest@sha256:5bed71463491c098bdb7f057a95c5a77bb71c36ed6b5009ff8397ce076d0e22e
platform: linux/amd64
command: ["-a", "--start"]
volumes:
- ./rippled.cfg:/opt/ripple/etc/rippled.cfg:ro
ports:
- "5005:5005"
- "6006:6006"
49 changes: 49 additions & 0 deletions devnet/rippled.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
[server]
port_rpc
port_ws
port_peer

[port_rpc]
port = 5005
ip = 0.0.0.0
admin = 0.0.0.0
protocol = http

[port_ws]
port = 6006
ip = 0.0.0.0
admin = 0.0.0.0
protocol = ws

[port_peer]
port = 51235
ip = 0.0.0.0
protocol = peer

[node_size]
tiny

[node_db]
type=NuDB
path=/var/lib/rippled/db/nudb

[database_path]
/var/lib/rippled/db

[debug_logfile]
/var/log/rippled/debug.log

[sntp_servers]
time.windows.com
time.apple.com
time.nist.gov
pool.ntp.org

[validators_file]
validators.txt

[rpc_startup]
{ "command": "log_level", "severity": "warning" }

[ssl_verify]
0
1 change: 1 addition & 0 deletions devnet/sol-upgrade-authority.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[203,153,124,228,11,213,175,145,240,141,1,21,109,98,159,137,119,169,88,42,214,87,227,7,120,38,81,127,84,163,237,228,217,112,67,201,246,128,40,98,207,41,196,91,232,32,202,225,23,4,219,235,110,238,181,69,63,176,155,85,188,247,224,94]
Loading
Loading