Skip to content
Open
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
5 changes: 4 additions & 1 deletion flows/flow-12-obol-payment.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 45 additions & 12 deletions internal/openclaw/monetize_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,29 @@ 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) {
_, _ = obolRunErr(cfg, "kubectl", "patch", name,
"-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.
Expand Down Expand Up @@ -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/<model> 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)
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions internal/testutil/anvil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 50 additions & 14 deletions internal/testutil/facilitator_real.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,24 +66,40 @@ 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)

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

Expand Down Expand Up @@ -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.
Expand Down
Loading