Skip to content

Commit 7a8a821

Browse files
committed
test(smoke): flow-12 runnable on macOS + against override facilitator images
Validates the x402-rs upstream-sync candidate (v1.5.6 overlay) locally: flow-12 OBOL Permit2 sell->buy->settle PASSES against a locally built ghcr.io/obolnetwork/x402-facilitator-prometheus-overlay:1.5.6-local (3 settlements status=0x1, buyer/seller deltas exact). - testutil: honor X402_FACILITATOR_IMAGE (same env as flows/lib.sh) and use a locally-present image without pulling. - testutil: Darwin container networking — Docker Desktop host networking does not share the Mac loopback; publish the facilitator port and reach Anvil via host.docker.internal (mirrors lib.sh). - testutil: ApprovePermit2ViaImpersonation — the one-time approve(Permit2, max) a real owner does per token. The fork token is not the registry's canonical OBOL address, so the 402 never advertises eip2612GasSponsoring (anti-spoof) and buy.py's allowance preflight correctly refuses; earlier green runs only skipped that preflight when the allowance read raced eRPC pin propagation. - test: retry transport-level errors on the first paid call (controller rolls LiteLLM to publish the paid route; Service can briefly hit a terminating pod). - test: PurchaseRequest sweep deletes BEFORE stripping finalizers (the controller re-adds finalizers on live PRs; deleting purchases with unspent auths intentionally drain) and waits until gone. - flow-12: go test -count=1 — prerequisites live outside the build graph, a cached verdict is a stale verdict.
1 parent b76098e commit 7a8a821

4 files changed

Lines changed: 130 additions & 27 deletions

File tree

flows/flow-12-obol-payment.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ ARTIFACT_DIR="${FLOW12_ARTIFACT_DIR:-$OBOL_ROOT/.tmp/flow-12-$(date +%Y%m%d-%H%M
6363
mkdir -p "$ARTIFACT_DIR"
6464
LOG="$ARTIFACT_DIR/test-output.log"
6565
set +e
66-
go test -tags integration -v \
66+
# -count=1 forbids the Go test cache: this test's prerequisites (Ollama
67+
# models, cluster state, facilitator image) live outside the build graph, so
68+
# a cached result silently replays a stale verdict.
69+
go test -tags integration -count=1 -v \
6770
-run '^TestIntegration_SellBuySidecar_OBOLPermit2$' \
6871
-timeout "${FLOW12_TIMEOUT:-30m}" \
6972
./internal/openclaw/ 2>&1 | tee "$LOG"

internal/openclaw/monetize_integration_test.go

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,29 @@ func cleanupPurchaseRequestsForTest(t *testing.T, cfg *config.Config) {
7070
t.Helper()
7171
namespace := agentNamespace(cfg)
7272

73+
// Delete FIRST, then strip finalizers. The controller re-adds its
74+
// finalizer to any live PurchaseRequest, so clearing before delete is a
75+
// no-op race; and a deleting purchase with unspent auths intentionally
76+
// drains (stays Terminating) — the finalizer strip is the test's
77+
// force-path past that drain.
78+
_, _ = obolRunErr(cfg, "kubectl", "delete", "purchaserequests.obol.org",
79+
"-n", namespace, "--all", "--ignore-not-found", "--wait=false")
7380
if out, err := obolRunErr(cfg, "kubectl", "get", "purchaserequests.obol.org",
7481
"-n", namespace, "-o", "name"); err == nil {
7582
for _, name := range strings.Fields(out) {
7683
_, _ = obolRunErr(cfg, "kubectl", "patch", name,
7784
"-n", namespace, "--type=merge", "-p", `{"metadata":{"finalizers":[]}}`)
7885
}
7986
}
80-
_, _ = obolRunErr(cfg, "kubectl", "delete", "purchaserequests.obol.org",
81-
"-n", namespace, "--all", "--ignore-not-found", "--wait=false")
87+
for i := 0; i < 12; i++ {
88+
out, err := obolRunErr(cfg, "kubectl", "get", "purchaserequests.obol.org",
89+
"-n", namespace, "-o", "name")
90+
if err == nil && strings.TrimSpace(out) == "" {
91+
return
92+
}
93+
time.Sleep(5 * time.Second)
94+
}
95+
t.Logf("warning: PurchaseRequests still present in %s after cleanup", namespace)
8296
}
8397

8498
// getServiceOffer returns the ServiceOffer as a parsed JSON map.
@@ -1101,18 +1115,30 @@ req = urllib.request.Request(
11011115
},
11021116
)
11031117
1104-
try:
1105-
with urllib.request.urlopen(req, timeout=180) as resp:
1118+
# Transport-level errors (connection refused) are retried: the controller
1119+
# may roll the LiteLLM deployment to publish the paid/<model> route right
1120+
# before the first paid call, and the Service can briefly point at a
1121+
# terminating pod. HTTP errors are NOT retried — their status codes are
1122+
# what the test asserts on.
1123+
import time
1124+
for attempt in range(12):
1125+
try:
1126+
with urllib.request.urlopen(req, timeout=180) as resp:
1127+
sys.stdout.write(json.dumps({
1128+
"status": resp.status,
1129+
"body": resp.read().decode(),
1130+
}))
1131+
break
1132+
except urllib.error.HTTPError as err:
11061133
sys.stdout.write(json.dumps({
1107-
"status": resp.status,
1108-
"body": resp.read().decode(),
1134+
"status": err.code,
1135+
"body": err.read().decode(),
11091136
}))
1110-
except urllib.error.HTTPError as err:
1111-
sys.stdout.write(json.dumps({
1112-
"status": err.code,
1113-
"body": err.read().decode(),
1114-
}))
1115-
sys.exit(1)
1137+
sys.exit(1)
1138+
except urllib.error.URLError:
1139+
if attempt == 11:
1140+
raise
1141+
time.sleep(5)
11161142
`, model, prompt, "http://litellm.llm.svc.cluster.local:4000/v1/chat/completions", "Bearer "+masterKey)
11171143

11181144
out, err := execInAgentErr(cfg, "python3", "-c", script)
@@ -3886,6 +3912,13 @@ func TestIntegration_SellBuySidecar_OBOLPermit2(t *testing.T) {
38863912
}
38873913
anvil.FundETH(t, agentWallet, big.NewInt(1e18))
38883914
anvil.MintMintableERC20(t, obolToken, anvil.Accounts[0].PrivateKey, agentWallet, new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)))
3915+
// One-time approve(Permit2, max) the buyer wallet owner does on a real
3916+
// chain. The fork token is not the registry's canonical OBOL address, so
3917+
// the 402 never advertises eip2612GasSponsoring (anti-spoof check in
3918+
// internal/x402/chains.go) and buy.py's allowance preflight requires a
3919+
// real allowance. Earlier green runs only skipped that preflight when
3920+
// the allowance read raced eRPC pin propagation and missed the fork.
3921+
anvil.ApprovePermit2ViaImpersonation(t, obolToken, agentWallet)
38893922
t.Logf("funded agent wallet %s with 10 OBOL on fork token %s", agentWallet, obolToken)
38903923

38913924
originalERPCConfig := getERPCConfigYAML(t, cfg)

internal/testutil/anvil.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,37 @@ func (f *AnvilFork) FundETH(t *testing.T, addr string, amount *big.Int) {
227227
t.Logf("funded %s with %s wei", addr, amount)
228228
}
229229

230+
// ApprovePermit2ViaImpersonation performs the one-time approve(Permit2, max)
231+
// from owner on token via anvil_impersonateAccount — the fork-test stand-in
232+
// for the on-chain approval a real wallet owner does once per token. Without
233+
// it buy.py's Permit2 allowance preflight (correctly) refuses to pre-sign.
234+
func (f *AnvilFork) ApprovePermit2ViaImpersonation(t *testing.T, token, owner string) {
235+
t.Helper()
236+
237+
const permit2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
238+
// approve(address,uint256) selector + permit2 + max uint256.
239+
data := "0x095ea7b3" +
240+
"000000000000000000000000" + strings.ToLower(strings.TrimPrefix(permit2, "0x")) +
241+
strings.Repeat("f", 64)
242+
243+
for _, call := range []string{
244+
fmt.Sprintf(`{"jsonrpc":"2.0","method":"anvil_impersonateAccount","params":["%s"],"id":1}`, owner),
245+
fmt.Sprintf(`{"jsonrpc":"2.0","method":"eth_sendTransaction","params":[{"from":"%s","to":"%s","data":"%s"}],"id":1}`, owner, token, data),
246+
fmt.Sprintf(`{"jsonrpc":"2.0","method":"anvil_stopImpersonatingAccount","params":["%s"],"id":1}`, owner),
247+
} {
248+
resp, err := http.Post(f.RPCURL, "application/json", strings.NewReader(call))
249+
if err != nil {
250+
t.Fatalf("approve Permit2 via impersonation: %v", err)
251+
}
252+
raw, _ := io.ReadAll(resp.Body)
253+
resp.Body.Close()
254+
if strings.Contains(string(raw), `"error"`) {
255+
t.Fatalf("approve Permit2 via impersonation: %s", raw)
256+
}
257+
}
258+
t.Logf("approved Permit2 for %s on token %s (impersonated)", owner, token)
259+
}
260+
230261
// ClearCode removes contract code from an address on Anvil.
231262
// Required for deterministic Anvil accounts that have proxy contracts on Base Sepolia —
232263
// USDC's SignatureChecker sees code → tries EIP-1271 instead of ecrecover.

internal/testutil/facilitator_real.go

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,24 @@ import (
99
"net/http"
1010
"os"
1111
"os/exec"
12+
"runtime"
1213
"strconv"
1314
"testing"
1415
"time"
1516
)
1617

17-
const x402FacilitatorImage = "ghcr.io/obolnetwork/x402-facilitator-prometheus-overlay:1.4.9"
18+
const defaultX402FacilitatorImage = "ghcr.io/obolnetwork/x402-facilitator-prometheus-overlay:1.4.9"
19+
20+
// x402FacilitatorImage resolves the facilitator image, honoring the same
21+
// X402_FACILITATOR_IMAGE override the shell flows use (flows/lib.sh) so a
22+
// locally-built facilitator (e.g. an upstream-sync candidate) can be smoked
23+
// through the Go integration path without editing the pin.
24+
func x402FacilitatorImage() string {
25+
if img := os.Getenv("X402_FACILITATOR_IMAGE"); img != "" {
26+
return img
27+
}
28+
return defaultX402FacilitatorImage
29+
}
1830

1931
// RealFacilitator wraps a running x402-rs facilitator process.
2032
// Unlike MockFacilitator, this validates real EIP-712 signatures against
@@ -54,24 +66,40 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa
5466
port := l.Addr().(*net.TCPAddr).Port
5567
l.Close()
5668

57-
// The facilitator runs on the host, so it needs the localhost Anvil URL
58-
// (not host.docker.internal which only resolves inside Docker/k3d).
59-
anvilLocalURL := fmt.Sprintf("http://127.0.0.1:%d", anvil.Port)
69+
// The facilitator runs in a Docker container. On Linux it gets
70+
// `--network host`, so the host loopback works for the Anvil URL. On
71+
// macOS, Docker Desktop's host networking does not share the Mac
72+
// loopback — mirror flows/lib.sh::start_x402_facilitator_container:
73+
// publish the port with -p and reach Anvil via host.docker.internal.
74+
anvilHost := "127.0.0.1"
75+
if runtime.GOOS == "darwin" {
76+
anvilHost = "host.docker.internal"
77+
}
78+
anvilLocalURL := fmt.Sprintf("http://%s:%d", anvilHost, anvil.Port)
6079

6180
// Generate config file.
6281
configPath := writeRealFacilitatorConfig(t, port, anvilLocalURL, anvil.Accounts[0].PrivateKey, opts)
6382

6483
ctx, cancel := context.WithCancel(context.Background())
6584
containerName := fmt.Sprintf("obol-test-x402-facilitator-%d", time.Now().UnixNano())
6685

67-
cmd := exec.CommandContext(ctx,
68-
"docker", "run", "--rm",
86+
// Linux: host networking, the facilitator binds the host port directly.
87+
// macOS: publish the port instead — Docker Desktop's host networking
88+
// does not share the Mac loopback (same split as flows/lib.sh).
89+
netArgs := []string{"--network", "host"}
90+
if runtime.GOOS == "darwin" {
91+
netArgs = []string{"-p", fmt.Sprintf("%d:%d", port, port)}
92+
}
93+
args := append([]string{
94+
"run", "--rm",
6995
"--name", containerName,
70-
"--network", "host",
96+
}, netArgs...)
97+
args = append(args,
7198
"-v", configPath+":/config.json:ro",
72-
x402FacilitatorImage,
99+
x402FacilitatorImage(),
73100
"--config", "/config.json",
74101
)
102+
cmd := exec.CommandContext(ctx, "docker", args...)
75103

76104
var stderr bytes.Buffer
77105

@@ -110,21 +138,29 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa
110138
return rf
111139
}
112140

113-
// requireFacilitatorImage verifies the pinned facilitator image is available.
141+
// requireFacilitatorImage verifies the facilitator image is available.
114142
// Local facilitator experiments should be packaged as a Docker image instead of
115-
// depending on host checkout paths.
143+
// depending on host checkout paths: build + tag the image, then point
144+
// X402_FACILITATOR_IMAGE at it. An image already present locally is used as-is
145+
// (never pulled), mirroring flows/lib.sh::docker_pull_public_image.
116146
func requireFacilitatorImage(t *testing.T) {
117147
t.Helper()
118148

149+
image := x402FacilitatorImage()
119150
if _, err := exec.LookPath("docker"); err != nil {
120-
t.Fatalf("docker not installed; cannot run %s", x402FacilitatorImage)
151+
t.Fatalf("docker not installed; cannot run %s", image)
152+
}
153+
154+
if err := exec.Command("docker", "image", "inspect", image).Run(); err == nil {
155+
t.Logf("using local x402 facilitator image %s", image)
156+
return
121157
}
122158

123-
pull := exec.Command("docker", "pull", x402FacilitatorImage)
159+
pull := exec.Command("docker", "pull", image)
124160
if out, err := pull.CombinedOutput(); err != nil {
125-
t.Fatalf("pull %s: %v\n%s", x402FacilitatorImage, err, out)
161+
t.Fatalf("pull %s: %v\n%s", image, err, out)
126162
}
127-
t.Logf("using x402 facilitator image %s", x402FacilitatorImage)
163+
t.Logf("using x402 facilitator image %s", image)
128164
}
129165

130166
// writeRealFacilitatorConfig writes a temporary config-test.json for the facilitator.

0 commit comments

Comments
 (0)