From 7a8a82176cae146a073160fffe8b699f12b83b4b Mon Sep 17 00:00:00 2001 From: bussyjd <145845+bussyjd@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:45:37 +0400 Subject: [PATCH] test(smoke): flow-12 runnable on macOS + against override facilitator images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- flows/flow-12-obol-payment.sh | 5 +- .../openclaw/monetize_integration_test.go | 57 +++++++++++++---- internal/testutil/anvil.go | 31 +++++++++ internal/testutil/facilitator_real.go | 64 +++++++++++++++---- 4 files changed, 130 insertions(+), 27 deletions(-) diff --git a/flows/flow-12-obol-payment.sh b/flows/flow-12-obol-payment.sh index 9b9af25f..3a2a8d77 100755 --- a/flows/flow-12-obol-payment.sh +++ b/flows/flow-12-obol-payment.sh @@ -63,7 +63,10 @@ ARTIFACT_DIR="${FLOW12_ARTIFACT_DIR:-$OBOL_ROOT/.tmp/flow-12-$(date +%Y%m%d-%H%M mkdir -p "$ARTIFACT_DIR" LOG="$ARTIFACT_DIR/test-output.log" set +e -go test -tags integration -v \ +# -count=1 forbids the Go test cache: this test's prerequisites (Ollama +# models, cluster state, facilitator image) live outside the build graph, so +# a cached result silently replays a stale verdict. +go test -tags integration -count=1 -v \ -run '^TestIntegration_SellBuySidecar_OBOLPermit2$' \ -timeout "${FLOW12_TIMEOUT:-30m}" \ ./internal/openclaw/ 2>&1 | tee "$LOG" diff --git a/internal/openclaw/monetize_integration_test.go b/internal/openclaw/monetize_integration_test.go index 5133582f..22538818 100644 --- a/internal/openclaw/monetize_integration_test.go +++ b/internal/openclaw/monetize_integration_test.go @@ -70,6 +70,13 @@ func cleanupPurchaseRequestsForTest(t *testing.T, cfg *config.Config) { t.Helper() namespace := agentNamespace(cfg) + // Delete FIRST, then strip finalizers. The controller re-adds its + // finalizer to any live PurchaseRequest, so clearing before delete is a + // no-op race; and a deleting purchase with unspent auths intentionally + // drains (stays Terminating) — the finalizer strip is the test's + // force-path past that drain. + _, _ = obolRunErr(cfg, "kubectl", "delete", "purchaserequests.obol.org", + "-n", namespace, "--all", "--ignore-not-found", "--wait=false") if out, err := obolRunErr(cfg, "kubectl", "get", "purchaserequests.obol.org", "-n", namespace, "-o", "name"); err == nil { for _, name := range strings.Fields(out) { @@ -77,8 +84,15 @@ func cleanupPurchaseRequestsForTest(t *testing.T, cfg *config.Config) { "-n", namespace, "--type=merge", "-p", `{"metadata":{"finalizers":[]}}`) } } - _, _ = obolRunErr(cfg, "kubectl", "delete", "purchaserequests.obol.org", - "-n", namespace, "--all", "--ignore-not-found", "--wait=false") + for i := 0; i < 12; i++ { + out, err := obolRunErr(cfg, "kubectl", "get", "purchaserequests.obol.org", + "-n", namespace, "-o", "name") + if err == nil && strings.TrimSpace(out) == "" { + return + } + time.Sleep(5 * time.Second) + } + t.Logf("warning: PurchaseRequests still present in %s after cleanup", namespace) } // getServiceOffer returns the ServiceOffer as a parsed JSON map. @@ -1101,18 +1115,30 @@ req = urllib.request.Request( }, ) -try: - with urllib.request.urlopen(req, timeout=180) as resp: +# Transport-level errors (connection refused) are retried: the controller +# may roll the LiteLLM deployment to publish the paid/ route right +# before the first paid call, and the Service can briefly point at a +# terminating pod. HTTP errors are NOT retried — their status codes are +# what the test asserts on. +import time +for attempt in range(12): + try: + with urllib.request.urlopen(req, timeout=180) as resp: + sys.stdout.write(json.dumps({ + "status": resp.status, + "body": resp.read().decode(), + })) + break + except urllib.error.HTTPError as err: sys.stdout.write(json.dumps({ - "status": resp.status, - "body": resp.read().decode(), + "status": err.code, + "body": err.read().decode(), })) -except urllib.error.HTTPError as err: - sys.stdout.write(json.dumps({ - "status": err.code, - "body": err.read().decode(), - })) - sys.exit(1) + sys.exit(1) + except urllib.error.URLError: + if attempt == 11: + raise + time.sleep(5) `, model, prompt, "http://litellm.llm.svc.cluster.local:4000/v1/chat/completions", "Bearer "+masterKey) out, err := execInAgentErr(cfg, "python3", "-c", script) @@ -3886,6 +3912,13 @@ func TestIntegration_SellBuySidecar_OBOLPermit2(t *testing.T) { } anvil.FundETH(t, agentWallet, big.NewInt(1e18)) anvil.MintMintableERC20(t, obolToken, anvil.Accounts[0].PrivateKey, agentWallet, new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18))) + // One-time approve(Permit2, max) the buyer wallet owner does on a real + // chain. The fork token is not the registry's canonical OBOL address, so + // the 402 never advertises eip2612GasSponsoring (anti-spoof check in + // internal/x402/chains.go) and buy.py's allowance preflight requires a + // real allowance. Earlier green runs only skipped that preflight when + // the allowance read raced eRPC pin propagation and missed the fork. + anvil.ApprovePermit2ViaImpersonation(t, obolToken, agentWallet) t.Logf("funded agent wallet %s with 10 OBOL on fork token %s", agentWallet, obolToken) originalERPCConfig := getERPCConfigYAML(t, cfg) diff --git a/internal/testutil/anvil.go b/internal/testutil/anvil.go index 75e07e3f..4486b328 100644 --- a/internal/testutil/anvil.go +++ b/internal/testutil/anvil.go @@ -227,6 +227,37 @@ func (f *AnvilFork) FundETH(t *testing.T, addr string, amount *big.Int) { t.Logf("funded %s with %s wei", addr, amount) } +// ApprovePermit2ViaImpersonation performs the one-time approve(Permit2, max) +// from owner on token via anvil_impersonateAccount — the fork-test stand-in +// for the on-chain approval a real wallet owner does once per token. Without +// it buy.py's Permit2 allowance preflight (correctly) refuses to pre-sign. +func (f *AnvilFork) ApprovePermit2ViaImpersonation(t *testing.T, token, owner string) { + t.Helper() + + const permit2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3" + // approve(address,uint256) selector + permit2 + max uint256. + data := "0x095ea7b3" + + "000000000000000000000000" + strings.ToLower(strings.TrimPrefix(permit2, "0x")) + + strings.Repeat("f", 64) + + for _, call := range []string{ + fmt.Sprintf(`{"jsonrpc":"2.0","method":"anvil_impersonateAccount","params":["%s"],"id":1}`, owner), + fmt.Sprintf(`{"jsonrpc":"2.0","method":"eth_sendTransaction","params":[{"from":"%s","to":"%s","data":"%s"}],"id":1}`, owner, token, data), + fmt.Sprintf(`{"jsonrpc":"2.0","method":"anvil_stopImpersonatingAccount","params":["%s"],"id":1}`, owner), + } { + resp, err := http.Post(f.RPCURL, "application/json", strings.NewReader(call)) + if err != nil { + t.Fatalf("approve Permit2 via impersonation: %v", err) + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if strings.Contains(string(raw), `"error"`) { + t.Fatalf("approve Permit2 via impersonation: %s", raw) + } + } + t.Logf("approved Permit2 for %s on token %s (impersonated)", owner, token) +} + // ClearCode removes contract code from an address on Anvil. // Required for deterministic Anvil accounts that have proxy contracts on Base Sepolia — // USDC's SignatureChecker sees code → tries EIP-1271 instead of ecrecover. diff --git a/internal/testutil/facilitator_real.go b/internal/testutil/facilitator_real.go index eda545f7..12a0230a 100644 --- a/internal/testutil/facilitator_real.go +++ b/internal/testutil/facilitator_real.go @@ -9,12 +9,24 @@ import ( "net/http" "os" "os/exec" + "runtime" "strconv" "testing" "time" ) -const x402FacilitatorImage = "ghcr.io/obolnetwork/x402-facilitator-prometheus-overlay:1.4.9" +const defaultX402FacilitatorImage = "ghcr.io/obolnetwork/x402-facilitator-prometheus-overlay:1.4.9" + +// x402FacilitatorImage resolves the facilitator image, honoring the same +// X402_FACILITATOR_IMAGE override the shell flows use (flows/lib.sh) so a +// locally-built facilitator (e.g. an upstream-sync candidate) can be smoked +// through the Go integration path without editing the pin. +func x402FacilitatorImage() string { + if img := os.Getenv("X402_FACILITATOR_IMAGE"); img != "" { + return img + } + return defaultX402FacilitatorImage +} // RealFacilitator wraps a running x402-rs facilitator process. // Unlike MockFacilitator, this validates real EIP-712 signatures against @@ -54,9 +66,16 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa port := l.Addr().(*net.TCPAddr).Port l.Close() - // The facilitator runs on the host, so it needs the localhost Anvil URL - // (not host.docker.internal which only resolves inside Docker/k3d). - anvilLocalURL := fmt.Sprintf("http://127.0.0.1:%d", anvil.Port) + // The facilitator runs in a Docker container. On Linux it gets + // `--network host`, so the host loopback works for the Anvil URL. On + // macOS, Docker Desktop's host networking does not share the Mac + // loopback — mirror flows/lib.sh::start_x402_facilitator_container: + // publish the port with -p and reach Anvil via host.docker.internal. + anvilHost := "127.0.0.1" + if runtime.GOOS == "darwin" { + anvilHost = "host.docker.internal" + } + anvilLocalURL := fmt.Sprintf("http://%s:%d", anvilHost, anvil.Port) // Generate config file. configPath := writeRealFacilitatorConfig(t, port, anvilLocalURL, anvil.Accounts[0].PrivateKey, opts) @@ -64,14 +83,23 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa ctx, cancel := context.WithCancel(context.Background()) containerName := fmt.Sprintf("obol-test-x402-facilitator-%d", time.Now().UnixNano()) - cmd := exec.CommandContext(ctx, - "docker", "run", "--rm", + // Linux: host networking, the facilitator binds the host port directly. + // macOS: publish the port instead — Docker Desktop's host networking + // does not share the Mac loopback (same split as flows/lib.sh). + netArgs := []string{"--network", "host"} + if runtime.GOOS == "darwin" { + netArgs = []string{"-p", fmt.Sprintf("%d:%d", port, port)} + } + args := append([]string{ + "run", "--rm", "--name", containerName, - "--network", "host", + }, netArgs...) + args = append(args, "-v", configPath+":/config.json:ro", - x402FacilitatorImage, + x402FacilitatorImage(), "--config", "/config.json", ) + cmd := exec.CommandContext(ctx, "docker", args...) var stderr bytes.Buffer @@ -110,21 +138,29 @@ func StartRealFacilitatorWithOptions(t *testing.T, anvil *AnvilFork, opts RealFa return rf } -// requireFacilitatorImage verifies the pinned facilitator image is available. +// requireFacilitatorImage verifies the facilitator image is available. // Local facilitator experiments should be packaged as a Docker image instead of -// depending on host checkout paths. +// depending on host checkout paths: build + tag the image, then point +// X402_FACILITATOR_IMAGE at it. An image already present locally is used as-is +// (never pulled), mirroring flows/lib.sh::docker_pull_public_image. func requireFacilitatorImage(t *testing.T) { t.Helper() + image := x402FacilitatorImage() if _, err := exec.LookPath("docker"); err != nil { - t.Fatalf("docker not installed; cannot run %s", x402FacilitatorImage) + t.Fatalf("docker not installed; cannot run %s", image) + } + + if err := exec.Command("docker", "image", "inspect", image).Run(); err == nil { + t.Logf("using local x402 facilitator image %s", image) + return } - pull := exec.Command("docker", "pull", x402FacilitatorImage) + pull := exec.Command("docker", "pull", image) if out, err := pull.CombinedOutput(); err != nil { - t.Fatalf("pull %s: %v\n%s", x402FacilitatorImage, err, out) + t.Fatalf("pull %s: %v\n%s", image, err, out) } - t.Logf("using x402 facilitator image %s", x402FacilitatorImage) + t.Logf("using x402 facilitator image %s", image) } // writeRealFacilitatorConfig writes a temporary config-test.json for the facilitator.