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
2 changes: 1 addition & 1 deletion modules/evm/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func (m *Module) PythonAPIDocs() map[string]types.ModuleDoc {
},
"faucet": {
Signature: "faucet(network, address) -> str",
Description: "Mine the network's PoW faucet and claim test ETH to address; returns the claim tx hash. Runs the full agent PoW flow server-side (no browser, WebSocket, or captcha). Requires panda auth.",
Description: "Mine the network's PoW faucet and claim test ETH to address; returns the claim tx hash once the transaction is on-chain, so the balance is readable as soon as it returns. Runs the full agent PoW flow server-side (no browser, WebSocket, or captcha). Requires panda auth.",
},
},
},
Expand Down
18 changes: 15 additions & 3 deletions modules/evm/python/evm.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import os
import sys
from typing import Any

from ethpandaops import _runtime
Expand Down Expand Up @@ -366,15 +367,26 @@ def faucet(network: str, address: str) -> str:
"""Mine the network's PoW faucet and claim test ETH to address.

Runs the full agent proof-of-work flow server-side — no browser, WebSocket,
or captcha — and returns the claim transaction hash once it confirms.
or captcha — and waits for the claim transaction to land on-chain, so the
balance is readable as soon as this returns. If the transaction had not been
included yet, a warning is printed and the hash is still returned.
Requires panda auth (run 'panda auth login'). Source:
https://github.com/pk910/PoWFaucet
"""
_require_available()
result = _runtime.invoke_json(
result = _runtime.invoke_data(
"evm.faucet",
{"network": network, "address": address},
)
if not isinstance(result, dict) or not result.get("claim_hash"):
raise ValueError(f"faucet claim did not return a tx hash: {result!r}")
return result["claim_hash"]

claim_hash = result["claim_hash"]
if not result.get("confirmed"):
print(
f"warning: faucet claim {claim_hash} was submitted but is not on-chain yet; "
f"the balance of {address} may lag — poll it before spending",
file=sys.stderr,
)

return claim_hash
25 changes: 17 additions & 8 deletions pkg/faucet/faucet.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,23 @@ import (
// client user-agents with 403, so we always send an explicit one.
const userAgent = "panda-faucet/1"

// pollInterval and pollAttempts bound the wait for on-chain claim confirmation.
// pollInterval and pollAttempts bound the wait for the faucet to report the
// claim submitted. This is the faucet's own claimStatus, not chain state.
const (
pollInterval = 3 * time.Second
pollAttempts = 40
)

// Result is the outcome of a successful claim.
// Result is the outcome of a successful claim. Confirmed and BlockNumber are
// filled in by the caller once the claim transaction has an on-chain receipt;
// the faucet flow itself only learns that the faucet broadcast it.
type Result struct {
Session string `json:"session"`
Target string `json:"target"`
ClaimHash string `json:"claim_hash"`
AmountWei string `json:"amount_wei"`
Session string `json:"session"`
Target string `json:"target"`
ClaimHash string `json:"claim_hash"`
AmountWei string `json:"amount_wei"`
Confirmed bool `json:"confirmed"`
BlockNumber uint64 `json:"block_number,omitempty"`
}

// Transport issues one faucet HTTP request and returns the response body and
Expand Down Expand Up @@ -66,8 +71,12 @@ func New(baseURL string, httpClient *http.Client) *Client {

// Claim runs the full agent flow for address: start a session, mine PoW shares
// until the balance covers the minimum drop, close the session, submit the
// claim, and poll until the claim transaction confirms. It returns the claim
// transaction hash.
// claim, and poll until the faucet reports the claim transaction submitted. It
// returns the claim transaction hash.
//
// The faucet flips claimStatus to "confirmed" once it has broadcast the
// transaction, which can precede inclusion — so Result.Confirmed is left false
// here. Callers with chain access should wait for the receipt themselves.
func (c *Client) Claim(ctx context.Context, address string) (*Result, error) {
session, err := c.startSession(ctx, address)
if err != nil {
Expand Down
90 changes: 90 additions & 0 deletions pkg/server/operations_evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package server
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"regexp"
"time"

"github.com/sirupsen/logrus"

"github.com/ethpandaops/panda/pkg/faucet"
"github.com/ethpandaops/panda/pkg/operations"
)
Expand All @@ -16,6 +19,24 @@ import (
// the request. Argon2id/16MiB mining plus on-chain confirmation fits comfortably.
const faucetClaimTimeout = 5 * time.Minute

// The faucet reports a claim "confirmed" as soon as it has broadcast the
// transaction, which can precede inclusion — so the operation waits for the
// receipt itself before returning.
//
// The wait is deliberately short. Mining already burns most of the caller's
// budget (sandbox.timeout defaults to 60s) and the claim is not lost if the
// receipt is slow, so it is better to return an unconfirmed hash than to hold
// the request until the sandbox kills it.
const (
faucetReceiptTimeout = 30 * time.Second
faucetReceiptPollInterval = 2 * time.Second
)

// faucetReceiptInstance is the ethnode handler's sentinel for a network's
// load-balanced execution endpoint (rpc.<network>.ethpandaops.io), so the
// receipt wait does not need to know any individual node name.
const faucetReceiptInstance = "lb"

// faucetNetworkPattern guards the network segment used to build the proxy path.
var faucetNetworkPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$`)

Expand Down Expand Up @@ -89,12 +110,81 @@ func (s *service) handleEVMFaucet(w http.ResponseWriter, r *http.Request) {
return
}

if err := s.awaitFaucetReceipt(ctx, network, result); err != nil {
writeAPIError(w, http.StatusBadGateway, "faucet claim failed: "+err.Error())

return
}

writeOperationResponse(s.log, w, http.StatusOK, operations.Response{
Kind: operations.ResultKindObject,
Data: result,
})
}

// awaitFaucetReceipt polls the network's load-balanced execution RPC until the
// claim transaction has a receipt, recording the block it landed in.
//
// The claim is already paid for by the time this runs, so a missing receipt is
// never fatal: a slow chain or an unreachable execution endpoint leaves
// Confirmed false with the hash intact, rather than reporting a funded address
// as a failed claim. Only a receipt saying the transaction reverted is an error.
func (s *service) awaitFaucetReceipt(ctx context.Context, network string, result *faucet.Result) error {
ctx, cancel := context.WithTimeout(ctx, faucetReceiptTimeout)
defer cancel()

var lastErr error

for {
raw, _, err := s.ethNodeExecutionRPC(
ctx, network, faucetReceiptInstance,
"eth_getTransactionReceipt", []any{result.ClaimHash},
)
if err != nil {
lastErr = err
} else if receipt, ok := raw.(map[string]any); ok {
// Anything else (a JSON null) means "not mined yet"; keep polling.
return applyFaucetReceipt(result, receipt)
}

select {
case <-ctx.Done():
s.log.WithError(lastErr).WithFields(logrus.Fields{
"network": network,
"claim_hash": result.ClaimHash,
}).Warn("Faucet claim submitted but not seen on-chain within the receipt wait")

return nil
case <-time.After(faucetReceiptPollInterval):
}
}
}

// applyFaucetReceipt records a mined claim on result, or reports a reverted one.
func applyFaucetReceipt(result *faucet.Result, receipt map[string]any) error {
// Pre-Byzantium receipts have no status field; treat only an explicit
// failure as a revert.
if status, ok := receipt["status"].(string); ok && status != "0x1" {
return fmt.Errorf("claim transaction %s reverted on-chain (status %s)", result.ClaimHash, status)
}

blockHex, ok := receipt["blockNumber"].(string)
if !ok {
return fmt.Errorf("claim transaction %s receipt has no block number", result.ClaimHash)
}

block, err := parseHexUint64(blockHex)
if err != nil {
return fmt.Errorf("claim transaction %s has an unparseable block number %q: %w",
result.ClaimHash, blockHex, err)
}

result.Confirmed = true
result.BlockNumber = block

return nil
}

// proxyFaucetTransport routes faucet REST calls through the proxy's
// /faucet/{network}/ passthrough. The proxy authenticates the caller's bearer
// token and attaches the faucet credential, which never leaves the proxy.
Expand Down
53 changes: 53 additions & 0 deletions pkg/server/operations_evm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"

authclient "github.com/ethpandaops/panda/pkg/auth/client"
"github.com/ethpandaops/panda/pkg/faucet"
)

// authedService returns a network-operation service whose credential controller
Expand Down Expand Up @@ -67,3 +68,55 @@ func TestFaucetAuthenticatedResolution(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code)
})
}

// The faucet reports a claim confirmed once it has broadcast the transaction,
// which can precede inclusion — evm.faucet therefore waits for the receipt and
// reports the block. These cover how that receipt is interpreted.
func TestApplyFaucetReceipt(t *testing.T) {
const hash = "0xf6a59c5d523cd13bcf66ead42c3eacb6a346130d4a119787393fd6a1cd0817d4"

t.Run("mined receipt confirms and records the block", func(t *testing.T) {
result := &faucet.Result{ClaimHash: hash}

require.NoError(t, applyFaucetReceipt(result, map[string]any{
"status": "0x1",
"blockNumber": "0x36ddf",
}))
require.True(t, result.Confirmed)
require.Equal(t, uint64(224735), result.BlockNumber)
})

t.Run("pre-Byzantium receipt without status still confirms", func(t *testing.T) {
result := &faucet.Result{ClaimHash: hash}

require.NoError(t, applyFaucetReceipt(result, map[string]any{"blockNumber": "0x1"}))
require.True(t, result.Confirmed)
require.Equal(t, uint64(1), result.BlockNumber)
})

t.Run("reverted receipt is an error", func(t *testing.T) {
result := &faucet.Result{ClaimHash: hash}

err := applyFaucetReceipt(result, map[string]any{"status": "0x0", "blockNumber": "0x36ddf"})
require.ErrorContains(t, err, "reverted on-chain")
require.False(t, result.Confirmed)
})

t.Run("receipt without a block number is an error", func(t *testing.T) {
result := &faucet.Result{ClaimHash: hash}

require.ErrorContains(t, applyFaucetReceipt(result, map[string]any{"status": "0x1"}),
"no block number")
require.False(t, result.Confirmed)
})

t.Run("unparseable block number is an error", func(t *testing.T) {
result := &faucet.Result{ClaimHash: hash}

require.ErrorContains(t, applyFaucetReceipt(result, map[string]any{
"status": "0x1",
"blockNumber": "0xzz",
}), "unparseable block number")
require.False(t, result.Confirmed)
})
}
Loading