diff --git a/Dockerfile.x402-escrow b/Dockerfile.x402-escrow new file mode 100644 index 00000000..df464973 --- /dev/null +++ b/Dockerfile.x402-escrow @@ -0,0 +1,10 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /x402-escrow ./cmd/x402-escrow + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=builder /x402-escrow /x402-escrow +ENTRYPOINT ["/x402-escrow"] diff --git a/cmd/obol/bounty.go b/cmd/obol/bounty.go index 99ec84b3..6cdf556a 100644 --- a/cmd/obol/bounty.go +++ b/cmd/obol/bounty.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/big" "slices" "strconv" "strings" @@ -17,10 +18,23 @@ import ( "github.com/ObolNetwork/obol-stack/internal/kubectl" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ObolNetwork/obol-stack/internal/ui" + x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" + "github.com/ObolNetwork/obol-stack/internal/x402/escrow" "github.com/ethereum/go-ethereum/common" + ethcrypto "github.com/ethereum/go-ethereum/crypto" "github.com/urfave/cli/v3" ) +// Voucher ferry annotations — must match the serviceoffer-controller's +// bounty_eval.go constants exactly (the CLI writes, the controller reads; the +// controller never signs and escrow endpoint/credentials never ride in here). +const ( + bountyRewardVoucherAnnotation = "obol.org/reward-voucher" + bountyBondVoucherAnnotation = "obol.org/bond-voucher" + bountyEvalVoucherAnnotation = "obol.org/eval-voucher" + bountyEvalVoucherR1Annotation = "obol.org/eval-voucher-r1" +) + // bountyCommand is the demand-side counterpart to `obol sell`: post a // ServiceBounty (escrowed reward for work) instead of a ServiceOffer. Task // types are discovered dynamically from the embedded catalog — exactly like @@ -42,8 +56,10 @@ func bountyCommand(cfg *config.Config) *cli.Command { bountyTypesCommand(cfg), bountyListCommand(cfg), bountyStatusCommand(cfg), + bountyFundCommand(cfg), bountyClaimCommand(cfg), bountySubmitCommand(cfg), + bountyFeedbackCommand(cfg), bountyVerdictCommand(cfg, "accept", "Accept the submission (poster verdict; releases the escrowed reward)"), bountyVerdictCommand(cfg, "reject", "Reject the submission (poster verdict; escrow stays held until deadline refund)"), bountyEvalCommand(cfg), @@ -189,8 +205,11 @@ func bountyEvalCommand(cfg *config.Config) *cli.Command { Name: "calldata", Usage: "Print ERC-8004 validationResponse calldata for your wallet to submit (the controller NEVER signs)", Flags: []cli.Flag{ + &cli.StringFlag{Name: "namespace", Aliases: []string{"n"}, Usage: "Namespace (with --bounty)", Value: "hermes-obol-agent"}, &cli.StringFlag{Name: "network", Usage: "Chain", Value: "base-sepolia"}, - &cli.StringFlag{Name: "request-hash", Usage: "[REQUIRED] The validation request hash (bytes32, 0x...)", Required: true}, + &cli.StringFlag{Name: "bounty", Usage: "Bounty name — derives the request hash from the bounty UID + --address"}, + &cli.StringFlag{Name: "address", Usage: "Your evaluator address (0x...; required with --bounty)"}, + &cli.StringFlag{Name: "request-hash", Usage: "Explicit validation request hash (bytes32, 0x...) — overrides --bounty derivation"}, &cli.IntFlag{Name: "response", Usage: "[REQUIRED] Your 0-100 verdict score", Required: true}, &cli.StringFlag{Name: "response-uri", Usage: "Optional URI of your evaluation report"}, &cli.StringFlag{Name: "tag", Usage: "Optional tag (e.g. the task type ref)"}, @@ -200,12 +219,16 @@ func bountyEvalCommand(cfg *config.Config) *cli.Command { if response < 0 || response > 100 { return fmt.Errorf("--response %d out of range 0-100", response) } + requestHash, err := resolveEvalRequestHash(cfg, cmd) + if err != nil { + return err + } registry, err := erc8004.ValidationRegistryAddress(cmd.String("network")) if err != nil { return err } calldata, err := erc8004.EncodeValidationResponse( - common.HexToHash(cmd.String("request-hash")), + requestHash, uint8(response), cmd.String("response-uri"), common.Hash{}, @@ -214,16 +237,44 @@ func bountyEvalCommand(cfg *config.Config) *cli.Command { if err != nil { return err } + fmt.Printf("Request hash: %s\n", requestHash.Hex()) fmt.Printf("ValidationRegistry (%s): %s\n", cmd.String("network"), registry) fmt.Printf("Calldata: 0x%x\n", calldata) fmt.Println("Submit with YOUR wallet (e.g. the agent remote-signer or cast send) — then pass the tx hash to `obol bounty eval reveal --validation-tx`.") return nil }, }, + bountyEvalFundCommand(cfg), }, } } +// resolveEvalRequestHash returns the explicit --request-hash override, or +// derives the hash from the named bounty's UID + the evaluator --address via +// erc8004.BountyEvalRequestHash (the controller grounds reveals against the +// exact same derivation). +func resolveEvalRequestHash(cfg *config.Config, cmd *cli.Command) (common.Hash, error) { + if raw := strings.TrimSpace(cmd.String("request-hash")); raw != "" { + return common.HexToHash(raw), nil + } + name := strings.TrimSpace(cmd.String("bounty")) + address := strings.TrimSpace(cmd.String("address")) + if name == "" || address == "" { + return common.Hash{}, fmt.Errorf("pass --request-hash 0x..., or --bounty with --address 0x... to derive it from the bounty UID") + } + if !common.IsHexAddress(address) { + return common.Hash{}, fmt.Errorf("--address %q is not a 0x address", address) + } + sb, err := getBountyCLI(cfg, cmd.String("namespace"), name) + if err != nil { + return common.Hash{}, err + } + if sb.UID == "" { + return common.Hash{}, fmt.Errorf("bounty %s has no UID — cannot derive the request hash", name) + } + return erc8004.BountyEvalRequestHash(string(sb.UID), address), nil +} + // bountyTypesCommand lists the enabled task-type catalog with its eval/pricing // policy, so an operator can see what bounties are postable and on what terms. func bountyTypesCommand(cfg *config.Config) *cli.Command { @@ -567,20 +618,18 @@ func bountyStatusCommand(cfg *config.Config) *cli.Command { if name == "" { return fmt.Errorf("missing bounty name: obol bounty status ") } - bin, kc := kubectl.Paths(cfg) - out, err := kubectl.Output(bin, kc, "get", bountyResource(), name, "-n", cmd.String("namespace"), "-o", "json") + namespace := cmd.String("namespace") + sb, err := getBountyCLI(cfg, namespace, name) if err != nil { return err } - var sb monetizeapi.ServiceBounty - if err := json.Unmarshal([]byte(out), &sb); err != nil { - return fmt.Errorf("decode bounty: %w", err) - } - fmt.Printf("%s (%s)\n", sb.Name, sb.Spec.Task.TypeRef) fmt.Printf(" Phase: %s\n", sb.Status.Phase) fmt.Printf(" Reward: %s %s on %s (escrow: %s)\n", sb.Spec.Reward.Amount, sb.Spec.Reward.Asset.Symbol, sb.Spec.Reward.Network, valueOr(sb.Status.EscrowState, "not reserved")) + if sb.Status.EscrowSpender != "" { + fmt.Printf(" Escrow spender: %s (bind your Permit2 vouchers to this executor)\n", sb.Status.EscrowSpender) + } if sb.Status.CaptureTxHash != "" { fmt.Printf(" Payout: %s\n", sb.Status.CaptureTxHash) } @@ -596,19 +645,19 @@ func bountyStatusCommand(cfg *config.Config) *cli.Command { if sb.Status.BondState != "" { fmt.Printf(" Bond: %s\n", sb.Status.BondState) } + if seed := sb.Status.PanelSeed; seed != nil { + fmt.Printf(" Panel seed: source=%s", seed.Source) + if seed.Round > 0 { + fmt.Printf(" round=%d", seed.Round) + } + fmt.Println() + } if len(sb.Status.Evaluations) > 0 { fmt.Printf(" Evaluations (quorum k=%d, median>=50 verifies):\n", sb.Spec.Eval.K) if sb.Status.RevealDeadline != nil { fmt.Printf(" reveal window closes %s\n", sb.Status.RevealDeadline.UTC().Format(time.RFC3339)) } - for _, ev := range sb.Status.Evaluations { - score := "-" - if ev.Phase == "Revealed" { - score = fmt.Sprintf("%d", ev.Score) - } - fmt.Printf(" %s seat=%-9s phase=%-10s score=%-4s withinBand=%-5v paid=%v\n", - ev.Address, valueOr(ev.Seat, "open"), ev.Phase, score, ev.WithinBand, ev.Paid) - } + printBountyEvaluations(sb.Status.Evaluations, " ") if sb.Status.EvalBudgetState != "" { fmt.Printf(" eval budget: %s", sb.Status.EvalBudgetState) if sb.Status.EvalPayoutTxHash != "" { @@ -617,10 +666,25 @@ func bountyStatusCommand(cfg *config.Config) *cli.Command { fmt.Println() } } + if esc := sb.Status.Escalation; esc != nil { + fmt.Printf(" Escalation (round %d): %s\n", esc.Round, valueOr(esc.Reason, "-")) + fmt.Printf(" budget: %s\n", valueOr(esc.BudgetState, "not reserved")) + if esc.VoucherDeadline != nil { + fmt.Printf(" voucher deadline %s\n", esc.VoucherDeadline.UTC().Format(time.RFC3339)) + } + if esc.RevealDeadline != nil { + fmt.Printf(" reveal window closes %s\n", esc.RevealDeadline.UTC().Format(time.RFC3339)) + } + for _, seat := range esc.Panel { + fmt.Printf(" panel: %s seat=%s\n", seat.Address, seat.Seat) + } + printBountyEvaluations(esc.Evaluations, " ") + } fmt.Println(" Conditions:") for _, condition := range sb.Status.Conditions { fmt.Printf(" %-15s %-5s %-22s %s\n", condition.Type, condition.Status, condition.Reason, condition.Message) } + printBountyVoucherNextSteps(sb, namespace) return nil }, } @@ -629,12 +693,17 @@ func bountyStatusCommand(cfg *config.Config) *cli.Command { func bountyClaimCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "claim", - Usage: "Claim a bounty as a fulfiller (binds your payout address)", + Usage: "Claim a bounty as a fulfiller (binds your payout address; optionally sign the self-bond voucher)", ArgsUsage: "", Flags: []cli.Flag{ &cli.StringFlag{Name: "namespace", Aliases: []string{"n"}, Usage: "Namespace", Value: "hermes-obol-agent"}, &cli.StringFlag{Name: "address", Usage: "[REQUIRED] Fulfiller payout address (0x...)", Required: true}, &cli.StringFlag{Name: "commit", Usage: "Optional commit hash (binds you to a specific deliverable before reveal)"}, + &cli.StringFlag{Name: "bond-key", Usage: "Hex private key to sign the self-bond Permit2 voucher locally (or use --bond-signer-url)"}, + &cli.StringFlag{Name: "bond-signer-url", Usage: "Remote-signer base URL to sign the self-bond voucher without exposing a key"}, + &cli.StringFlag{Name: "bond-recipient", Usage: "Bond forfeiture recipient (default: the poster's spec.reward.payTo address)"}, + &cli.StringFlag{Name: "spender", Usage: "Escrow facilitator address to bind as the only executor (default: status.escrowSpender)"}, + &cli.IntFlag{Name: "deadline-hours", Usage: "Bond voucher expiry in hours from now", Value: 72}, }, Action: func(ctx context.Context, cmd *cli.Command) error { name := cmd.Args().First() @@ -645,11 +714,80 @@ func bountyClaimCommand(cfg *config.Config) *cli.Command { if commit := cmd.String("commit"); commit != "" { annotations = append(annotations, "obol.org/commit="+commit) } - return annotateBountyCLI(cfg, cmd.String("namespace"), name, annotations) + if err := annotateBountyCLI(cfg, cmd.String("namespace"), name, annotations); err != nil { + return err + } + + // Optional self-bond voucher: the FULFILLER's own funds, signed by + // their wallet (never the controller's), forfeited to the poster + // only on rejected work. + bondKey, bondSigner := cmd.String("bond-key"), cmd.String("bond-signer-url") + if bondKey == "" && bondSigner == "" { + return nil + } + return attachBountyBondVoucher(ctx, cfg, cmd, name, bondKey, bondSigner) }, } } +// attachBountyBondVoucher builds, signs, and ferries the fulfiller's self-bond +// voucher (annotation obol.org/bond-voucher, nonce leg bond). The recipient is +// the poster's payout address (spec.reward.payTo) — the bond is forfeited TO +// the poster on rejected work — overridable / required via --bond-recipient +// when the spec field is absent. +func attachBountyBondVoucher(ctx context.Context, cfg *config.Config, cmd *cli.Command, name, bondKey, bondSigner string) error { + namespace := cmd.String("namespace") + sb, err := getBountyCLI(cfg, namespace, name) + if err != nil { + return err + } + bond := sb.Spec.Trust.SelfBond + if strings.TrimSpace(bond.Amount) == "" { + return fmt.Errorf("bounty %s declares no self-bond (spec.trust.selfBond.amount is empty) — nothing to sign", name) + } + recipient := cmd.String("bond-recipient") + if recipient == "" { + recipient = sb.Spec.Reward.PayTo + } + if recipient == "" { + return fmt.Errorf("bounty %s has no poster payout address (spec.reward.payTo) — pass --bond-recipient 0x... explicitly", name) + } + if !common.IsHexAddress(recipient) { + return fmt.Errorf("bond recipient %q is not a 0x address", recipient) + } + + symbol := bond.Token + if symbol == "" { + symbol = sb.Spec.Reward.Asset.Symbol + } + token, err := resolveBountyToken(symbol, sb.Spec.Reward.Network) + if err != nil { + return err + } + amount, err := humanToAtomic(bond.Amount, token.Decimals) + if err != nil { + return fmt.Errorf("bond amount: %w", err) + } + spender, err := resolveBountySpender(cmd.String("spender"), sb.Status.EscrowSpender) + if err != nil { + return err + } + + voucher := escrow.Permit2Voucher{ + Token: token.Address, + Network: sb.Spec.Reward.Network, + Spender: spender, + Nonce: bountyVoucherNonce(string(sb.UID), "bond"), + Deadline: bountyVoucherDeadline(int64(cmd.Int("deadline-hours"))), + Recipients: []escrow.BatchRecipient{ + {Address: common.HexToAddress(recipient).Hex(), Amount: amount}, + }, + } + fmt.Printf("Self-bond: %s %s (%s atomic) -> poster %s on %s (refundable; forfeited only on rejected work)\n", + bond.Amount, symbol, amount, common.HexToAddress(recipient).Hex(), sb.Spec.Reward.Network) + return attachBountyVoucher(ctx, cfg, namespace, name, bountyBondVoucherAnnotation, &voucher, bondKey, bondSigner) +} + func bountySubmitCommand(cfg *config.Config) *cli.Command { return &cli.Command{ Name: "submit", @@ -718,3 +856,415 @@ func valueOr(value, fallback string) string { } return value } + +// ── poster-side voucher signing (fund / claim-bond / eval fund) ───────────── +// +// A Permit2 voucher is the poster's (or fulfiller's, for the bond) signed +// authorization the escrow facilitator executes. The CLI signs it locally +// (--key) or via the agent remote-signer (--signer-url) and ferries it to the +// controller on an annotation. The controller NEVER signs — it only attaches +// the voucher to the matching escrow reservation. + +// bountyVoucherNonce derives the Permit2 unordered nonce DETERMINISTICALLY as +// the uint256 of keccak256("|") with leg one of reward, bond, +// eval, eval-r1. Re-running a fund command re-signs the SAME nonce, so +// re-funding is idempotent and a nonce already consumed on-chain can never be +// double-captured. +func bountyVoucherNonce(bountyUID, leg string) string { + return new(big.Int).SetBytes(ethcrypto.Keccak256([]byte(bountyUID + "|" + leg))).String() +} + +// humanToAtomic converts a human-unit decimal amount (e.g. "500.00") to +// atomic token units ("500000000" at 6 decimals) without float rounding. +// Shared with the controller's settle paths via escrow.HumanToAtomic so the +// CLI-signed voucher seats and the controller's capture recipients can never +// drift apart in units. +func humanToAtomic(amount string, decimals int) (string, error) { + return escrow.HumanToAtomic(amount, decimals) +} + +// resolveBountySpender picks the escrow facilitator address the voucher must +// bind as its only executor: the --spender override, else status.escrowSpender +// (ferried from the facilitator's reserve receipt). +func resolveBountySpender(override, statusSpender string) (string, error) { + if override != "" { + if !common.IsHexAddress(override) { + return "", fmt.Errorf("--spender %q is not a 0x address", override) + } + return common.HexToAddress(override).Hex(), nil + } + if strings.TrimSpace(statusSpender) == "" { + return "", fmt.Errorf("status.escrowSpender is not set yet and no --spender was given — the escrow facilitator reports its address on the first reserve receipt; wait for the controller to reconcile (obol bounty status) or pass --spender 0x... explicitly") + } + if !common.IsHexAddress(statusSpender) { + return "", fmt.Errorf("status.escrowSpender %q is not a 0x address — pass --spender explicitly", statusSpender) + } + return common.HexToAddress(statusSpender).Hex(), nil +} + +// resolveBountyToken looks the payment token up in the x402 registry and +// returns its contract address + decimals for the given network. +func resolveBountyToken(symbol, network string) (x402verifier.TokenEntry, error) { + entry, ok := x402verifier.ResolveToken(symbol, network) + if !ok { + return x402verifier.TokenEntry{}, fmt.Errorf("token %q is not registered on network %q (supported: %s)", + symbol, network, strings.Join(x402verifier.SupportedTokens(), ", ")) + } + return entry, nil +} + +// signBountyVoucher signs the voucher with the local hex key or the remote +// signer, then verifies the result against the spender binding. Exactly the +// poster's wallet authorizes funds — the controller never signs. +func signBountyVoucher(ctx context.Context, v *escrow.Permit2Voucher, keyHex, signerURL string) error { + chainID, err := escrow.ChainIDForNetwork(v.Network) + if err != nil { + return err + } + switch { + case keyHex != "": + key, err := ethcrypto.HexToECDSA(strings.TrimPrefix(strings.TrimPrefix(keyHex, "0x"), "0X")) + if err != nil { + return fmt.Errorf("parse signing key: %w", err) + } + if err := escrow.SignVoucher(v, chainID, key); err != nil { + return err + } + case signerURL != "": + signer := erc8004.NewRemoteSigner(signerURL) + addr, err := signer.GetAddress(ctx) + if err != nil { + return err + } + v.Owner = addr.Hex() + _, remote, err := escrow.VoucherTypedData(*v, chainID) + if err != nil { + return err + } + sig, err := signer.SignTypedData(ctx, addr, remote) + if err != nil { + return err + } + v.Signature = sig + default: + return fmt.Errorf("no signer: pass --key or --signer-url — the controller NEVER signs; only your wallet can authorize funds") + } + return escrow.VerifyVoucher(*v, chainID, common.HexToAddress(v.Spender)) +} + +// attachBountyVoucher signs the voucher and ferries it to the controller on +// the given annotation. +func attachBountyVoucher(ctx context.Context, cfg *config.Config, namespace, name, annotation string, v *escrow.Permit2Voucher, keyHex, signerURL string) error { + if err := signBountyVoucher(ctx, v, keyHex, signerURL); err != nil { + return err + } + raw, err := json.Marshal(v) + if err != nil { + return err + } + fmt.Printf("Voucher signed by %s (spender %s, nonce %s, deadline %s)\n", + v.Owner, v.Spender, v.Nonce, time.Unix(v.Deadline, 0).UTC().Format(time.RFC3339)) + fmt.Println("Nonce is deterministic per (bounty, leg): re-running re-signs the same nonce, so re-funding is idempotent and a consumed nonce can never be double-captured.") + return annotateBountyCLI(cfg, namespace, name, []string{annotation + "=" + string(raw)}) +} + +// getBountyCLI fetches and decodes one ServiceBounty. +func getBountyCLI(cfg *config.Config, namespace, name string) (*monetizeapi.ServiceBounty, error) { + bin, kc := kubectl.Paths(cfg) + out, err := kubectl.Output(bin, kc, "get", bountyResource(), name, "-n", namespace, "-o", "json") + if err != nil { + return nil, err + } + var sb monetizeapi.ServiceBounty + if err := json.Unmarshal([]byte(out), &sb); err != nil { + return nil, fmt.Errorf("decode bounty: %w", err) + } + return &sb, nil +} + +// bountyVoucherDeadline turns --deadline-hours into a unix voucher expiry. +func bountyVoucherDeadline(hours int64) int64 { + return time.Now().Add(time.Duration(hours) * time.Hour).Unix() +} + +// bountyFundCommand signs + attaches the poster's Permit2 reward voucher: +// one recipient seat binding the claimed fulfiller to the full reward amount. +func bountyFundCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "fund", + Usage: "Sign + attach the reward escrow voucher (your wallet signs; the controller NEVER does)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "namespace", Aliases: []string{"n"}, Usage: "Namespace", Value: "hermes-obol-agent"}, + &cli.StringFlag{Name: "key", Usage: "Hex private key to sign the Permit2 voucher locally (or use --signer-url)"}, + &cli.StringFlag{Name: "signer-url", Usage: "Remote-signer base URL (e.g. http://127.0.0.1:9000) to sign without exposing a key"}, + &cli.StringFlag{Name: "spender", Usage: "Escrow facilitator address to bind as the only executor (default: status.escrowSpender)"}, + &cli.IntFlag{Name: "deadline-hours", Usage: "Voucher expiry in hours from now (the hard on-chain guarantee)", Value: 72}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + name := cmd.Args().First() + if name == "" { + return fmt.Errorf("missing bounty name: obol bounty fund (--key | --signer-url )") + } + namespace := cmd.String("namespace") + sb, err := getBountyCLI(cfg, namespace, name) + if err != nil { + return err + } + if len(sb.Status.Claims) == 0 || sb.Status.Claims[0].FulfillerAddress == "" { + return fmt.Errorf("bounty %s has no claim yet — the reward voucher binds the fulfiller's payout seat, so fund AFTER `obol bounty claim`", name) + } + fulfiller := sb.Status.Claims[0].FulfillerAddress + + token, err := resolveBountyToken(sb.Spec.Reward.Asset.Symbol, sb.Spec.Reward.Network) + if err != nil { + return err + } + amount, err := humanToAtomic(sb.Spec.Reward.Amount, token.Decimals) + if err != nil { + return fmt.Errorf("reward amount: %w", err) + } + spender, err := resolveBountySpender(cmd.String("spender"), sb.Status.EscrowSpender) + if err != nil { + return err + } + + voucher := escrow.Permit2Voucher{ + Token: token.Address, + Network: sb.Spec.Reward.Network, + Spender: spender, + Nonce: bountyVoucherNonce(string(sb.UID), "reward"), + Deadline: bountyVoucherDeadline(int64(cmd.Int("deadline-hours"))), + Recipients: []escrow.BatchRecipient{ + {Address: fulfiller, Amount: amount}, + }, + } + fmt.Printf("Funding reward: %s %s (%s atomic) -> fulfiller %s on %s\n", + sb.Spec.Reward.Amount, sb.Spec.Reward.Asset.Symbol, amount, fulfiller, sb.Spec.Reward.Network) + return attachBountyVoucher(ctx, cfg, namespace, name, bountyRewardVoucherAnnotation, + &voucher, cmd.String("key"), cmd.String("signer-url")) + }, + } +} + +// bountyEvalFundRecipients mirrors the controller's evalBudgetTotal math for +// round 0 (counting seats: full price, probation at half price, shadows free) +// and reserveEscalationBudget for round 1 (every seat full price). +func bountyEvalFundRecipients(panel []monetizeapi.ServiceBountyPanelSeat, perAtomic *big.Int, escalation bool) []escrow.BatchRecipient { + half := new(big.Int).Div(perAtomic, big.NewInt(2)) + var recipients []escrow.BatchRecipient + for _, seat := range panel { + if !escalation && seat.Seat == monetizeapi.PanelSeatShadow { + continue // shadows evaluate free — never a paid voucher seat + } + amount := perAtomic + if !escalation && seat.Seat == monetizeapi.PanelSeatProbation { + amount = half // newcomer discount passed to the poster + } + recipients = append(recipients, escrow.BatchRecipient{Address: seat.Address, Amount: amount.String()}) + } + return recipients +} + +// bountyEvalFundCommand signs + attaches the poster's eval-budget voucher: +// one seat per counting panel evaluator. When the escalation budget is +// AwaitingVoucher it targets the round-1 panel instead (full price, voucher +// annotation obol.org/eval-voucher-r1, nonce leg eval-r1). +func bountyEvalFundCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "fund", + Usage: "Sign + attach the poster-funded eval-budget voucher (evaluators are paid win-or-lose; the controller NEVER signs)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "namespace", Aliases: []string{"n"}, Usage: "Namespace", Value: "hermes-obol-agent"}, + &cli.StringFlag{Name: "key", Usage: "Hex private key to sign the Permit2 voucher locally (or use --signer-url)"}, + &cli.StringFlag{Name: "signer-url", Usage: "Remote-signer base URL to sign without exposing a key"}, + &cli.StringFlag{Name: "spender", Usage: "Escrow facilitator address to bind as the only executor (default: status.escrowSpender)"}, + &cli.IntFlag{Name: "deadline-hours", Usage: "Voucher expiry in hours from now", Value: 72}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + name := cmd.Args().First() + if name == "" { + return fmt.Errorf("missing bounty name: obol bounty eval fund (--key | --signer-url )") + } + namespace := cmd.String("namespace") + sb, err := getBountyCLI(cfg, namespace, name) + if err != nil { + return err + } + per := strings.TrimSpace(sb.Spec.Eval.Payment.PerEvaluator) + if per == "" { + return fmt.Errorf("bounty %s has no eval payment leg (spec.eval.payment.perEvaluator is empty) — nothing to fund", name) + } + token, err := resolveBountyToken(sb.Spec.Eval.Payment.Asset, sb.Spec.Reward.Network) + if err != nil { + return err + } + perAtomicStr, err := humanToAtomic(per, token.Decimals) + if err != nil { + return fmt.Errorf("perEvaluator amount: %w", err) + } + perAtomic, _ := new(big.Int).SetString(perAtomicStr, 10) + + // Escalation targeting: a round-1 panel waiting on its budget wins. + leg, annotation := "eval", bountyEvalVoucherAnnotation + panel := sb.Status.EvaluatorPanel + escalation := false + if esc := sb.Status.Escalation; esc != nil && esc.BudgetState == escrow.StateAwaitingVoucher { + leg, annotation = "eval-r1", bountyEvalVoucherR1Annotation + panel = esc.Panel + escalation = true + } + if len(panel) == 0 { + return fmt.Errorf("bounty %s has no evaluator panel selected yet — wait for the controller to draw the panel (obol bounty status)", name) + } + recipients := bountyEvalFundRecipients(panel, perAtomic, escalation) + if len(recipients) == 0 { + return fmt.Errorf("bounty %s panel has no counting seats to fund", name) + } + spender, err := resolveBountySpender(cmd.String("spender"), sb.Status.EscrowSpender) + if err != nil { + return err + } + + voucher := escrow.Permit2Voucher{ + Token: token.Address, + Network: sb.Spec.Reward.Network, + Spender: spender, + Nonce: bountyVoucherNonce(string(sb.UID), leg), + Deadline: bountyVoucherDeadline(int64(cmd.Int("deadline-hours"))), + Recipients: recipients, + } + round := "round-0 quorum" + if escalation { + round = fmt.Sprintf("escalation round %d", sb.Status.Escalation.Round) + } + fmt.Printf("Funding eval budget (%s): %d seat(s) x %s %s on %s (probation seats at half price)\n", + round, len(recipients), per, sb.Spec.Eval.Payment.Asset, sb.Spec.Reward.Network) + return attachBountyVoucher(ctx, cfg, namespace, name, annotation, + &voucher, cmd.String("key"), cmd.String("signer-url")) + }, + } +} + +// bountyFeedbackCommand prints ERC-8004 giveFeedback calldata for the poster +// to score the fulfiller from the settled verdict — submitted with the +// poster's OWN wallet, exactly like `obol bounty eval calldata`. +func bountyFeedbackCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "feedback", + Usage: "Print ERC-8004 giveFeedback calldata for the fulfiller, scored from the verdict (the controller NEVER signs)", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "namespace", Aliases: []string{"n"}, Usage: "Namespace", Value: "hermes-obol-agent"}, + &cli.Int64Flag{Name: "agent-id", Usage: "[REQUIRED] The fulfiller's ERC-8004 agent id (Identity Registry tokenId)", Required: true}, + &cli.StringFlag{Name: "feedback-uri", Usage: "Optional URI of the bounty report backing the feedback"}, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + name := cmd.Args().First() + if name == "" { + return fmt.Errorf("missing bounty name: obol bounty feedback --agent-id N") + } + sb, err := getBountyCLI(cfg, cmd.String("namespace"), name) + if err != nil { + return err + } + verdictSpoken := false + for _, condition := range sb.Status.Conditions { + if condition.Type == "Verified" { + verdictSpoken = true + break + } + } + if !verdictSpoken { + return fmt.Errorf("bounty %s has no Verified verdict yet — feedback scores the settled verdict (status.weightedScore)", name) + } + score := sb.Status.WeightedScore + if score < 0 || score > 100 { + return fmt.Errorf("status.weightedScore %d out of range 0-100", score) + } + + network := sb.Spec.Reward.Network + registry, err := erc8004.ReputationRegistryAddress(network) + if err != nil { + return err + } + calldata, err := erc8004.EncodeGiveFeedback( + big.NewInt(cmd.Int64("agent-id")), + big.NewInt(score), + 0, // score is already 0-100, no fixed-point scaling + sb.Spec.Task.TypeRef, + "obol-bounty", + "", + cmd.String("feedback-uri"), + common.Hash{}, + ) + if err != nil { + return err + } + fmt.Printf("Feedback: poster -> fulfiller %s, score %d/100 (from the %s verdict)\n", + valueOr(firstClaimAddress(sb), ""), score, valueOr(conditionReasonCLI(sb.Status.Conditions, "Verified"), "Verified")) + fmt.Printf("ReputationRegistry (%s): %s\n", network, registry) + fmt.Printf("Calldata: 0x%x\n", calldata) + fmt.Println("Submit with YOUR wallet (e.g. the agent remote-signer or cast send) — then pass the tx hash to `obol bounty eval reveal --validation-tx`.") + return nil + }, + } +} + +// printBountyEvaluations renders one round's evaluation lines with the +// grounded marker: [grounded] means the reveal is backed by an on-chain +// ERC-8004 validation entry for this bounty's eval-request hash. +func printBountyEvaluations(evaluations []monetizeapi.ServiceBountyEvaluation, indent string) { + for _, ev := range evaluations { + score := "-" + if ev.Phase == "Revealed" { + score = fmt.Sprintf("%d", ev.Score) + } + grounded := "" + if ev.Grounded { + grounded = " [grounded]" + } + fmt.Printf("%s%s seat=%-9s phase=%-10s score=%-4s withinBand=%-5v paid=%v%s\n", + indent, ev.Address, valueOr(ev.Seat, "open"), ev.Phase, score, ev.WithinBand, ev.Paid, grounded) + } +} + +// printBountyVoucherNextSteps prints the exact fund command for every escrow +// leg parked in AwaitingVoucher — the facilitator verified the reservation and +// is waiting for a signed Permit2 voucher to ferry in. +func printBountyVoucherNextSteps(sb *monetizeapi.ServiceBounty, namespace string) { + awaiting := escrow.StateAwaitingVoucher + if sb.Status.EscrowState == awaiting { + fmt.Printf(" Next: reward escrow is awaiting its voucher — run:\n") + fmt.Printf(" obol bounty fund %s -n %s (--key | --signer-url )\n", sb.Name, namespace) + } + if sb.Status.EvalBudgetState == awaiting { + fmt.Printf(" Next: eval budget is awaiting its voucher — run:\n") + fmt.Printf(" obol bounty eval fund %s -n %s (--key | --signer-url )\n", sb.Name, namespace) + } + if esc := sb.Status.Escalation; esc != nil && esc.BudgetState == awaiting { + fmt.Printf(" Next: escalation eval budget is awaiting its voucher — run:\n") + fmt.Printf(" obol bounty eval fund %s -n %s (--key | --signer-url ) # auto-targets the escalation panel\n", sb.Name, namespace) + } + if sb.Status.BondState == awaiting { + fmt.Printf(" Next: self-bond is awaiting its voucher — re-run claim with bond signing:\n") + fmt.Printf(" obol bounty claim %s -n %s --address <0x...> (--bond-key | --bond-signer-url )\n", sb.Name, namespace) + } +} + +func firstClaimAddress(sb *monetizeapi.ServiceBounty) string { + if len(sb.Status.Claims) == 0 { + return "" + } + return sb.Status.Claims[0].FulfillerAddress +} + +func conditionReasonCLI(conditions []monetizeapi.Condition, condType string) string { + for _, condition := range conditions { + if condition.Type == condType { + return condition.Reason + } + } + return "" +} diff --git a/cmd/obol/bounty_test.go b/cmd/obol/bounty_test.go new file mode 100644 index 00000000..191bb1d2 --- /dev/null +++ b/cmd/obol/bounty_test.go @@ -0,0 +1,315 @@ +package main + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/x402/escrow" + "github.com/ethereum/go-ethereum/common" + ethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/urfave/cli/v3" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Command structure (house style: sell_test.go) +// ───────────────────────────────────────────────────────────────────────────── + +func testBountyCommand(t *testing.T) *cli.Command { + t.Helper() + return bountyCommand(&config.Config{}) +} + +func TestBountyFundCommand_Flags(t *testing.T) { + fund := findSubcommand(t, testBountyCommand(t), "fund") + flags := flagMap(fund) + + requireFlags(t, flags, "namespace", "key", "signer-url", "spender", "deadline-hours") + assertStringDefault(t, flags, "namespace", "hermes-obol-agent") + assertIntDefault(t, flags, "deadline-hours", 72) +} + +func TestBountyClaimCommand_BondVoucherFlags(t *testing.T) { + claim := findSubcommand(t, testBountyCommand(t), "claim") + flags := flagMap(claim) + + requireFlags(t, flags, "address", "bond-key", "bond-signer-url", "bond-recipient", "spender", "deadline-hours") + assertFlagRequired(t, flags, "address") + assertIntDefault(t, flags, "deadline-hours", 72) +} + +func TestBountyEvalFundCommand_Flags(t *testing.T) { + eval := findSubcommand(t, testBountyCommand(t), "eval") + fund := findSubcommand(t, eval, "fund") + flags := flagMap(fund) + + requireFlags(t, flags, "namespace", "key", "signer-url", "spender", "deadline-hours") + assertStringDefault(t, flags, "namespace", "hermes-obol-agent") + assertIntDefault(t, flags, "deadline-hours", 72) +} + +func TestBountyEvalCalldata_DerivationFlags(t *testing.T) { + eval := findSubcommand(t, testBountyCommand(t), "eval") + calldata := findSubcommand(t, eval, "calldata") + flags := flagMap(calldata) + + requireFlags(t, flags, "bounty", "address", "request-hash", "response", "network", "namespace") + assertFlagRequired(t, flags, "response") + + // --request-hash became an explicit OVERRIDE: it must no longer be + // required, since --bounty + --address derive it from the bounty UID. + if f, ok := flags["request-hash"].(*cli.StringFlag); !ok || f.Required { + t.Errorf("--request-hash must be an optional override (derive via --bounty/--address), got required=%v", ok && f.Required) + } +} + +func TestBountyFeedbackCommand_Flags(t *testing.T) { + feedback := findSubcommand(t, testBountyCommand(t), "feedback") + flags := flagMap(feedback) + + requireFlags(t, flags, "namespace", "agent-id", "feedback-uri") + assertStringDefault(t, flags, "namespace", "hermes-obol-agent") + + // --agent-id is an Int64Flag (ERC-8004 tokenIds exceed int32), which the + // shared assertFlagRequired helper doesn't cover — assert inline. + f, ok := flags["agent-id"].(*cli.Int64Flag) + if !ok { + t.Fatalf("flag --agent-id is %T, want *cli.Int64Flag", flags["agent-id"]) + } + if !f.Required { + t.Error("flag --agent-id should be required") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Deterministic voucher nonce +// ───────────────────────────────────────────────────────────────────────────── + +func TestBountyVoucherNonce_Deterministic(t *testing.T) { + uid := "f81d4fae-7dec-11d0-a765-00a0c91e6bf6" + + // Re-running a fund command must re-derive the SAME nonce, so re-funding + // is idempotent and a consumed Permit2 nonce can never be double-captured. + if a, b := bountyVoucherNonce(uid, "reward"), bountyVoucherNonce(uid, "reward"); a != b { + t.Errorf("nonce not deterministic: %s != %s", a, b) + } + + // Cross-check the exact derivation: uint256 of keccak256("|reward"). + want := new(big.Int).SetBytes(ethcrypto.Keccak256([]byte(uid + "|reward"))).String() + if got := bountyVoucherNonce(uid, "reward"); got != want { + t.Errorf("nonce derivation drifted: got %s, want %s", got, want) + } + + // Every leg gets its own nonce — reward, bond, eval, and eval-r1 vouchers + // for the same bounty must never collide. + seen := map[string]string{} + for _, leg := range []string{"reward", "bond", "eval", "eval-r1"} { + nonce := bountyVoucherNonce(uid, leg) + if prev, dup := seen[nonce]; dup { + t.Errorf("legs %s and %s derived the same nonce %s", prev, leg, nonce) + } + seen[nonce] = leg + + // Permit2 nonces are uint256 decimal strings. + v, ok := new(big.Int).SetString(nonce, 10) + if !ok || v.Sign() < 0 || v.BitLen() > 256 { + t.Errorf("leg %s nonce %q is not a decimal uint256", leg, nonce) + } + } + + // Distinct bounties must derive distinct nonces for the same leg. + if bountyVoucherNonce(uid, "reward") == bountyVoucherNonce("other-uid", "reward") { + t.Error("distinct bounty UIDs derived the same reward nonce") + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Human → atomic amount conversion +// ───────────────────────────────────────────────────────────────────────────── + +func TestHumanToAtomic(t *testing.T) { + cases := []struct { + amount string + decimals int + want string + wantErr bool + }{ + {"500.00", 6, "500000000", false}, + {"0.5", 18, "500000000000000000", false}, + {"1", 6, "1000000", false}, + {"0.000001", 6, "1", false}, + {"1.230000", 2, "123", false}, // trailing zeros beyond precision OK + {".5", 6, "500000", false}, + {"0.0000001", 6, "", true}, // sub-atomic remainder + {"0", 6, "", true}, // must be positive + {"-1", 6, "", true}, + {"abc", 6, "", true}, + {"", 6, "", true}, + } + for _, tc := range cases { + got, err := humanToAtomic(tc.amount, tc.decimals) + if tc.wantErr { + if err == nil { + t.Errorf("humanToAtomic(%q, %d) = %q, want error", tc.amount, tc.decimals, got) + } + continue + } + if err != nil { + t.Errorf("humanToAtomic(%q, %d): %v", tc.amount, tc.decimals, err) + continue + } + if got != tc.want { + t.Errorf("humanToAtomic(%q, %d) = %q, want %q", tc.amount, tc.decimals, got, tc.want) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Eval voucher seats mirror the controller's budget math +// ───────────────────────────────────────────────────────────────────────────── + +func TestBountyEvalFundRecipients_MirrorsControllerMath(t *testing.T) { + full := "0x1111111111111111111111111111111111111111" + probation := "0x2222222222222222222222222222222222222222" + shadow := "0x3333333333333333333333333333333333333333" + panel := []monetizeapi.ServiceBountyPanelSeat{ + {Address: full, Seat: monetizeapi.PanelSeatFull}, + {Address: probation, Seat: monetizeapi.PanelSeatProbation}, + {Address: shadow, Seat: monetizeapi.PanelSeatShadow}, + } + per := big.NewInt(1_000_000) + + // Round 0: full seat at full price, probation at half, shadow free — + // exactly the controller's evalBudgetTotal / settleEvalBudget math. + recipients := bountyEvalFundRecipients(panel, per, false) + if len(recipients) != 2 { + t.Fatalf("round-0 recipients = %d, want 2 (shadow evaluates free)", len(recipients)) + } + if recipients[0].Address != full || recipients[0].Amount != "1000000" { + t.Errorf("full seat = %+v, want %s at 1000000", recipients[0], full) + } + if recipients[1].Address != probation || recipients[1].Amount != "500000" { + t.Errorf("probation seat = %+v, want %s at 500000 (half price)", recipients[1], probation) + } + + // Voucher total must equal the controller's reserve: k×per − per/2 for + // one sitting probation seat (k=2 counting seats here). + total := new(big.Int) + for _, r := range recipients { + amount, ok := new(big.Int).SetString(r.Amount, 10) + if !ok { + t.Fatalf("recipient amount %q is not a decimal uint256", r.Amount) + } + total.Add(total, amount) + } + wantTotal := big.NewInt(2*1_000_000 - 1_000_000/2) + if total.Cmp(wantTotal) != 0 { + t.Errorf("voucher total = %s, want %s (k×per − per/2)", total, wantTotal) + } + + // Escalation round: every seat full price, no discount, no free seats — + // mirrors reserveEscalationBudget. + escPanel := []monetizeapi.ServiceBountyPanelSeat{ + {Address: full, Seat: monetizeapi.PanelSeatFull}, + {Address: probation, Seat: monetizeapi.PanelSeatFull}, + } + escRecipients := bountyEvalFundRecipients(escPanel, per, true) + if len(escRecipients) != 2 { + t.Fatalf("escalation recipients = %d, want 2", len(escRecipients)) + } + for _, r := range escRecipients { + if r.Amount != "1000000" { + t.Errorf("escalation seat %s = %s, want full price 1000000", r.Address, r.Amount) + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Voucher signing +// ───────────────────────────────────────────────────────────────────────────── + +func TestSignBountyVoucher_LocalKeyRoundTrip(t *testing.T) { + // anvil key 0 — test-only, never funded outside local forks. + key, err := ethcrypto.HexToECDSA("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") + if err != nil { + t.Fatalf("parse key: %v", err) + } + spender := common.HexToAddress("0x4444444444444444444444444444444444444444") + + voucher := escrow.Permit2Voucher{ + Token: "0x0a09371a8b011d5110656ceBCc70603e53FD2c78", + Network: "base-sepolia", + Spender: spender.Hex(), + Nonce: bountyVoucherNonce("uid-1", "reward"), + Deadline: time.Now().Add(time.Hour).Unix(), + Recipients: []escrow.BatchRecipient{ + {Address: "0x5555555555555555555555555555555555555555", Amount: "1000000"}, + }, + } + + // signBountyVoucher signs AND verifies against the spender binding. + if err := signBountyVoucher(context.Background(), &voucher, "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", ""); err != nil { + t.Fatalf("signBountyVoucher: %v", err) + } + if want := ethcrypto.PubkeyToAddress(key.PublicKey).Hex(); voucher.Owner != want { + t.Errorf("voucher owner = %s, want signing key address %s", voucher.Owner, want) + } + chainID, err := escrow.ChainIDForNetwork(voucher.Network) + if err != nil { + t.Fatalf("ChainIDForNetwork: %v", err) + } + if err := escrow.VerifyVoucher(voucher, chainID, spender); err != nil { + t.Errorf("signed voucher does not verify: %v", err) + } +} + +func TestSignBountyVoucher_RequiresASigner(t *testing.T) { + voucher := escrow.Permit2Voucher{ + Token: "0x0a09371a8b011d5110656ceBCc70603e53FD2c78", + Network: "base-sepolia", + Spender: "0x4444444444444444444444444444444444444444", + Nonce: "1", + Deadline: time.Now().Add(time.Hour).Unix(), + Recipients: []escrow.BatchRecipient{ + {Address: "0x5555555555555555555555555555555555555555", Amount: "1"}, + }, + } + err := signBountyVoucher(context.Background(), &voucher, "", "") + if err == nil { + t.Fatal("expected error with neither --key nor --signer-url") + } + if !strings.Contains(err.Error(), "controller NEVER signs") { + t.Errorf("error %q must carry the controller-never-signs messaging", err) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Spender resolution +// ───────────────────────────────────────────────────────────────────────────── + +func TestResolveBountySpender(t *testing.T) { + statusSpender := "0x4444444444444444444444444444444444444444" + + got, err := resolveBountySpender("", statusSpender) + if err != nil || got != common.HexToAddress(statusSpender).Hex() { + t.Errorf("status spender path = (%q, %v), want canonical %s", got, err, statusSpender) + } + + override := "0x5555555555555555555555555555555555555555" + got, err = resolveBountySpender(override, statusSpender) + if err != nil || got != common.HexToAddress(override).Hex() { + t.Errorf("override path = (%q, %v), want %s", got, err, override) + } + + if _, err := resolveBountySpender("", ""); err == nil { + t.Error("expected a helpful error when neither --spender nor status.escrowSpender is set") + } + + if _, err := resolveBountySpender("not-an-address", statusSpender); err == nil { + t.Error("expected error for malformed --spender") + } +} diff --git a/cmd/x402-escrow/main.go b/cmd/x402-escrow/main.go new file mode 100644 index 00000000..1dc05d1e --- /dev/null +++ b/cmd/x402-escrow/main.go @@ -0,0 +1,144 @@ +// Command x402-escrow is the escrow facilitator for ServiceBounty rewards: +// it verifies and holds Permit2 batch-transfer vouchers (reserve), settles +// them on-chain via permitTransferFrom (capture), and drops them store-only +// (void — the voucher deadline is the hard on-chain guarantee). +// +// Configuration is environment-driven: +// +// OBOL_ESCROW_TOKEN bearer token for /escrow/* (empty = no auth, dev only) +// OBOL_ESCROW_STATE_DIR file-backed JSON state dir (default /data) +// OBOL_ESCROW_KEY hex private key for local settlement signing +// OBOL_ESCROW_SIGNER_URL remote-signer base URL (used when no key is set) +// OBOL_ESCROW_RPC_BASE per-network JSON-RPC base (default in-cluster eRPC) +// OBOL_ESCROW_NETWORKS csv chain aliases served (default base,base-sepolia) +// +// With neither key nor signer URL, capture returns 503 while reserve/void +// keep working (vouchers cannot be verified either — the spender binding has +// nothing to bind to). +package main + +import ( + "context" + "flag" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/x402/escrow" +) + +type config struct { + Token string + StateDir string + KeyHex string + SignerURL string + RPCBase string + Networks []string +} + +// loadConfig resolves the environment with defaults; get is injectable for +// tests (pass os.Getenv in main). +func loadConfig(get func(string) string) config { + cfg := config{ + Token: get("OBOL_ESCROW_TOKEN"), + StateDir: get("OBOL_ESCROW_STATE_DIR"), + KeyHex: get("OBOL_ESCROW_KEY"), + SignerURL: get("OBOL_ESCROW_SIGNER_URL"), + RPCBase: get("OBOL_ESCROW_RPC_BASE"), + } + if cfg.StateDir == "" { + cfg.StateDir = "/data" + } + if cfg.RPCBase == "" { + cfg.RPCBase = erc8004.DefaultRPCBase + } + csv := get("OBOL_ESCROW_NETWORKS") + if csv == "" { + csv = "base,base-sepolia" + } + for _, part := range strings.Split(csv, ",") { + if n := strings.TrimSpace(part); n != "" { + cfg.Networks = append(cfg.Networks, n) + } + } + return cfg +} + +func main() { + listen := flag.String("listen", ":8403", "Listen address") + flag.Parse() + + cfg := loadConfig(os.Getenv) + + store, err := escrow.NewStore(cfg.StateDir) + if err != nil { + log.Fatalf("open state dir: %v", err) + } + + var spender common.Address + var submitter escrow.Submitter + switch { + case cfg.KeyHex != "": + key, err := crypto.HexToECDSA(strings.TrimPrefix(strings.TrimPrefix(cfg.KeyHex, "0x"), "0X")) + if err != nil { + log.Fatalf("parse OBOL_ESCROW_KEY: %v", err) + } + spender = crypto.PubkeyToAddress(key.PublicKey) + submitter = &escrow.EthSubmitter{RPCBase: cfg.RPCBase, Key: key} + log.Printf("settling locally as %s", spender.Hex()) + case cfg.SignerURL != "": + signer := erc8004.NewRemoteSigner(cfg.SignerURL) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + addr, err := signer.GetAddress(ctx) + cancel() + if err != nil { + log.Fatalf("resolve remote signer address at %s: %v", cfg.SignerURL, err) + } + spender = addr + submitter = &escrow.EthSubmitter{RPCBase: cfg.RPCBase, Signer: signer, SignerAddress: addr} + log.Printf("settling via remote signer %s as %s", cfg.SignerURL, spender.Hex()) + default: + log.Printf("no OBOL_ESCROW_KEY or OBOL_ESCROW_SIGNER_URL: capture disabled, reserve/void still served") + } + if cfg.Token == "" { + log.Printf("warning: OBOL_ESCROW_TOKEN is empty — escrow routes are unauthenticated (mirrors HTTPGateway omitting the Authorization header)") + } + + srv := escrow.NewServer(store, escrow.ServerOptions{ + Token: cfg.Token, + Spender: spender, + Networks: cfg.Networks, + Submitter: submitter, + }) + + server := &http.Server{ + Addr: *listen, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + go func() { + log.Printf("x402-escrow listening on %s (state: %s, networks: %s)", *listen, cfg.StateDir, strings.Join(cfg.Networks, ", ")) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %v", err) + } + }() + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + log.Printf("shutdown: %v", err) + } +} diff --git a/cmd/x402-escrow/main_test.go b/cmd/x402-escrow/main_test.go new file mode 100644 index 00000000..52098343 --- /dev/null +++ b/cmd/x402-escrow/main_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "reflect" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" +) + +func TestLoadConfig_Defaults(t *testing.T) { + cfg := loadConfig(func(string) string { return "" }) + + if cfg.StateDir != "/data" { + t.Errorf("StateDir = %q, want /data", cfg.StateDir) + } + if cfg.RPCBase != erc8004.DefaultRPCBase { + t.Errorf("RPCBase = %q, want %q", cfg.RPCBase, erc8004.DefaultRPCBase) + } + if !reflect.DeepEqual(cfg.Networks, []string{"base", "base-sepolia"}) { + t.Errorf("Networks = %v, want [base base-sepolia]", cfg.Networks) + } + if cfg.Token != "" || cfg.KeyHex != "" || cfg.SignerURL != "" { + t.Errorf("credentials should default empty: %+v", cfg) + } +} + +func TestLoadConfig_Overrides(t *testing.T) { + env := map[string]string{ + "OBOL_ESCROW_TOKEN": "tok", + "OBOL_ESCROW_STATE_DIR": "/var/lib/escrow", + "OBOL_ESCROW_KEY": "0xabc123", + "OBOL_ESCROW_SIGNER_URL": "http://remote-signer:9000", + "OBOL_ESCROW_RPC_BASE": "http://127.0.0.1:8545", + "OBOL_ESCROW_NETWORKS": " base-sepolia , , polygon ", + } + cfg := loadConfig(func(k string) string { return env[k] }) + + if cfg.Token != "tok" || cfg.StateDir != "/var/lib/escrow" || cfg.KeyHex != "0xabc123" { + t.Errorf("cfg = %+v", cfg) + } + if cfg.SignerURL != "http://remote-signer:9000" || cfg.RPCBase != "http://127.0.0.1:8545" { + t.Errorf("cfg = %+v", cfg) + } + if !reflect.DeepEqual(cfg.Networks, []string{"base-sepolia", "polygon"}) { + t.Errorf("Networks = %v, want trimmed csv with empties dropped", cfg.Networks) + } +} diff --git a/go.mod b/go.mod index eb8589f5..2c9f2014 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/cucumber/godog v0.15.1 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/drand/kyber v1.3.2 + github.com/drand/kyber-bls12381 v0.3.4 github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 github.com/ethereum/go-ethereum v1.16.7 github.com/google/go-sev-guest v0.14.1 @@ -40,7 +42,7 @@ require ( github.com/StackExchange/wmi v1.2.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.24.2 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect @@ -81,6 +83,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kilic/bls12-381 v0.1.0 // indirect github.com/klauspost/compress v1.18.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -93,7 +96,6 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect github.com/spf13/cobra v1.9.1 // indirect diff --git a/go.sum b/go.sum index 60ea8dfb..f3995e12 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= -github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -74,6 +74,10 @@ github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiD github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/drand/kyber v1.3.2 h1:Cf3NNcb5bV3eODopr3XVHzImjDK40GiObhFUFG93Zeo= +github.com/drand/kyber v1.3.2/go.mod h1:ciDFWoC7ajb89niGJnS4C1Xeo4lSJMmbi+km5w8juAI= +github.com/drand/kyber-bls12381 v0.3.4 h1:rrmYcRcXmtOAvKWVBxRQxi22qNMVcS2Jz7MAebZQJxI= +github.com/drand/kyber-bls12381 v0.3.4/go.mod h1:jh3IGIAQfdLrdNKYz1HWZ3YdfJM0DWlN1TxXkh60utk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk= github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= @@ -195,6 +199,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kilic/bls12-381 v0.1.0 h1:encrdjqKMEvabVQ7qYOKu1OvhqpK4s47wDYtNiPtlp4= +github.com/kilic/bls12-381 v0.1.0/go.mod h1:vDTTHJONJ6G+P2R74EhnyotQDTliQDnFEwhdmfzw1ig= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= @@ -342,6 +348,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.dedis.ch/fixbuf v1.0.3 h1:hGcV9Cd/znUxlusJ64eAlExS+5cJDIyTyEG+otu5wQs= +go.dedis.ch/fixbuf v1.0.3/go.mod h1:yzJMt34Wa5xD37V5RTdmp38cz3QhMagdGoem9anUalw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -380,6 +388,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/bounty/decay.go b/internal/bounty/decay.go new file mode 100644 index 00000000..2da487c1 --- /dev/null +++ b/internal/bounty/decay.go @@ -0,0 +1,67 @@ +package bounty + +// Reputation decay (design doc §11.4): ladder weight earned by an evaluator +// halves every decayHalfLife of inactivity past lastEvalAt. These are PURE +// read-time functions — nothing here mutates ladder status. Stored records +// keep their raw counters; decay is applied only where reputation is READ +// (selection weights and tier gating), so an evaluator who returns from a +// long idle resumes from their stored counters, just with less pull until +// they participate again. + +import ( + "math" + "time" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" +) + +// defaultDecayHalfLife mirrors applyLadderDefaults (registry.go) for callers +// holding a zero/unparseable Ladder. +const defaultDecayHalfLife = 720 * time.Hour + +// DecayHalfLifeDuration parses the ladder's decayHalfLife knob, falling back +// to the registry default (720h) when it is missing or unparseable. +func (l Ladder) DecayHalfLifeDuration() time.Duration { + if d, err := time.ParseDuration(l.DecayHalfLife); err == nil && d > 0 { + return d + } + return defaultDecayHalfLife +} + +// EffectiveCompleted is the decayed completion count: +// +// completed × 2^(−idle/halfLife) +// +// where idle = now − lastEvalAt. A nil lastEvalAt is a legacy record from +// before decay landed — there is no anchor to decay from, so it is taken at +// face value. +func EffectiveCompleted(completed int, lastEvalAt *time.Time, now time.Time, halfLife time.Duration) float64 { + if lastEvalAt == nil || halfLife <= 0 { + return float64(completed) + } + idle := now.Sub(*lastEvalAt) + if idle <= 0 { + return float64(completed) + } + return float64(completed) * math.Exp2(-float64(idle)/float64(halfLife)) +} + +// EffectiveTier is the read-time tier gate: a stored "Full" record whose +// decayed completion count has fallen below the task's probation threshold +// AND whose idle time exceeds the half-life is treated as Probation for +// selection purposes — stale reputation buys a discounted seat, not a full +// one. Every other case returns the stored tier unchanged (legacy records +// with no lastEvalAt anchor are never demoted). +func EffectiveTier(record monetizeapi.EvaluatorLadderRecord, ladder Ladder, now time.Time) string { + if record.Tier != monetizeapi.EvaluatorTierFull || record.LastEvalAt == nil { + return record.Tier + } + halfLife := ladder.DecayHalfLifeDuration() + if now.Sub(record.LastEvalAt.Time) <= halfLife { + return record.Tier + } + if EffectiveCompleted(int(record.Completed), &record.LastEvalAt.Time, now, halfLife) < float64(ladder.ProbationEvals) { + return monetizeapi.EvaluatorTierProbation + } + return record.Tier +} diff --git a/internal/bounty/decay_test.go b/internal/bounty/decay_test.go new file mode 100644 index 00000000..8ba44a42 --- /dev/null +++ b/internal/bounty/decay_test.go @@ -0,0 +1,115 @@ +package bounty + +import ( + "math" + "testing" + "time" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const halfLife = 720 * time.Hour + +func TestEffectiveCompleted_HalvesAfterOneHalfLife(t *testing.T) { + now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + last := now.Add(-halfLife) + got := EffectiveCompleted(10, &last, now, halfLife) + if math.Abs(got-5.0) > 1e-9 { + t.Fatalf("EffectiveCompleted after one half-life = %v, want 5.0", got) + } + last2 := now.Add(-2 * halfLife) + if got := EffectiveCompleted(10, &last2, now, halfLife); math.Abs(got-2.5) > 1e-9 { + t.Fatalf("EffectiveCompleted after two half-lives = %v, want 2.5", got) + } +} + +func TestEffectiveCompleted_NilLastEvalNoDecay(t *testing.T) { + now := time.Now() + if got := EffectiveCompleted(10, nil, now, halfLife); got != 10.0 { + t.Fatalf("legacy record (nil lastEvalAt) must not decay, got %v", got) + } +} + +func TestEffectiveCompleted_FreshAndZeroHalfLife(t *testing.T) { + now := time.Now() + fresh := now + if got := EffectiveCompleted(7, &fresh, now, halfLife); got != 7.0 { + t.Fatalf("zero idle must not decay, got %v", got) + } + future := now.Add(time.Hour) + if got := EffectiveCompleted(7, &future, now, halfLife); got != 7.0 { + t.Fatalf("clock-skewed future lastEvalAt must not decay, got %v", got) + } + old := now.Add(-halfLife) + if got := EffectiveCompleted(7, &old, now, 0); got != 7.0 { + t.Fatalf("non-positive half-life must disable decay, got %v", got) + } +} + +func TestEffectiveTier_StaleFullDemotedToProbation(t *testing.T) { + now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + ladder := Ladder{ProbationEvals: 10, DecayHalfLife: "720h"} + record := monetizeapi.EvaluatorLadderRecord{ + Tier: monetizeapi.EvaluatorTierFull, + Completed: 10, + LastEvalAt: &metav1.Time{Time: now.Add(-2 * halfLife)}, // effective 2.5 < 10 + } + if got := EffectiveTier(record, ladder, now); got != monetizeapi.EvaluatorTierProbation { + t.Fatalf("stale Full must read as Probation, got %s", got) + } +} + +func TestEffectiveTier_FreshFullStaysFull(t *testing.T) { + now := time.Now() + ladder := Ladder{ProbationEvals: 10, DecayHalfLife: "720h"} + record := monetizeapi.EvaluatorLadderRecord{ + Tier: monetizeapi.EvaluatorTierFull, + Completed: 10, + LastEvalAt: &metav1.Time{Time: now.Add(-halfLife / 2)}, // idle under the half-life + } + if got := EffectiveTier(record, ladder, now); got != monetizeapi.EvaluatorTierFull { + t.Fatalf("Full within the half-life must stay Full, got %s", got) + } +} + +func TestEffectiveTier_HighVolumeFullSurvivesIdle(t *testing.T) { + now := time.Now() + ladder := Ladder{ProbationEvals: 10, DecayHalfLife: "720h"} + record := monetizeapi.EvaluatorLadderRecord{ + Tier: monetizeapi.EvaluatorTierFull, + Completed: 100, // effective 25 after two half-lives, still ≥ 10 + LastEvalAt: &metav1.Time{Time: now.Add(-2 * halfLife)}, + } + if got := EffectiveTier(record, ladder, now); got != monetizeapi.EvaluatorTierFull { + t.Fatalf("high-volume Full must survive the idle window, got %s", got) + } +} + +func TestEffectiveTier_LegacyAndNonFullUntouched(t *testing.T) { + now := time.Now() + ladder := Ladder{ProbationEvals: 10, DecayHalfLife: "720h"} + legacy := monetizeapi.EvaluatorLadderRecord{Tier: monetizeapi.EvaluatorTierFull, Completed: 1} + if got := EffectiveTier(legacy, ladder, now); got != monetizeapi.EvaluatorTierFull { + t.Fatalf("legacy record (nil lastEvalAt) must keep its stored tier, got %s", got) + } + shadow := monetizeapi.EvaluatorLadderRecord{ + Tier: monetizeapi.EvaluatorTierShadow, + LastEvalAt: &metav1.Time{Time: now.Add(-10 * halfLife)}, + } + if got := EffectiveTier(shadow, ladder, now); got != monetizeapi.EvaluatorTierShadow { + t.Fatalf("non-Full tiers are never demoted further, got %s", got) + } +} + +func TestDecayHalfLifeDuration(t *testing.T) { + if got := (Ladder{}).DecayHalfLifeDuration(); got != defaultDecayHalfLife { + t.Fatalf("zero ladder must default to %v, got %v", defaultDecayHalfLife, got) + } + if got := (Ladder{DecayHalfLife: "48h"}).DecayHalfLifeDuration(); got != 48*time.Hour { + t.Fatalf("parseable half-life = %v, want 48h", got) + } + if got := (Ladder{DecayHalfLife: "soon"}).DecayHalfLifeDuration(); got != defaultDecayHalfLife { + t.Fatalf("unparseable half-life must default, got %v", got) + } +} diff --git a/internal/bounty/registry.go b/internal/bounty/registry.go index a0cc4f1e..bba19057 100644 --- a/internal/bounty/registry.go +++ b/internal/bounty/registry.go @@ -58,6 +58,21 @@ type Ladder struct { // NonRevealPenalty grades a missing reveal; "outlier" treats it as a // worst-case divergence so silent abstention is never the cheap exit. NonRevealPenalty string `yaml:"nonRevealPenalty"` + + // DecayHalfLife is the reputation half-life: ladder weight earned by an + // evaluator halves every window of inactivity past lastEvalAt. + DecayHalfLife string `yaml:"decayHalfLife"` + + // EscalationWindow is the second-round commit→reveal duration when a + // diverged quorum escalates to a fresh, larger panel. + EscalationWindow string `yaml:"escalationWindow"` + + // EscalationEpsilon is the knife-edge band: when the quorum median lands + // within epsilon score points of the pass threshold, the verdict + // escalates to a fresh 2k+1 panel instead of settling. 0 means "unset" + // and backfills to the default (5); use a NEGATIVE value to disable the + // knife-edge trigger for a task package. + EscalationEpsilon int `yaml:"escalationEpsilon"` } type Eval struct { @@ -144,9 +159,25 @@ func Load(name string) (TaskType, error) { return TaskType{}, fmt.Errorf("task type %q: missing id", name) } + applyLadderDefaults(&t.Eval.Ladder) + return t, nil } +// applyLadderDefaults backfills ladder knobs a task package omits, so older +// packages keep working when the ladder grows a field. +func applyLadderDefaults(l *Ladder) { + if l.DecayHalfLife == "" { + l.DecayHalfLife = "720h" + } + if l.EscalationWindow == "" { + l.EscalationWindow = "30m" + } + if l.EscalationEpsilon == 0 { + l.EscalationEpsilon = 5 + } +} + // Available returns every embedded task type (enabled or not), sorted by id. func Available() ([]TaskType, error) { names, err := embed.GetAvailableBountyTasks() diff --git a/internal/bounty/registry_test.go b/internal/bounty/registry_test.go index 13a722cc..3c57699c 100644 --- a/internal/bounty/registry_test.go +++ b/internal/bounty/registry_test.go @@ -65,6 +65,15 @@ func TestEnabled_IncludesBenchmark(t *testing.T) { if ladder.NonRevealPenalty != "outlier" { t.Errorf("ladder.nonRevealPenalty = %q, want outlier (non-reveal must cost >= divergence)", ladder.NonRevealPenalty) } + if ladder.DecayHalfLife != "720h" { + t.Errorf("ladder.decayHalfLife = %q, want 720h (reputation must decay with inactivity)", ladder.DecayHalfLife) + } + if ladder.EscalationWindow != "30m" { + t.Errorf("ladder.escalationWindow = %q, want 30m (escalation rounds need their own reveal window)", ladder.EscalationWindow) + } + if ladder.EscalationEpsilon != 5 { + t.Errorf("ladder.escalationEpsilon = %d, want 5 (diverged quorums must escalate, not settle)", ladder.EscalationEpsilon) + } // Report variants drive a2ui catalog negotiation: the first variant whose // catalogId the client advertises wins. The lean default is declarative; @@ -94,6 +103,29 @@ func TestEnabled_IncludesBenchmark(t *testing.T) { } } +// applyLadderDefaults backfills knobs older task packages omit — without it a +// package missing decayHalfLife/escalationWindow/escalationEpsilon would have +// undecaying reputation and unescalatable verdicts. +func TestApplyLadderDefaults(t *testing.T) { + var l Ladder + applyLadderDefaults(&l) + if l.DecayHalfLife != "720h" { + t.Errorf("default decayHalfLife = %q, want 720h", l.DecayHalfLife) + } + if l.EscalationWindow != "30m" { + t.Errorf("default escalationWindow = %q, want 30m", l.EscalationWindow) + } + if l.EscalationEpsilon != 5 { + t.Errorf("default escalationEpsilon = %d, want 5", l.EscalationEpsilon) + } + + set := Ladder{DecayHalfLife: "24h", EscalationWindow: "1h", EscalationEpsilon: 9} + applyLadderDefaults(&set) + if set.DecayHalfLife != "24h" || set.EscalationWindow != "1h" || set.EscalationEpsilon != 9 { + t.Errorf("explicit ladder values overwritten: %+v", set) + } +} + func TestResolve(t *testing.T) { for _, ref := range []string{"benchmark", "benchmark@v1"} { got, err := Resolve(ref) diff --git a/internal/defaults/defaults.go b/internal/defaults/defaults.go index 9083180d..96a4973a 100644 --- a/internal/defaults/defaults.go +++ b/internal/defaults/defaults.go @@ -138,6 +138,7 @@ var devLocallyBuiltImageBases = []string{ "ghcr.io/obolnetwork/x402-verifier", "ghcr.io/obolnetwork/serviceoffer-controller", "ghcr.io/obolnetwork/x402-buyer", + "ghcr.io/obolnetwork/x402-escrow", "ghcr.io/obolnetwork/demo-server", "ghcr.io/obolnetwork/obol-stack-public-storefront", } diff --git a/internal/defaults/defaults_test.go b/internal/defaults/defaults_test.go index 60d84ef3..67fd6260 100644 --- a/internal/defaults/defaults_test.go +++ b/internal/defaults/defaults_test.go @@ -41,11 +41,15 @@ func TestCopyInfrastructure_DevModeRewritesDigestPins(t *testing.T) { for _, base := range []string{ "ghcr.io/obolnetwork/x402-verifier", "ghcr.io/obolnetwork/serviceoffer-controller", + "ghcr.io/obolnetwork/x402-escrow", } { want := base + ":" + devTag if !strings.Contains(out, want) { t.Errorf("dev mode did not rewrite to %q in %s", want, x402Path) } + if strings.Contains(out, base+":"+devTag+"@sha256:") { + t.Errorf("dev mode left orphan @sha256: suffix on %s:%s in %s — regex missed the combo form", base, devTag, x402Path) + } } // The persisted dev tag MUST equal what was stamped into the manifests, or @@ -79,6 +83,51 @@ func TestCopyInfrastructure_DevModeRewritesDigestPins(t *testing.T) { } } +// TestRewriteDevDigestPins_ComboFormAllBases pins the rewrite behaviour for +// every locally-built base — including ghcr.io/obolnetwork/x402-escrow — +// against all three pin styles, with the combo `@sha256:` form +// exercised explicitly. The embedded manifests don't carry every base in +// every style (x402-escrow ships tag-only until the first publish), so this +// synthetic file guarantees a future digest bump can't resurrect the +// orphan-@sha256 bug for a base the real tree happens not to cover today. +func TestRewriteDevDigestPins_ComboFormAllBases(t *testing.T) { + dir := t.TempDir() + + digest := strings.Repeat("ab12", 16) // 64 hex chars + var lines []string + for _, base := range devLocallyBuiltImageBases { + lines = append(lines, + "image: "+base+":b13254e@sha256:"+digest, // combo tag+digest + "image: "+base+"@sha256:"+digest, // digest-only + "image: "+base+":b13254e", // short-SHA tag + ) + } + path := filepath.Join(dir, "synthetic.yaml") + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatalf("write synthetic manifest: %v", err) + } + + if err := rewriteDevDigestPins(dir, "dev-test"); err != nil { + t.Fatalf("rewriteDevDigestPins: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read rewritten manifest: %v", err) + } + out := string(data) + + if strings.Contains(out, "@sha256:") { + t.Errorf("rewrite left a @sha256: pin behind (orphan-suffix combo bug):\n%s", out) + } + for _, base := range devLocallyBuiltImageBases { + want := "image: " + base + ":dev-test" + if got := strings.Count(out, want); got != 3 { + t.Errorf("base %s: %d of 3 pin styles rewritten to %q:\n%s", base, got, want, out) + } + } +} + func TestDevImageTag_Format(t *testing.T) { // Tests run inside the git checkout, so expect dev-; tolerate the // :latest fallback for non-git build environments. diff --git a/internal/embed/bountytasks/benchlocal/task.yaml b/internal/embed/bountytasks/benchlocal/task.yaml index 7686201d..fbc29e49 100644 --- a/internal/embed/bountytasks/benchlocal/task.yaml +++ b/internal/embed/bountytasks/benchlocal/task.yaml @@ -66,6 +66,9 @@ eval: probationValueCap: "50.00" revealWindow: 10m nonRevealPenalty: outlier + decayHalfLife: 720h + escalationWindow: 30m + escalationEpsilon: 5 # Pack scores are hardware-agnostic (pass/fail scoring), so self-report is the # honest default; bounties pinning a specific GPU should post with diff --git a/internal/embed/bountytasks/benchmark/task.yaml b/internal/embed/bountytasks/benchmark/task.yaml index 33bd1d2d..4d4f3504 100644 --- a/internal/embed/bountytasks/benchmark/task.yaml +++ b/internal/embed/bountytasks/benchmark/task.yaml @@ -72,6 +72,9 @@ eval: probationValueCap: "50.00" # reward (human units) above which no probation seat is offered revealWindow: 10m # commit→reveal window; every commit closes before any reveal opens nonRevealPenalty: outlier # non-reveal is graded as a worst-case outlier (>= divergence penalty) + decayHalfLife: 720h # reputation half-life: ladder weight halves per window of inactivity + escalationWindow: 30m # second-round commit→reveal window when a diverged quorum escalates + escalationEpsilon: 5 # max spread (score points) between counting reveals before escalation # hardwareProof — self-report is a reputation-backed CLAIM (forgeable text). # Throughput-flavored bounties should require gpu-attestation or diff --git a/internal/embed/bountytasks/finetune/task.yaml b/internal/embed/bountytasks/finetune/task.yaml index 02a3eb31..635eec56 100644 --- a/internal/embed/bountytasks/finetune/task.yaml +++ b/internal/embed/bountytasks/finetune/task.yaml @@ -58,6 +58,9 @@ eval: probationValueCap: "50.00" revealWindow: 10m nonRevealPenalty: outlier + decayHalfLife: 720h + escalationWindow: 30m + escalationEpsilon: 5 hardwareProof: self-report diff --git a/internal/embed/infrastructure/base/templates/evaluatorenrollment-crd.yaml b/internal/embed/infrastructure/base/templates/evaluatorenrollment-crd.yaml index ec372bd6..ca5d6dde 100644 --- a/internal/embed/infrastructure/base/templates/evaluatorenrollment-crd.yaml +++ b/internal/embed/infrastructure/base/templates/evaluatorenrollment-crd.yaml @@ -108,6 +108,17 @@ spec: reveals) — the negative reputation signal. format: int64 type: integer + groundedEvals: + description: |- + GroundedEvals counts settled seats whose verdict was grounded by an + on-chain ERC-8004 validation entry. + type: integer + lastEvalAt: + description: |- + LastEvalAt is when this evaluator's most recent seat settled — the + anchor for reputation decay (decayHalfLife). + format: date-time + type: string probationEvals: description: |- ProbationEvals counts paid in-band evals while on Probation (promotion diff --git a/internal/embed/infrastructure/base/templates/servicebounty-crd.yaml b/internal/embed/infrastructure/base/templates/servicebounty-crd.yaml index 211c0348..31a1472a 100644 --- a/internal/embed/infrastructure/base/templates/servicebounty-crd.yaml +++ b/internal/embed/infrastructure/base/templates/servicebounty-crd.yaml @@ -389,6 +389,115 @@ spec: - type type: object type: array + escalation: + description: |- + Escalation is the second-round eval state opened when the first-round + quorum diverges beyond the task's escalation epsilon. + properties: + budgetState: + description: |- + BudgetState tracks the escalation eval budget at the escrow gateway: + Reserved | Captured | Voided. + type: string + evaluations: + description: Evaluations are the escalation round's commit-reveal + records. + items: + description: |- + ServiceBountyEvaluation is one evaluator's commit-reveal record. WithinBand + is the per-bounty ladder bookkeeping hook: divergence from the quorum median + (or a missing/invalid reveal) is what future reputation feedback keys on. + properties: + address: + description: Address is the evaluator's payout/identity + address (annotation key suffix). + type: string + commitHash: + description: CommitHash = EvalCommitHash(score, salt, address), + promoted first-write-wins. + type: string + grounded: + description: |- + Grounded marks a verdict backed by an on-chain ERC-8004 validation + entry observed for this bounty's eval-request hash — the chain-anchored + reputation signal, as opposed to an annotation-only reveal. + type: boolean + paid: + description: |- + Paid marks inclusion in the eval-budget batch settlement (counting + seats that revealed validly; shadows evaluate free). + type: boolean + phase: + description: 'Phase: Committed | Revealed | BadReveal | + NonReveal.' + type: string + revealedAt: + description: RevealedAt records when a valid reveal was + promoted. + format: date-time + type: string + score: + description: Score is the revealed 0-100 verdict (ERC-8004 + validationResponse semantics). + format: int64 + type: integer + seat: + description: |- + Seat mirrors the panel seat kind (full | probation | shadow); empty in + open-door mode. + type: string + validationTxHash: + description: |- + ValidationTxHash is the evaluator-submitted ERC-8004 validationResponse + transaction, recorded as provenance (the evaluator's OWN wallet signs; + the controller never does). + type: string + withinBand: + description: |- + WithinBand is false for NonReveal/BadReveal and for revealed scores + outside the outlier band around the quorum median. + type: boolean + type: object + type: array + panel: + description: Panel is the escalation-round seat assignment. + items: + description: ServiceBountyPanelSeat is one selected evaluator + seat. + properties: + address: + description: Address is the enrolled evaluator's address. + type: string + seat: + description: 'Seat: full | probation | shadow.' + type: string + type: object + type: array + reason: + description: Reason records why the escalation opened (e.g. quorum + divergence). + type: string + revealDeadline: + description: RevealDeadline is the escalation round's commit→reveal + cutoff. + format: date-time + type: string + round: + description: Round is the escalation round number (1 = first escalation). + type: integer + voucherDeadline: + description: VoucherDeadline is when the escalation eval-budget + voucher expires. + format: date-time + type: string + required: + - round + type: object + escrowSpender: + description: |- + EscrowSpender is the facilitator address Permit2 vouchers must name as + the only executor (Receipt.Spender echoed into status for signers). + type: string escrowState: description: 'EscrowState: Reserved | Captured | Voided (held auth at the facilitator).' @@ -421,6 +530,12 @@ spec: description: CommitHash = EvalCommitHash(score, salt, address), promoted first-write-wins. type: string + grounded: + description: |- + Grounded marks a verdict backed by an on-chain ERC-8004 validation + entry observed for this bounty's eval-request hash — the chain-anchored + reputation signal, as opposed to an annotation-only reveal. + type: boolean paid: description: |- Paid marks inclusion in the eval-budget batch settlement (counting @@ -480,6 +595,28 @@ spec: observedGeneration: format: int64 type: integer + panelSeed: + description: |- + PanelSeed records the randomness source the evaluator panel was drawn + from, so the sampling is auditable (drand round, raw randomness, sig). + properties: + randomness: + description: Randomness is the beacon output the panel sampling + was keyed on. + type: string + round: + description: Round is the drand round the randomness came from. + format: int64 + type: integer + signature: + description: Signature is the beacon signature proving the randomness. + type: string + source: + description: Source names the randomness origin (e.g. drand, local-dev). + type: string + required: + - source + type: object phase: type: string refundTxHash: diff --git a/internal/embed/infrastructure/base/templates/x402.yaml b/internal/embed/infrastructure/base/templates/x402.yaml index 7c28f02a..a4bbefaa 100644 --- a/internal/embed/infrastructure/base/templates/x402.yaml +++ b/internal/embed/infrastructure/base/templates/x402.yaml @@ -2,6 +2,9 @@ # x402 runtime components: # - x402-verifier: shared seller-owned x402 gateway (and legacy /verify endpoint) # - serviceoffer-controller: control-plane reconciler for ServiceOffer child resources +# - x402-escrow: ServiceBounty escrow facilitator (verify/hold Permit2 vouchers, +# settle permitTransferFrom on capture). ClusterIP-internal ONLY — never +# routed through Traefik or the tunnel. apiVersion: v1 kind: Namespace metadata: @@ -391,6 +394,18 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + # ServiceBounty escrow seam. The escrow endpoint + credential come + # ONLY from controller env (never from CR spec or annotations) — + # see newBountyEscrowGateway. The controller holds no keys: it is + # a bounded release trigger against the escrow facilitator. + - name: OBOL_BOUNTY_ESCROW_URL + value: "http://x402-escrow.x402.svc.cluster.local:8403" + - name: OBOL_BOUNTY_ESCROW_TOKEN + valueFrom: + secretKeyRef: + name: x402-escrow + key: token + optional: true args: [] resources: requests: @@ -404,6 +419,144 @@ spec: # 256Mi and triggered OOMKilled restart loops. memory: 512Mi +--- +# x402-escrow: the ServiceBounty escrow facilitator. Holds poster-signed +# Permit2 batch-transfer vouchers (reserve), settles permitTransferFrom +# on-chain (capture), drops holds store-only (void). +# +# Security posture: +# - ClusterIP-internal ONLY. No Traefik HTTPRoute, no Middleware, no tunnel +# exposure — the only callers are the serviceoffer-controller (via +# OBOL_BOUNTY_ESCROW_URL) and in-cluster operators. +# - The serviceoffer-controller never signs and holds no keys; the escrow +# settlement key/credentials live ONLY in the optional `x402-escrow` +# Secret consumed here (keys: `token` = bearer auth for POST /escrow/*, +# `key` = hex settlement private key). Both are optional: with no token +# the routes are unauthenticated (dev); with no key, capture returns 503 +# while reserve/void keep working. +# - State is an emptyDir: escrow entries are vouchers + receipts, and a +# voucher lost PRE-capture only means the poster re-attaches it (reserve +# is idempotent and re-runs from the obol.org/*-voucher annotations). +# Captured receipts are also recorded on-chain, so replay after pod churn +# re-converges from the chain + annotation channel. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: x402-escrow + namespace: x402 + labels: + app: x402-escrow +spec: + # Single replica: the file-backed store serializes per-id operations + # in-process; multiple replicas would race reserve/capture on the same id. + replicas: 1 + selector: + matchLabels: + app: x402-escrow + template: + metadata: + labels: + app: x402-escrow + spec: + # PSS Restricted: pod-level identity, same posture as the verifier. + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: escrow + # Pinned like the sibling images in this file. The publish workflow + # adds the @sha256 digest on the first image-bump PR (Renovate); + # under OBOL_DEVELOPMENT the pin is rewritten to the local dev tag + # (internal/defaults.rewriteDevDigestPins, lockstep with + # internal/stack.baseLocalImages). + image: ghcr.io/obolnetwork/x402-escrow:04bebbc + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + ports: + - name: http + containerPort: 8403 + protocol: TCP + args: + - --listen=:8403 + env: + # Bearer token for POST /escrow/* (reserve/capture/void). Empty = + # unauthenticated (dev-only; the controller gateway symmetrically + # omits the Authorization header when its token is empty). + - name: OBOL_ESCROW_TOKEN + valueFrom: + secretKeyRef: + name: x402-escrow + key: token + optional: true + # Hex settlement key (the Permit2 voucher spender). Optional: + # without it capture returns 503 and vouchers cannot be + # spender-bound, but voucher-less reserve/void still work. + - name: OBOL_ESCROW_KEY + valueFrom: + secretKeyRef: + name: x402-escrow + key: key + optional: true + - name: OBOL_ESCROW_RPC_BASE + value: "http://erpc.erpc.svc.cluster.local/rpc" + # File-backed JSON store on an emptyDir: losing it pre-capture + # only means re-attaching the voucher (see header comment). + - name: OBOL_ESCROW_STATE_DIR + value: "/data" + volumeMounts: + - name: escrow-state + mountPath: /data + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi + volumes: + - name: escrow-state + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: x402-escrow + namespace: x402 + labels: + app: x402-escrow +spec: + type: ClusterIP + selector: + app: x402-escrow + ports: + - name: http + port: 8403 + targetPort: http + protocol: TCP + --- apiVersion: v1 kind: Service diff --git a/internal/enclave/enclave_darwin.go b/internal/enclave/enclave_darwin.go index 215fcd4a..db0c8479 100644 --- a/internal/enclave/enclave_darwin.go +++ b/internal/enclave/enclave_darwin.go @@ -344,8 +344,8 @@ func (k *seKey) Sign(digest []byte) ([]byte, error) { ) if n == 0 { msg := cfStringToGo(errStr) - if unsafe.Pointer(errStr) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + if errStr != 0 { + C.CFRelease(C.CFTypeRef(errStr)) } return nil, fmt.Errorf("enclave: Sign failed: %s", msg) } @@ -370,8 +370,8 @@ func (k *seKey) ECDH(peerPubKeyBytes []byte) ([]byte, error) { ) if n == 0 { msg := cfStringToGo(errStr) - if unsafe.Pointer(errStr) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + if errStr != 0 { + C.CFRelease(C.CFTypeRef(errStr)) } return nil, fmt.Errorf("enclave: ECDH failed: %s", msg) } @@ -413,14 +413,14 @@ func newKey(tag string) (Key, error) { var errStr C.CFStringRef privRef := C.create_se_key(ctag, C.int(1), &errCode, &errStr) //nolint:gocritic // CGo pointer arguments, not duplicate subexpressions - if unsafe.Pointer(privRef) != nil { + if privRef != 0 { // Success — key is in keychain. - if unsafe.Pointer(errStr) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + if errStr != 0 { + C.CFRelease(C.CFTypeRef(errStr)) } pub, err := extractPublicKey(privRef) if err != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(privRef))) + C.CFRelease(C.CFTypeRef(privRef)) return nil, err } return &seKey{privRef: privRef, tag: tag, pubKey: pub, persistent: true}, nil @@ -430,32 +430,32 @@ func newKey(tag string) (Key, error) { // fall back to an ephemeral key (dev/test use without code-signing). if C.int(errCode) != C.OBOL_ERR_SEC_MISSING_ENTITLEMENT { msg := cfStringToGo(errStr) - if unsafe.Pointer(errStr) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + if errStr != 0 { + C.CFRelease(C.CFTypeRef(errStr)) } return nil, fmt.Errorf("enclave: create_se_key (persistent): %s", msg) } - if unsafe.Pointer(errStr) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + if errStr != 0 { + C.CFRelease(C.CFTypeRef(errStr)) } // Ephemeral fallback. var errStr2 C.CFStringRef privRef = C.create_se_key(ctag, C.int(0), &errCode, &errStr2) //nolint:gocritic // CGo pointer arguments, not duplicate subexpressions - if unsafe.Pointer(privRef) == nil { + if privRef == 0 { msg := cfStringToGo(errStr2) - if unsafe.Pointer(errStr2) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr2))) + if errStr2 != 0 { + C.CFRelease(C.CFTypeRef(errStr2)) } return nil, fmt.Errorf("enclave: create_se_key (ephemeral fallback): %s", msg) } - if unsafe.Pointer(errStr2) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr2))) + if errStr2 != 0 { + C.CFRelease(C.CFTypeRef(errStr2)) } pub, err := extractPublicKey(privRef) if err != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(privRef))) + C.CFRelease(C.CFTypeRef(privRef)) return nil, err } k := &seKey{privRef: privRef, tag: tag, pubKey: pub, persistent: false} @@ -475,23 +475,23 @@ func loadKey(tag string) (Key, error) { privRef := C.load_se_key(ctag, &found, &errStr) //nolint:gocritic // CGo pointer arguments, not duplicate subexpressions if found == 0 { - if unsafe.Pointer(errStr) != nil { + if errStr != 0 { msg := cfStringToGo(errStr) - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + C.CFRelease(C.CFTypeRef(errStr)) return nil, fmt.Errorf("enclave: load_se_key: %s", msg) } return nil, ErrKeyNotFound } - if unsafe.Pointer(privRef) == nil { + if privRef == 0 { return nil, ErrKeyNotFound } - if unsafe.Pointer(errStr) != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(errStr))) + if errStr != 0 { + C.CFRelease(C.CFTypeRef(errStr)) } pub, err := extractPublicKey(privRef) if err != nil { - C.CFRelease(C.CFTypeRef(unsafe.Pointer(privRef))) + C.CFRelease(C.CFTypeRef(privRef)) return nil, err } @@ -590,7 +590,7 @@ func extractPublicKey(privRef C.SecKeyRef) ([]byte, error) { // cfStringToGo converts a CFStringRef to a Go string. func cfStringToGo(s C.CFStringRef) string { - if unsafe.Pointer(s) == nil { + if s == 0 { return "(no error description)" } cstr := C.cfstring_to_c(s) diff --git a/internal/enclave/enclave_stub.go b/internal/enclave/enclave_stub.go index b3c46460..fc38d7ca 100644 --- a/internal/enclave/enclave_stub.go +++ b/internal/enclave/enclave_stub.go @@ -5,16 +5,16 @@ package enclave // stubKey satisfies the Key interface on unsupported platforms. type stubKey struct{ tag string } -func (s *stubKey) PublicKeyBytes() []byte { return nil } -func (s *stubKey) Sign(_ []byte) ([]byte, error) { return nil, ErrNotSupported } -func (s *stubKey) ECDH(_ []byte) ([]byte, error) { return nil, ErrNotSupported } -func (s *stubKey) Decrypt(_ []byte) ([]byte, error) { return nil, ErrNotSupported } -func (s *stubKey) Tag() string { return s.tag } -func (s *stubKey) Persistent() bool { return false } -func (s *stubKey) Delete() error { return ErrNotSupported } +func (s *stubKey) PublicKeyBytes() []byte { return nil } +func (s *stubKey) Sign(_ []byte) ([]byte, error) { return nil, ErrNotSupported } +func (s *stubKey) ECDH(_ []byte) ([]byte, error) { return nil, ErrNotSupported } +func (s *stubKey) Decrypt(_ []byte) ([]byte, error) { return nil, ErrNotSupported } +func (s *stubKey) Tag() string { return s.tag } +func (s *stubKey) Persistent() bool { return false } +func (s *stubKey) Delete() error { return ErrNotSupported } -func newKey(_ string) (Key, error) { return nil, ErrNotSupported } -func loadKey(_ string) (Key, error) { return nil, ErrNotSupported } -func deleteKey(_ string) error { return ErrNotSupported } -func checkSIP() error { return ErrNotSupported } -func decrypt(_ string, _ []byte) ([]byte, error) { return nil, ErrNotSupported } +func newKey(_ string) (Key, error) { return nil, ErrNotSupported } +func loadKey(_ string) (Key, error) { return nil, ErrNotSupported } +func deleteKey(_ string) error { return ErrNotSupported } +func checkSIP() error { return ErrNotSupported } +func decrypt(_ string, _ []byte) ([]byte, error) { return nil, ErrNotSupported } diff --git a/internal/erc8004/bounty.go b/internal/erc8004/bounty.go new file mode 100644 index 00000000..b84d4b5a --- /dev/null +++ b/internal/erc8004/bounty.go @@ -0,0 +1,26 @@ +// ServiceBounty ↔ ERC-8004 grounding: the eval-request hash binds an +// evaluator's on-chain validationResponse to one specific bounty + evaluator +// pair, so an annotation-level reveal can be checked against a chain-anchored +// entry (a "grounded" verdict). + +package erc8004 + +import ( + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// bountyEvalDomain is the versioned domain prefix for bounty eval-request +// hashes. Changing it is a breaking change for every grounded verdict. +const bountyEvalDomain = "obol/bounty-eval/v1" + +// BountyEvalRequestHash derives the ERC-8004 validation request hash for one +// (bounty, evaluator) pair: keccak256 of the exact ASCII bytes +// "obol/bounty-eval/v1||". The CLI (evaluator +// side, submitting validationResponse) and the controller (grounding side, +// matching chain entries) MUST compute this identically. +func BountyEvalRequestHash(bountyUID, evaluator string) common.Hash { + return crypto.Keccak256Hash([]byte(bountyEvalDomain + "|" + bountyUID + "|" + strings.ToLower(evaluator))) +} diff --git a/internal/erc8004/bounty_test.go b/internal/erc8004/bounty_test.go new file mode 100644 index 00000000..1f4af417 --- /dev/null +++ b/internal/erc8004/bounty_test.go @@ -0,0 +1,35 @@ +package erc8004 + +import "testing" + +// TestBountyEvalRequestHash_Golden pins the exact preimage layout +// ("obol/bounty-eval/v1||"). The CLI signs +// validationResponses against this hash and the controller grounds verdicts +// by matching it on-chain — any drift silently breaks grounding, so the +// vector is hardcoded, not recomputed. +func TestBountyEvalRequestHash_Golden(t *testing.T) { + const ( + bountyUID = "8b9af0d4-9c3e-4a64-b1d0-2f50f2a1c111" + evaluator = "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01" + golden = "0x22683f2360f35f41b5e5122865e048bb0dcb3b7896fc7280545fb09fbfdfa51a" + ) + + if got := BountyEvalRequestHash(bountyUID, evaluator).Hex(); got != golden { + t.Errorf("BountyEvalRequestHash = %s, want %s", got, golden) + } + + // The evaluator address is lowercased into the preimage: checksummed and + // lowercase forms of the same address must ground identically. + lower := BountyEvalRequestHash(bountyUID, "0xabcdef0123456789abcdef0123456789abcdef01") + if lower.Hex() != golden { + t.Errorf("lowercase evaluator hash = %s, want %s (address must be case-insensitive)", lower.Hex(), golden) + } + + // Different bounty or evaluator must never collide with the golden pair. + if BountyEvalRequestHash("other-uid", evaluator).Hex() == golden { + t.Error("different bountyUID produced the golden hash") + } + if BountyEvalRequestHash(bountyUID, "0x0000000000000000000000000000000000000001").Hex() == golden { + t.Error("different evaluator produced the golden hash") + } +} diff --git a/internal/erc8004/networks_test.go b/internal/erc8004/networks_test.go index 034bd7d6..e8eb670a 100644 --- a/internal/erc8004/networks_test.go +++ b/internal/erc8004/networks_test.go @@ -41,7 +41,7 @@ func TestResolveNetworks(t *testing.T) { {"base-sepolia", 1, false}, {"mainnet,base", 2, false}, {"base-sepolia,base,ethereum", 3, false}, - {"base,base", 1, false}, // deduplicate + {"base,base", 1, false}, // deduplicate {"mainnet,ethereum", 1, false}, // same network, different aliases {"", 0, true}, {"unknown", 0, true}, diff --git a/internal/erc8004/revert.go b/internal/erc8004/revert.go index bf8d3198..3f7d0c68 100644 --- a/internal/erc8004/revert.go +++ b/internal/erc8004/revert.go @@ -19,8 +19,8 @@ import ( // - revert("message") → ABI-encoded Error(string) → "message" // - panic(N) → ABI-encoded Panic(uint256) → "panic: " // - revert CustomError(...) → 4-byte selector with no public ABI → -// "custom error 0x" (so an -// operator can grep the contract source) +// "custom error 0x" (so an +// operator can grep the contract source) // // The whole point: when an ERC-8004 setMetadata reverts at gas-estimation // time, the Geth/Reth node returns the revert payload as the `data` field of diff --git a/internal/monetizeapi/evaluatorenrollment.go b/internal/monetizeapi/evaluatorenrollment.go index ae352b6c..16d60a8c 100644 --- a/internal/monetizeapi/evaluatorenrollment.go +++ b/internal/monetizeapi/evaluatorenrollment.go @@ -106,6 +106,14 @@ type EvaluatorLadderRecord struct { // RecentFulfillers are the last few fulfiller addresses this evaluator // judged — the pair-diversity rule down-weights repeat pairings. RecentFulfillers []string `json:"recentFulfillers,omitempty"` + + // LastEvalAt is when this evaluator's most recent seat settled — the + // anchor for reputation decay (decayHalfLife). + LastEvalAt *metav1.Time `json:"lastEvalAt,omitempty"` + + // GroundedEvals counts settled seats whose verdict was grounded by an + // on-chain ERC-8004 validation entry. + GroundedEvals int `json:"groundedEvals,omitempty"` } // ── deepcopy (hand-written, matching the package idiom) ───────────────────── @@ -189,4 +197,8 @@ func (in *EvaluatorLadderRecord) DeepCopyInto(out *EvaluatorLadderRecord) { out.RecentFulfillers = make([]string, len(in.RecentFulfillers)) copy(out.RecentFulfillers, in.RecentFulfillers) } + if in.LastEvalAt != nil { + l, m := &in.LastEvalAt, &out.LastEvalAt + *m = (*l).DeepCopy() + } } diff --git a/internal/monetizeapi/servicebounty.go b/internal/monetizeapi/servicebounty.go index 3e8c2fd7..bdcbfbfd 100644 --- a/internal/monetizeapi/servicebounty.go +++ b/internal/monetizeapi/servicebounty.go @@ -313,6 +313,60 @@ type ServiceBountyStatus struct { // Reserved | Returned (success/honest timeout) | Forfeited (rejected work, // offsets the poster's burned eval budget). BondState string `json:"bondState,omitempty"` + + // PanelSeed records the randomness source the evaluator panel was drawn + // from, so the sampling is auditable (drand round, raw randomness, sig). + PanelSeed *ServiceBountyPanelSeed `json:"panelSeed,omitempty"` + + // Escalation is the second-round eval state opened when the first-round + // quorum diverges beyond the task's escalation epsilon. + Escalation *ServiceBountyEscalation `json:"escalation,omitempty"` + + // EscrowSpender is the facilitator address Permit2 vouchers must name as + // the only executor (Receipt.Spender echoed into status for signers). + EscrowSpender string `json:"escrowSpender,omitempty"` +} + +// ServiceBountyPanelSeed is the auditable randomness behind a panel draw. +type ServiceBountyPanelSeed struct { + // Source names the randomness origin (e.g. drand, local-dev). + Source string `json:"source"` + + // Round is the drand round the randomness came from. + Round uint64 `json:"round,omitempty"` + + // Randomness is the beacon output the panel sampling was keyed on. + Randomness string `json:"randomness,omitempty"` + + // Signature is the beacon signature proving the randomness. + Signature string `json:"signature,omitempty"` +} + +// ServiceBountyEscalation is one escalation round: a fresh, larger panel +// re-evaluates the same submission with its own commit-reveal cycle and its +// own eval budget. +type ServiceBountyEscalation struct { + // Round is the escalation round number (1 = first escalation). + Round int `json:"round"` + + // Reason records why the escalation opened (e.g. quorum divergence). + Reason string `json:"reason,omitempty"` + + // Panel is the escalation-round seat assignment. + Panel []ServiceBountyPanelSeat `json:"panel,omitempty"` + + // Evaluations are the escalation round's commit-reveal records. + Evaluations []ServiceBountyEvaluation `json:"evaluations,omitempty"` + + // RevealDeadline is the escalation round's commit→reveal cutoff. + RevealDeadline *metav1.Time `json:"revealDeadline,omitempty"` + + // VoucherDeadline is when the escalation eval-budget voucher expires. + VoucherDeadline *metav1.Time `json:"voucherDeadline,omitempty"` + + // BudgetState tracks the escalation eval budget at the escrow gateway: + // Reserved | Captured | Voided. + BudgetState string `json:"budgetState,omitempty"` } // Panel seat kinds (design doc §11.4): full and probation seats count in the @@ -368,6 +422,11 @@ type ServiceBountyEvaluation struct { // transaction, recorded as provenance (the evaluator's OWN wallet signs; // the controller never does). ValidationTxHash string `json:"validationTxHash,omitempty"` + + // Grounded marks a verdict backed by an on-chain ERC-8004 validation + // entry observed for this bounty's eval-request hash — the chain-anchored + // reputation signal, as opposed to an annotation-only reveal. + Grounded bool `json:"grounded,omitempty"` } type ServiceBountyClaim struct { @@ -503,6 +562,37 @@ func (in *ServiceBountyStatus) DeepCopyInto(out *ServiceBountyStatus) { l, m := &in.RevealDeadline, &out.RevealDeadline *m = (*l).DeepCopy() } + if in.PanelSeed != nil { + out.PanelSeed = new(ServiceBountyPanelSeed) + *out.PanelSeed = *in.PanelSeed + } + if in.Escalation != nil { + out.Escalation = new(ServiceBountyEscalation) + in.Escalation.DeepCopyInto(out.Escalation) + } +} + +func (in *ServiceBountyEscalation) DeepCopyInto(out *ServiceBountyEscalation) { + *out = *in + if in.Panel != nil { + out.Panel = make([]ServiceBountyPanelSeat, len(in.Panel)) + copy(out.Panel, in.Panel) + } + if in.Evaluations != nil { + l, m := &in.Evaluations, &out.Evaluations + *m = make([]ServiceBountyEvaluation, len(*l)) + for i := range *l { + (*l)[i].DeepCopyInto(&(*m)[i]) + } + } + if in.RevealDeadline != nil { + l, m := &in.RevealDeadline, &out.RevealDeadline + *m = (*l).DeepCopy() + } + if in.VoucherDeadline != nil { + l, m := &in.VoucherDeadline, &out.VoucherDeadline + *m = (*l).DeepCopy() + } } func (in *ServiceBountyEvaluation) DeepCopyInto(out *ServiceBountyEvaluation) { diff --git a/internal/monetizeapi/zz_generated.deepcopy.go b/internal/monetizeapi/zz_generated.deepcopy.go index 78674df4..84f72540 100644 --- a/internal/monetizeapi/zz_generated.deepcopy.go +++ b/internal/monetizeapi/zz_generated.deepcopy.go @@ -557,6 +557,16 @@ func (in *ServiceBountyDatasetCommit) DeepCopy() *ServiceBountyDatasetCommit { return out } +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBountyEscalation. +func (in *ServiceBountyEscalation) DeepCopy() *ServiceBountyEscalation { + if in == nil { + return nil + } + out := new(ServiceBountyEscalation) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceBountyEscrow) DeepCopyInto(out *ServiceBountyEscrow) { *out = *in @@ -628,6 +638,21 @@ func (in *ServiceBountyPanelSeat) DeepCopy() *ServiceBountyPanelSeat { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceBountyPanelSeed) DeepCopyInto(out *ServiceBountyPanelSeed) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceBountyPanelSeed. +func (in *ServiceBountyPanelSeed) DeepCopy() *ServiceBountyPanelSeed { + if in == nil { + return nil + } + out := new(ServiceBountyPanelSeed) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceBountyReward) DeepCopyInto(out *ServiceBountyReward) { *out = *in diff --git a/internal/serviceoffercontroller/bounty.go b/internal/serviceoffercontroller/bounty.go index 42e092a0..5c89afc2 100644 --- a/internal/serviceoffercontroller/bounty.go +++ b/internal/serviceoffercontroller/bounty.go @@ -132,6 +132,11 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { log.Printf("serviceoffer-controller: void eval budget for deleting bounty %s: %v", key, err) } } + if sb.Status.Escalation != nil && sb.Status.Escalation.BudgetState == escrow.StateReserved { + if _, err := c.escrowGateway().Void(ctx, string(sb.UID)+"-eval-r1"); err != nil { + log.Printf("serviceoffer-controller: void escalation budget for deleting bounty %s: %v", key, err) + } + } return c.removeBountyFinalizer(ctx, raw) } @@ -170,8 +175,12 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { } // 3. Escrow reserve — hold the reward before any claim is admitted, so a - // fulfiller never starts work against an unfunded bounty. - if status.EscrowState == "" { + // fulfiller never starts work against an unfunded bounty. A facilitator + // that needs the poster's Permit2 voucher answers AwaitingVoucher; the + // voucher ferries in on the obol.org/reward-voucher annotation and the + // reserve re-runs (idempotent at the facilitator) until it holds. + annotations := raw.GetAnnotations() + if status.EscrowState == "" || status.EscrowState == escrowStateAwaitingVoucher { receipt, err := c.escrowGateway().Reserve(ctx, escrow.ReserveRequest{ ID: string(sb.UID), Network: sb.Spec.Reward.Network, @@ -179,6 +188,7 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { Asset: sb.Spec.Reward.Asset.Symbol, Amount: sb.Spec.Reward.Amount, Scheme: sb.Spec.Reward.Escrow.Scheme, + Voucher: voucherFromAnnotations(annotations, bountyRewardVoucherAnnotation), }) if err != nil { setPurchaseCondition(&status.Conditions, "EscrowReserved", "False", "FacilitatorError", truncateMessage(err.Error())) @@ -189,11 +199,16 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { return err // rate-limited retry } status.EscrowState = receipt.State + ferryEscrowSpender(&status, receipt) + } + if status.EscrowState == escrowStateAwaitingVoucher { + setPurchaseCondition(&status.Conditions, "EscrowReserved", "False", "EscrowAwaitingVoucher", + fmt.Sprintf("Reward hold awaits the poster's Permit2 voucher (%s annotation)", bountyRewardVoucherAnnotation)) + } else { + setPurchaseCondition(&status.Conditions, "EscrowReserved", "True", "Reserved", escrowReason(c.escrowGateway())) } - setPurchaseCondition(&status.Conditions, "EscrowReserved", "True", "Reserved", escrowReason(c.escrowGateway())) // 4. Claim — promote the claim annotation into controller-owned status. - annotations := raw.GetAnnotations() if claim := strings.TrimSpace(annotations[bountyClaimAnnotation]); claim != "" && len(status.Claims) == 0 { if !common.IsHexAddress(claim) { setPurchaseCondition(&status.Conditions, "Claimed", "False", "InvalidAddress", @@ -223,7 +238,8 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { // 4b. Self-bond — held at the escrow gateway against the fulfiller's own // funds at claim time (anti-griefing: returned on success or honest // timeout, forfeited on rejected work to offset the poster's eval spend). - if sb.Spec.Trust.SelfBond.Required && len(status.Claims) > 0 && status.BondState == "" { + if sb.Spec.Trust.SelfBond.Required && len(status.Claims) > 0 && + (status.BondState == "" || status.BondState == escrowStateAwaitingVoucher) { receipt, err := c.escrowGateway().Reserve(ctx, escrow.ReserveRequest{ ID: string(sb.UID) + "-bond", Network: sb.Spec.Reward.Network, @@ -231,6 +247,7 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { Asset: sb.Spec.Trust.SelfBond.Token, Amount: sb.Spec.Trust.SelfBond.Amount, Scheme: sb.Spec.Reward.Escrow.Scheme, + Voucher: voucherFromAnnotations(annotations, bountyBondVoucherAnnotation), }) if err != nil { if statusErr := c.updateBountyStatus(ctx, raw, status); statusErr != nil { @@ -239,6 +256,7 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { return err // rate-limited retry } status.BondState = receipt.State + ferryEscrowSpender(&status, receipt) } // 5. Submit — parse the submission annotation, advance the claim. @@ -322,6 +340,15 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { if bountyConditionIsTrue(status.Conditions, "Verified") && status.EscrowState == escrow.StateReserved { receipt, err := c.escrowGateway().Capture(ctx, string(sb.UID)) if err != nil { + if isEscrowVoucherRefusal(err) { + // The facilitator wants a (fresh) Permit2 voucher before it + // settles — a poster-side signing gap, not a controller + // failure. Park as a condition + requeue; never fail the loop. + setPurchaseCondition(&status.Conditions, "Paid", "False", "EscrowAwaitingVoucher", truncateMessage(err.Error())) + c.bountyQueue.AddAfter(key, 30*time.Second) + status.Phase = bountyPhaseRollup(status) + return c.updateBountyStatus(ctx, raw, status) + } setPurchaseCondition(&status.Conditions, "Paid", "False", "CaptureFailed", truncateMessage(err.Error())) if statusErr := c.updateBountyStatus(ctx, raw, status); statusErr != nil { return statusErr @@ -330,6 +357,7 @@ func (c *Controller) reconcileBounty(ctx context.Context, key string) error { } status.EscrowState = receipt.State status.CaptureTxHash = receipt.TxHash + ferryEscrowSpender(&status, receipt) } if status.EscrowState == escrow.StateCaptured { setPurchaseCondition(&status.Conditions, "Paid", "True", "Captured", "Reward released to fulfiller") @@ -358,6 +386,11 @@ func (c *Controller) refundBounty(ctx context.Context, raw *unstructured.Unstruc status.EvalBudgetState = escrow.StateVoided } } + if status.Escalation != nil && status.Escalation.BudgetState == escrow.StateReserved { + if _, err := c.escrowGateway().Void(ctx, string(sb.UID)+"-eval-r1"); err == nil { + status.Escalation.BudgetState = escrow.StateVoided + } + } if status.EscrowState == escrow.StateReserved { receipt, err := c.escrowGateway().Void(ctx, string(sb.UID)) if err != nil { diff --git a/internal/serviceoffercontroller/bounty_escalation.go b/internal/serviceoffercontroller/bounty_escalation.go new file mode 100644 index 00000000..9863cfc5 --- /dev/null +++ b/internal/serviceoffercontroller/bounty_escalation.go @@ -0,0 +1,300 @@ +package serviceoffercontroller + +// Escalation round (design doc §11.6): when round 0 settles diverged +// (dispersion) or knife-edge on the pass threshold, the verdict is NOT spoken. +// Instead a fresh 2k+1 panel — excluding the round-0 panel and the fulfiller — +// re-runs the same commit-reveal cycle on annotation prefixes +// obol.org/eval-commit-r1- / obol.org/eval-reveal-r1-, and ITS +// median is final. One escalation per bounty (status.escalation is a latch); +// the round-1 eval budget is a separate poster-funded escrow leg +// (-eval-r1, voucher annotation obol.org/eval-voucher-r1) that pays every +// round-1 evaluator full price, win-or-lose. If the voucher never arrives +// before the escalation window closes, the escalation is Unfunded and the +// round-0 median stands — evaluators are never asked to work unpaid. + +import ( + "context" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/ObolNetwork/obol-stack/internal/bounty" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/x402/escrow" + "github.com/ethereum/go-ethereum/common" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + bountyEvalCommitR1Prefix = "obol.org/eval-commit-r1-" + bountyEvalRevealR1Prefix = "obol.org/eval-reveal-r1-" + + // Fallbacks mirror internal/bounty registry ladder defaults for task + // packages that cannot be resolved. + defaultEscalationWindow = 30 * time.Minute + defaultEscalationEpsilon = 5 +) + +// selectEscalationPanelFn is the seam to the escalation panel selection +// implemented in bounty_panel.go. A variable so tests inject a deterministic +// fake panel (the selection itself is exercised by the panel tests). +var selectEscalationPanelFn = (*Controller).selectEscalationPanel + +// escalationTrigger reports why round 0 must escalate ("" = settle normally): +// +// (a) dispersion — at least ⌈k/2⌉ counting REVEALS landed out of band +// around the median (non-reveals are penalized, not dispersion); +// (b) knife-edge — the median sits within epsilon of the pass threshold, +// where a single re-rolled evaluator could have flipped the verdict. +// epsilon <= 0 disables the knife-edge trigger. +func escalationTrigger(evaluations []monetizeapi.ServiceBountyEvaluation, k, median int64, epsilon int) string { + outOfBand := int64(0) + for _, evaluation := range evaluations { + if evaluation.Phase == evalPhaseRevealed && evaluation.Seat != monetizeapi.PanelSeatShadow && !evaluation.WithinBand { + outOfBand++ + } + } + if outOfBand >= (k+1)/2 { + return fmt.Sprintf("dispersion: %d of %d counting reveal(s) out of band around median %d", outOfBand, k, median) + } + if epsilon > 0 { + diff := median - evalPassThreshold + if diff < 0 { + diff = -diff + } + if diff <= int64(epsilon) { + return fmt.Sprintf("knife-edge: median %d within %d of the %d pass threshold", median, epsilon, evalPassThreshold) + } + } + return "" +} + +// escalationWindow resolves the task package's ladder.escalationWindow — the +// time the poster has to fund the round-1 eval budget (voucher arrival). +func escalationWindow(sb *monetizeapi.ServiceBounty) time.Duration { + t, err := bounty.Resolve(sb.Spec.Task.TypeRef) + if err != nil { + return defaultEscalationWindow + } + window, err := time.ParseDuration(t.Eval.Ladder.EscalationWindow) + if err != nil || window <= 0 { + return defaultEscalationWindow + } + return window +} + +// escalationEpsilon resolves the task package's ladder.escalationEpsilon. +func escalationEpsilon(sb *monetizeapi.ServiceBounty) int { + t, err := bounty.Resolve(sb.Spec.Task.TypeRef) + if err != nil { + return defaultEscalationEpsilon + } + return t.Eval.Ladder.EscalationEpsilon +} + +// openEscalation latches the single escalation round: a 2k+1 panel excluding +// the round-0 panel, the round-0 (open-door) participants, and the fulfiller. +// opened=false, retry=true is a transient selection failure (seed source / +// enrollment list) — the verdict stays unspoken and the trigger re-checks; +// opened=false, retry=false means the enrolled pool cannot seat a round-1 +// panel at all, so the round-0 median stands. +func (c *Controller) openEscalation(ctx context.Context, sb *monetizeapi.ServiceBounty, annotations map[string]string, status *monetizeapi.ServiceBountyStatus, reason string, now time.Time) (opened, retry bool) { + size := int(2*evalQuorumK(sb) + 1) + + exclude := make(map[string]bool) + for _, seat := range status.EvaluatorPanel { + exclude[common.HexToAddress(seat.Address).Hex()] = true + } + for _, evaluation := range status.Evaluations { + // Open-door round-0 participants are excluded too: a diverged + // evaluator must not grade their own divergence. + exclude[common.HexToAddress(evaluation.Address).Hex()] = true + } + if len(status.Claims) > 0 && common.IsHexAddress(status.Claims[0].FulfillerAddress) { + exclude[common.HexToAddress(status.Claims[0].FulfillerAddress).Hex()] = true + } + + // Panel selection reads the raw object shape (spec.task.typeRef, UID, + // creation timestamp, status.claims) — feed it the WORKING status so a + // claim promoted this reconcile is visible to the pair-diversity weights. + working := *sb + working.Status = *status + rawObject, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&working) + if err != nil { + setPurchaseCondition(&status.Conditions, "Escalated", "False", "PanelUnavailable", + truncateMessage(fmt.Sprintf("escalation triggered (%s) but the bounty could not be encoded for panel selection: %v", reason, err))) + return false, true + } + panel, err := selectEscalationPanelFn(c, ctx, &unstructured.Unstructured{Object: rawObject}, size, exclude) + if err != nil { + setPurchaseCondition(&status.Conditions, "Escalated", "False", "PanelUnavailable", + truncateMessage(fmt.Sprintf("escalation triggered (%s) but no round-1 panel is available: %v", reason, err))) + return false, true + } + if len(panel) == 0 { + // Thin enrolled pool — same fallback posture as round 0's open door, + // except a round-1 open door would re-admit the very addresses the + // escalation excludes, so the round-0 median stands instead. + setPurchaseCondition(&status.Conditions, "Escalated", "False", "PanelExhausted", + truncateMessage(fmt.Sprintf("escalation triggered (%s) but the enrolled pool cannot seat a %d-member round-1 panel — the round-0 median stands", reason, size))) + return false, false + } + + voucherDeadline := metav1.NewTime(now.Add(escalationWindow(sb))) + status.Escalation = &monetizeapi.ServiceBountyEscalation{ + Round: 1, + Reason: reason, + Panel: panel, + VoucherDeadline: &voucherDeadline, + BudgetState: escrowStateAwaitingVoucher, + } + setPurchaseCondition(&status.Conditions, "Escalated", "True", "Escalated", truncateMessage(reason)) + c.reserveEscalationBudget(ctx, sb, annotations, status) + return true, false +} + +// runEscalation drives the open escalation to a conclusion. done=true means +// the escalation is resolved: either the round-1 cycle settled (its median is +// final) or the budget went Unfunded (round-0 median stands). done=false keeps +// the verdict unspoken; requeue covers the voucher/reveal deadlines. +func (c *Controller) runEscalation(ctx context.Context, sb *monetizeapi.ServiceBounty, annotations map[string]string, status *monetizeapi.ServiceBountyStatus, now time.Time) (done bool, requeue time.Duration) { + esc := status.Escalation + + if esc.BudgetState == "" || esc.BudgetState == escrowStateAwaitingVoucher { + c.reserveEscalationBudget(ctx, sb, annotations, status) + } + + switch esc.BudgetState { + case escrowStateUnfunded: + return true, 0 + case escrow.StateReserved, escrow.StateCaptured: + // funded — fall through to the round-1 cycle below + default: + // Still awaiting the voucher: past the escalation window the round-0 + // median stands; before it, wait (annotation arrival re-reconciles). + if esc.VoucherDeadline != nil && now.After(esc.VoucherDeadline.Time) { + esc.BudgetState = escrowStateUnfunded + setPurchaseCondition(&status.Conditions, "Escalated", "True", "EscalationUnfunded", + fmt.Sprintf("Escalation eval budget was never funded before %s — the round-0 median stands", esc.VoucherDeadline.UTC().Format(time.RFC3339))) + return true, 0 + } + setPurchaseCondition(&status.Conditions, "Escalated", "True", "EscrowAwaitingVoucher", + fmt.Sprintf("Escalation eval budget awaits the poster's Permit2 voucher (%s annotation)", bountyEvalVoucherR1Annotation)) + if esc.VoucherDeadline != nil { + return false, time.Until(esc.VoucherDeadline.Time) + time.Second + } + return false, 0 + } + + // Funded: full commit-reveal cycle, semantics identical to round 0. All + // 2k+1 seats are counting (no probation/shadow in round 1) and only panel + // members are admitted. + seats := make(map[string]string, len(esc.Panel)) + for _, seat := range esc.Panel { + seats[common.HexToAddress(seat.Address).Hex()] = seat.Seat + } + settled, roundRequeue := runEvalRound(annotations, evalRoundIO{ + commitPrefix: bountyEvalCommitR1Prefix, + revealPrefix: bountyEvalRevealR1Prefix, + seats: seats, + restrict: true, + k: int64(len(esc.Panel)), + window: revealWindow(sb), + evaluations: &esc.Evaluations, + deadline: &esc.RevealDeadline, + }, now) + return settled, roundRequeue +} + +// reserveEscalationBudget holds the round-1 eval budget — panel size × FULL +// perEvaluator, no probation discount — under -eval-r1, attaching the +// obol.org/eval-voucher-r1 Permit2 voucher when it has ferried in. Re-runs +// while AwaitingVoucher (idempotent at the facilitator). +func (c *Controller) reserveEscalationBudget(ctx context.Context, sb *monetizeapi.ServiceBounty, annotations map[string]string, status *monetizeapi.ServiceBountyStatus) { + esc := status.Escalation + if esc == nil || (esc.BudgetState != "" && esc.BudgetState != escrowStateAwaitingVoucher) { + return + } + per, err := strconv.ParseFloat(strings.TrimSpace(sb.Spec.Eval.Payment.PerEvaluator), 64) + if err != nil || per <= 0 { + // No eval-payment leg configured → nothing to fund; the round runs + // like a round-0 market without perEvaluator pricing (settle is a + // no-op for the same reason). + esc.BudgetState = escrow.StateReserved + return + } + total := strconv.FormatFloat(float64(len(esc.Panel))*per, 'f', 2, 64) + receipt, err := c.escrowGateway().Reserve(ctx, escrow.ReserveRequest{ + ID: string(sb.UID) + "-eval-r1", + Network: sb.Spec.Reward.Network, + PayTo: sb.Spec.Reward.PayTo, // poster refund address + Asset: sb.Spec.Eval.Payment.Asset, + Amount: total, + Scheme: sb.Spec.Reward.Escrow.Scheme, + Voucher: voucherFromAnnotations(annotations, bountyEvalVoucherR1Annotation), + }) + if err != nil { + log.Printf("serviceoffer-controller: reserve escalation budget for %s/%s: %v", sb.Namespace, sb.Name, err) + return + } + esc.BudgetState = receipt.State + ferryEscrowSpender(status, receipt) + if receipt.State == escrow.StateReserved { + setPurchaseCondition(&status.Conditions, "Escalated", "True", "EscalationFunded", + fmt.Sprintf("Round-1 panel of %d funded (%s); commit-reveal in progress", len(esc.Panel), total)) + } +} + +// settleEscalationBudget batch-settles the round-1 eval budget to every +// round-1 evaluator with a valid reveal — full price, win-or-lose. Non/bad +// reveals earn nothing, exactly like round 0. +func (c *Controller) settleEscalationBudget(ctx context.Context, sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceBountyStatus) { + esc := status.Escalation + if esc == nil || esc.BudgetState != escrow.StateReserved { + return + } + // Full price per round-1 seat, in atomic units when the asset resolves — + // capture recipients must match the poster's voucher seats exactly + // (see evalSeatAmounts). + amount, _, ok := evalSeatAmounts(sb) + if !ok { + return + } + + var recipients []escrow.BatchRecipient + var paidIdx []int + for i := range esc.Evaluations { + if esc.Evaluations[i].Phase != evalPhaseRevealed { + continue + } + recipients = append(recipients, escrow.BatchRecipient{ + Address: esc.Evaluations[i].Address, + Amount: amount, + }) + paidIdx = append(paidIdx, i) + } + if len(recipients) == 0 { + return // nothing to pay; refund path voids the budget + } + + var receipt escrow.Receipt + var err error + if batch, ok := c.escrowGateway().(escrow.BatchGateway); ok { + receipt, err = batch.CaptureBatch(ctx, string(sb.UID)+"-eval-r1", recipients) + } else { + receipt, err = c.escrowGateway().Capture(ctx, string(sb.UID)+"-eval-r1") + } + if err != nil { + log.Printf("serviceoffer-controller: settle escalation budget for %s/%s: %v", sb.Namespace, sb.Name, err) + return + } + esc.BudgetState = receipt.State + ferryEscrowSpender(status, receipt) + for _, i := range paidIdx { + esc.Evaluations[i].Paid = true + } +} diff --git a/internal/serviceoffercontroller/bounty_escalation_test.go b/internal/serviceoffercontroller/bounty_escalation_test.go new file mode 100644 index 00000000..88f9f421 --- /dev/null +++ b/internal/serviceoffercontroller/bounty_escalation_test.go @@ -0,0 +1,785 @@ +package serviceoffercontroller + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/x402/escrow" + "github.com/ethereum/go-ethereum/common" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// ── fakes ─────────────────────────────────────────────────────────────────── + +// fakeEscrowGateway is a voucher-aware escrow fake: ids listed in +// requireVoucher answer AwaitingVoucher until a Reserve carries a voucher, +// captures can be forced to fail, and every request/batch is recorded. +type fakeEscrowGateway struct { + mu sync.Mutex + spender string + requireVoucher map[string]bool + captureErr map[string]error + reserves map[string][]escrow.ReserveRequest + states map[string]string + batches map[string][]escrow.BatchRecipient +} + +func newFakeEscrow() *fakeEscrowGateway { + return &fakeEscrowGateway{ + requireVoucher: map[string]bool{}, + captureErr: map[string]error{}, + reserves: map[string][]escrow.ReserveRequest{}, + states: map[string]string{}, + batches: map[string][]escrow.BatchRecipient{}, + } +} + +func (f *fakeEscrowGateway) Reserve(_ context.Context, req escrow.ReserveRequest) (escrow.Receipt, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.reserves[req.ID] = append(f.reserves[req.ID], req) + if state := f.states[req.ID]; state == escrow.StateCaptured || state == escrow.StateVoided { + return escrow.Receipt{State: state, Spender: f.spender}, nil + } + if f.requireVoucher[req.ID] && req.Voucher == nil { + f.states[req.ID] = escrowStateAwaitingVoucher + return escrow.Receipt{State: escrowStateAwaitingVoucher, Spender: f.spender}, nil + } + f.states[req.ID] = escrow.StateReserved + return escrow.Receipt{State: escrow.StateReserved, Spender: f.spender}, nil +} + +func (f *fakeEscrowGateway) capture(id string) (escrow.Receipt, error) { + if err := f.captureErr[id]; err != nil { + return escrow.Receipt{}, err + } + f.states[id] = escrow.StateCaptured + return escrow.Receipt{State: escrow.StateCaptured, TxHash: "fake-capture:" + id, Spender: f.spender}, nil +} + +func (f *fakeEscrowGateway) Capture(_ context.Context, id string) (escrow.Receipt, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.capture(id) +} + +func (f *fakeEscrowGateway) CaptureBatch(_ context.Context, id string, recipients []escrow.BatchRecipient) (escrow.Receipt, error) { + f.mu.Lock() + defer f.mu.Unlock() + receipt, err := f.capture(id) + if err != nil { + return escrow.Receipt{}, err + } + f.batches[id] = recipients + return receipt, nil +} + +func (f *fakeEscrowGateway) Void(_ context.Context, id string) (escrow.Receipt, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.states[id] = escrow.StateVoided + return escrow.Receipt{State: escrow.StateVoided, Spender: f.spender}, nil +} + +func (f *fakeEscrowGateway) lastReserve(t *testing.T, id string) escrow.ReserveRequest { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + reqs := f.reserves[id] + if len(reqs) == 0 { + t.Fatalf("no Reserve recorded for %s", id) + } + return reqs[len(reqs)-1] +} + +// fakeValidationReader is the grounding chain fake. +type fakeValidationReader struct { + statuses map[common.Hash]erc8004.ValidationStatus + readErr error +} + +func (f *fakeValidationReader) ValidationStatus(_ context.Context, h common.Hash) (erc8004.ValidationStatus, error) { + if f.readErr != nil { + return erc8004.ValidationStatus{}, f.readErr + } + return f.statuses[h], nil +} + +func stubValidationReader(t *testing.T, reader bountyValidationReader, dialErr error) { + t.Helper() + orig := bountyValidationReaderFactory + bountyValidationReaderFactory = func(context.Context, string, string) (bountyValidationReader, func(), error) { + if dialErr != nil { + return nil, nil, dialErr + } + return reader, func() {}, nil + } + t.Cleanup(func() { bountyValidationReaderFactory = orig }) +} + +func stubEscalationPanel(t *testing.T, panel []monetizeapi.ServiceBountyPanelSeat, err error) { + t.Helper() + orig := selectEscalationPanelFn + selectEscalationPanelFn = func(*Controller, context.Context, *unstructured.Unstructured, int, map[string]bool) ([]monetizeapi.ServiceBountyPanelSeat, error) { + return panel, err + } + t.Cleanup(func() { selectEscalationPanelFn = orig }) +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +func r1Addr(i int) string { + return common.HexToAddress(fmt.Sprintf("0x%040x", 0xe100+i)).Hex() +} + +func r1Panel(size int) []monetizeapi.ServiceBountyPanelSeat { + seats := make([]monetizeapi.ServiceBountyPanelSeat, 0, size) + for i := 0; i < size; i++ { + seats = append(seats, monetizeapi.ServiceBountyPanelSeat{Address: r1Addr(i), Seat: monetizeapi.PanelSeatFull}) + } + return seats +} + +// addRound0 writes commit+reveal annotation pairs for a direct +// reconcileEvalMarket invocation (commits promote and reveals grade in the +// same pass once K commitments are present). +func addRound0(annotations map[string]string, scores map[string]int64) { + for addr, score := range scores { + annotations[bountyEvalCommitPrefix+addr] = monetizeapi.EvalCommitHash(score, "salt-"+addr, addr) + annotations[bountyEvalRevealPrefix+addr] = fmt.Sprintf(`{"score":%d,"salt":"salt-%s"}`, score, addr) + } +} + +func addRound1Commits(annotations map[string]string, scores map[string]int64) { + for addr, score := range scores { + annotations[bountyEvalCommitR1Prefix+addr] = monetizeapi.EvalCommitHash(score, "r1salt-"+addr, addr) + } +} + +func addRound1Reveals(annotations map[string]string, scores map[string]int64) { + for addr, score := range scores { + annotations[bountyEvalRevealR1Prefix+addr] = fmt.Sprintf(`{"score":%d,"salt":"r1salt-%s"}`, score, addr) + } +} + +func commitAndRevealR1(t *testing.T, c *Controller, ns, name string, scores map[string]int64) { + t.Helper() + key := ns + "/" + name + for addr, score := range scores { + annotateBounty(t, c, ns, name, map[string]string{ + bountyEvalCommitR1Prefix + addr: monetizeapi.EvalCommitHash(score, "r1salt-"+addr, addr), + }) + } + reconcileBountyUntilSettled(t, c, key) + for addr, score := range scores { + annotateBounty(t, c, ns, name, map[string]string{ + bountyEvalRevealR1Prefix + addr: fmt.Sprintf(`{"score":%d,"salt":"r1salt-%s"}`, score, addr), + }) + } + reconcileBountyUntilSettled(t, c, key) +} + +func bountyConditionMessage(conditions []monetizeapi.Condition, conditionType string) string { + for _, condition := range conditions { + if condition.Type == conditionType { + return condition.Message + } + } + return "" +} + +// ── trigger (pure) ────────────────────────────────────────────────────────── + +func revealedEval(addr string, score int64, withinBand bool) monetizeapi.ServiceBountyEvaluation { + return monetizeapi.ServiceBountyEvaluation{Address: addr, Phase: evalPhaseRevealed, Score: score, WithinBand: withinBand} +} + +func TestEscalationTrigger_Dispersion(t *testing.T) { + // ceil(3/2)=2 out-of-band reveals trigger; 1 does not. + one := []monetizeapi.ServiceBountyEvaluation{ + revealedEval(evalA, 85, true), revealedEval(evalB, 90, true), revealedEval(evalC, 20, false), + } + if got := escalationTrigger(one, 3, 85, 5); got != "" { + t.Fatalf("1 of 3 out of band must not trigger, got %q", got) + } + two := []monetizeapi.ServiceBountyEvaluation{ + revealedEval(evalA, 0, false), revealedEval(evalB, 75, true), revealedEval(evalC, 100, false), + } + got := escalationTrigger(two, 3, 75, 5) + if !strings.Contains(got, "dispersion") { + t.Fatalf("2 of 3 out of band must trigger dispersion, got %q", got) + } + + // Non-reveals are penalized, not dispersion: they never count. + nonReveals := []monetizeapi.ServiceBountyEvaluation{ + revealedEval(evalA, 85, true), + {Address: evalB, Phase: evalPhaseNonReveal, WithinBand: false}, + {Address: evalC, Phase: evalPhaseNonReveal, WithinBand: false}, + } + if got := escalationTrigger(nonReveals, 3, 85, 5); got != "" { + t.Fatalf("non-reveals must not count toward dispersion, got %q", got) + } + + // Shadow seats never count either. + shadow := []monetizeapi.ServiceBountyEvaluation{ + revealedEval(evalA, 85, true), + {Address: evalB, Phase: evalPhaseRevealed, Score: 0, WithinBand: false, Seat: monetizeapi.PanelSeatShadow}, + {Address: evalC, Phase: evalPhaseRevealed, Score: 100, WithinBand: false, Seat: monetizeapi.PanelSeatShadow}, + } + if got := escalationTrigger(shadow, 3, 85, 5); got != "" { + t.Fatalf("shadow divergence must not trigger dispersion, got %q", got) + } +} + +func TestEscalationTrigger_KnifeEdge(t *testing.T) { + inBand := []monetizeapi.ServiceBountyEvaluation{ + revealedEval(evalA, 52, true), revealedEval(evalB, 53, true), revealedEval(evalC, 54, true), + } + if got := escalationTrigger(inBand, 3, 53, 5); !strings.Contains(got, "knife-edge") { + t.Fatalf("median 53 within 5 of 50 must trigger knife-edge, got %q", got) + } + if got := escalationTrigger(inBand, 3, 56, 5); got != "" { + t.Fatalf("median 56 is outside epsilon 5, got %q", got) + } + // |median-threshold| == epsilon is inclusive. + if got := escalationTrigger(inBand, 3, 45, 5); !strings.Contains(got, "knife-edge") { + t.Fatalf("median 45 at exactly epsilon 5 must trigger, got %q", got) + } +} + +func TestEscalationTrigger_EpsilonZeroDisablesKnifeEdge(t *testing.T) { + dead := []monetizeapi.ServiceBountyEvaluation{ + revealedEval(evalA, 50, true), revealedEval(evalB, 50, true), revealedEval(evalC, 50, true), + } + if got := escalationTrigger(dead, 3, 50, 0); got != "" { + t.Fatalf("epsilon 0 must disable the knife-edge trigger, got %q", got) + } + if got := escalationTrigger(dead, 3, 50, 5); got == "" { + t.Fatal("epsilon 5 with a dead-center median must trigger") + } +} + +// ── escalation lifecycle (e2e through reconcileBounty) ───────────────────── + +func TestEscalation_DispersionTriggersAndRound1MedianIsFinal(t *testing.T) { + sb := testEvalBounty("escalate") + c := newBountyTestController(t, sb) + fake := newFakeEscrow() + c.bountyEscrow = fake + stubEscalationPanel(t, r1Panel(7), nil) + ns := "hermes-obol-agent" + + claimAndSubmit(t, c, ns, "escalate") + // Round 0: median 75 (would PASS), but 0 and 100 are out of band → + // dispersion (2 ≥ ⌈3/2⌉). + commitAndReveal(t, c, ns, "escalate", map[string]int64{evalA: 0, evalB: 75, evalC: 100}) + + got := getBounty(t, c, ns, "escalate") + esc := got.Status.Escalation + if esc == nil { + t.Fatal("escalation must open on dispersion") + } + if esc.Round != 1 || !strings.Contains(esc.Reason, "dispersion") { + t.Fatalf("escalation = round %d reason %q, want round 1 dispersion", esc.Round, esc.Reason) + } + if len(esc.Panel) != 7 { + t.Fatalf("round-1 panel size = %d, want 2k+1 = 7", len(esc.Panel)) + } + if esc.BudgetState != escrow.StateReserved { + t.Fatalf("escalation budget = %q, want Reserved (fake funds without voucher)", esc.BudgetState) + } + if reason := conditionReason(got.Status.Conditions, "Verified"); reason == "EvaluatorQuorum" { + t.Fatal("the EvaluatorQuorum verdict must NOT be spoken while the escalation is open") + } + // 7 seats × full 2.00 — no probation discount in round 1. + if req := fake.lastReserve(t, "uid-escalate-eval-r1"); req.Amount != "14.00" { + t.Fatalf("round-1 reserve amount = %q, want 14.00", req.Amount) + } + + // Round 1: median 30 → the ROUND-0 pass is overridden; round-1 is final. + r1Scores := map[string]int64{} + for i, score := range []int64{10, 20, 30, 30, 30, 90, 95} { + r1Scores[r1Addr(i)] = score + } + commitAndRevealR1(t, c, ns, "escalate", r1Scores) + + got = getBounty(t, c, ns, "escalate") + if bountyConditionIsTrue(got.Status.Conditions, "Verified") { + t.Fatal("round-1 median 30 < 50 must reject even though round-0 median was 75") + } + if reason := conditionReason(got.Status.Conditions, "Verified"); reason != "EvaluatorQuorum" { + t.Fatalf("Verified reason = %q, want EvaluatorQuorum (escalation keeps the quorum reason)", reason) + } + if msg := bountyConditionMessage(got.Status.Conditions, "Verified"); !strings.Contains(msg, "escalated") { + t.Fatalf("Verified message must note the escalation, got %q", msg) + } + if got.Status.WeightedScore != 30 { + t.Fatalf("WeightedScore = %d, want round-1 median 30", got.Status.WeightedScore) + } + if got.Status.Phase != bountyPhaseRejected { + t.Fatalf("phase = %q, want Rejected", got.Status.Phase) + } + if got.Status.Escalation.BudgetState != escrow.StateCaptured { + t.Fatalf("escalation budget = %q, want Captured (evaluators paid win-or-lose)", got.Status.Escalation.BudgetState) + } + recipients := fake.batches["uid-escalate-eval-r1"] + if len(recipients) != 7 { + t.Fatalf("round-1 batch recipients = %d, want 7", len(recipients)) + } + for _, recipient := range recipients { + if recipient.Amount != "2.00" { + t.Fatalf("round-1 evaluator %s paid %q, want full 2.00", recipient.Address, recipient.Amount) + } + } + for _, evaluation := range got.Status.Escalation.Evaluations { + if !evaluation.Paid { + t.Fatalf("round-1 evaluator %s not marked Paid", evaluation.Address) + } + } +} + +func TestEscalation_KnifeEdgeTriggers(t *testing.T) { + sb := testEvalBounty("knife") + c := newBountyTestController(t, sb) + c.bountyEscrow = newFakeEscrow() + stubEscalationPanel(t, r1Panel(7), nil) + ns := "hermes-obol-agent" + + claimAndSubmit(t, c, ns, "knife") + // Median 53 — all in band (no dispersion) but within epsilon 5 of 50. + commitAndReveal(t, c, ns, "knife", map[string]int64{evalA: 52, evalB: 53, evalC: 54}) + + got := getBounty(t, c, ns, "knife") + if got.Status.Escalation == nil { + t.Fatal("knife-edge median must escalate") + } + if !strings.Contains(got.Status.Escalation.Reason, "knife-edge") { + t.Fatalf("escalation reason = %q, want knife-edge", got.Status.Escalation.Reason) + } + if reason := conditionReason(got.Status.Conditions, "Verified"); reason == "EvaluatorQuorum" { + t.Fatal("verdict must wait for the escalation round") + } +} + +func TestEscalation_SingleRoundLatch(t *testing.T) { + sb := testEvalBounty("latch") + c := newBountyTestController(t, sb) + c.bountyEscrow = newFakeEscrow() + stubEscalationPanel(t, r1Panel(7), nil) + ns := "hermes-obol-agent" + + claimAndSubmit(t, c, ns, "latch") + commitAndReveal(t, c, ns, "latch", map[string]int64{evalA: 0, evalB: 75, evalC: 100}) + + // Round 1 lands knife-edge AND dispersed — conditions that would trigger + // again — but escalation is a single-round latch: its median is FINAL. + r1Scores := map[string]int64{} + for i, score := range []int64{0, 10, 50, 50, 52, 90, 100} { + r1Scores[r1Addr(i)] = score + } + commitAndRevealR1(t, c, ns, "latch", r1Scores) + + got := getBounty(t, c, ns, "latch") + if got.Status.Escalation == nil || got.Status.Escalation.Round != 1 { + t.Fatalf("escalation = %+v, want the single round 1", got.Status.Escalation) + } + if !bountyConditionIsTrue(got.Status.Conditions, "Verified") { + t.Fatal("round-1 median 50 >= 50 must verify") + } + if got.Status.WeightedScore != 50 { + t.Fatalf("WeightedScore = %d, want round-1 median 50", got.Status.WeightedScore) + } + if len(got.Status.Escalation.Evaluations) != 7 { + t.Fatalf("round-1 evaluations = %d, want 7", len(got.Status.Escalation.Evaluations)) + } + + // Extra reconciles never re-open a second round or move the verdict. + reconcileBountyUntilSettled(t, c, ns+"/latch") + again := getBounty(t, c, ns, "latch") + if again.Status.Escalation.Round != 1 || len(again.Status.Escalation.Evaluations) != 7 { + t.Fatalf("escalation re-opened: %+v", again.Status.Escalation) + } + if again.Status.WeightedScore != 50 { + t.Fatalf("verdict moved after latch: WeightedScore = %d", again.Status.WeightedScore) + } +} + +func TestEscalation_ExcludesRound0PanelAndFulfiller(t *testing.T) { + sb := testEvalBounty("exclude") + c := newBountyTestController(t, sb) + c.bountyEscrow = newFakeEscrow() + + var gotSize int + var gotExclude map[string]bool + orig := selectEscalationPanelFn + selectEscalationPanelFn = func(_ *Controller, _ context.Context, _ *unstructured.Unstructured, size int, exclude map[string]bool) ([]monetizeapi.ServiceBountyPanelSeat, error) { + gotSize = size + gotExclude = exclude + return r1Panel(7), nil + } + t.Cleanup(func() { selectEscalationPanelFn = orig }) + + ns := "hermes-obol-agent" + claimAndSubmit(t, c, ns, "exclude") + commitAndReveal(t, c, ns, "exclude", map[string]int64{evalA: 0, evalB: 75, evalC: 100}) + + if gotSize != 7 { + t.Fatalf("escalation panel size = %d, want 2k+1 = 7", gotSize) + } + for _, addr := range []string{evalA, evalB, evalC, "0x2222222222222222222222222222222222222222"} { + if !gotExclude[common.HexToAddress(addr).Hex()] { + t.Errorf("exclude set must contain %s (round-0 participant or fulfiller)", addr) + } + } +} + +// ── escalation funding (direct invocation for clock control) ─────────────── + +func TestEscalation_UnfundedFallbackPreservesRound0Verdict(t *testing.T) { + c := newBountyTestController(t) + fake := newFakeEscrow() + fake.requireVoucher["uid-unfunded-eval-r1"] = true + c.bountyEscrow = fake + stubEscalationPanel(t, r1Panel(7), nil) + + sb := testEvalBounty("unfunded") + status := &monetizeapi.ServiceBountyStatus{} + annotations := map[string]string{} + addRound0(annotations, map[string]int64{evalA: 0, evalB: 75, evalC: 100}) + + now0 := time.Now() + requeue := c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0) + if status.Escalation == nil || status.Escalation.BudgetState != escrowStateAwaitingVoucher { + t.Fatalf("escalation = %+v, want AwaitingVoucher", status.Escalation) + } + if reason := conditionReason(status.Conditions, "Verified"); reason != "" { + t.Fatalf("no verdict may be spoken while the escalation awaits funding, got reason %q", reason) + } + if reason := conditionReason(status.Conditions, "Escalated"); reason != "EscrowAwaitingVoucher" { + t.Fatalf("Escalated reason = %q, want EscrowAwaitingVoucher", reason) + } + if requeue <= 0 { + t.Fatal("an awaiting-voucher escalation must requeue for its deadline") + } + + // Past the escalation window (benchmark@v1 ladder: 30m) with no voucher: + // Unfunded, and the round-0 median (75 → pass) stands. + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0.Add(31*time.Minute)) + if status.Escalation.BudgetState != escrowStateUnfunded { + t.Fatalf("escalation budget = %q, want Unfunded", status.Escalation.BudgetState) + } + if reason := conditionReason(status.Conditions, "Escalated"); reason != "EscalationUnfunded" { + t.Fatalf("Escalated reason = %q, want EscalationUnfunded", reason) + } + if !bountyConditionIsTrue(status.Conditions, "Verified") { + t.Fatal("round-0 median 75 must verify when the escalation goes unfunded") + } + if reason := conditionReason(status.Conditions, "Verified"); reason != "EvaluatorQuorum" { + t.Fatalf("Verified reason = %q, want EvaluatorQuorum", reason) + } + if status.WeightedScore != 75 { + t.Fatalf("WeightedScore = %d, want round-0 median 75", status.WeightedScore) + } + if msg := bountyConditionMessage(status.Conditions, "Verified"); strings.Contains(msg, "escalated") { + t.Fatalf("an unfunded escalation must not claim a round-1 verdict: %q", msg) + } + if len(status.Escalation.Evaluations) != 0 { + t.Fatal("an unfunded escalation must never run a round-1 cycle") + } +} + +func TestEscalation_LateVoucherFundsRound1(t *testing.T) { + c := newBountyTestController(t) + fake := newFakeEscrow() + fake.requireVoucher["uid-late-eval-r1"] = true + c.bountyEscrow = fake + stubEscalationPanel(t, r1Panel(7), nil) + + sb := testEvalBounty("late") + status := &monetizeapi.ServiceBountyStatus{} + annotations := map[string]string{} + addRound0(annotations, map[string]int64{evalA: 0, evalB: 75, evalC: 100}) + + now0 := time.Now() + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0) + if status.Escalation.BudgetState != escrowStateAwaitingVoucher { + t.Fatalf("budget = %q, want AwaitingVoucher", status.Escalation.BudgetState) + } + + // The voucher annotation ferries in before the deadline → RE-reserve + // picks it up and the budget funds. + annotations[bountyEvalVoucherR1Annotation] = `{"owner":"0x1111111111111111111111111111111111111111","token":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","network":"base","spender":"0xFAC0000000000000000000000000000000000FAC","nonce":"42","deadline":1893456000,"recipients":[{"address":"` + r1Addr(0) + `","amount":"2000000"}],"signature":"0xsig"}` + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0.Add(5*time.Minute)) + if status.Escalation.BudgetState != escrow.StateReserved { + t.Fatalf("budget = %q, want Reserved after the voucher arrives", status.Escalation.BudgetState) + } + req := fake.lastReserve(t, "uid-late-eval-r1") + if req.Voucher == nil { + t.Fatal("re-reserve must attach the ferried voucher") + } + if req.Voucher.Nonce != "42" || req.Voucher.Owner != "0x1111111111111111111111111111111111111111" { + t.Fatalf("voucher fields not ferried intact: %+v", req.Voucher) + } + if reason := conditionReason(status.Conditions, "Escalated"); reason != "EscalationFunded" { + t.Fatalf("Escalated reason = %q, want EscalationFunded", reason) + } +} + +func TestEscalation_Round1NonRevealPenalty(t *testing.T) { + c := newBountyTestController(t) + fake := newFakeEscrow() + c.bountyEscrow = fake + stubEscalationPanel(t, r1Panel(7), nil) + + sb := testEvalBounty("r1silent") + status := &monetizeapi.ServiceBountyStatus{} + annotations := map[string]string{} + addRound0(annotations, map[string]int64{evalA: 0, evalB: 75, evalC: 100}) + + now0 := time.Now() + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0) + if status.Escalation == nil || status.Escalation.BudgetState != escrow.StateReserved { + t.Fatalf("escalation = %+v, want funded", status.Escalation) + } + + // All 7 commit; the reveal window opens. + r1Scores := map[string]int64{} + for i := 0; i < 7; i++ { + r1Scores[r1Addr(i)] = 80 + } + addRound1Commits(annotations, r1Scores) + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0.Add(time.Minute)) + if status.Escalation.RevealDeadline == nil { + t.Fatal("round-1 reveal window must open once all 2k+1 commitments are in") + } + + // Only 6 reveal. Before the deadline the round is not settled. + silent := r1Addr(6) + revealed := map[string]int64{} + for addr, score := range r1Scores { + if addr != silent { + revealed[addr] = score + } + } + addRound1Reveals(annotations, revealed) + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0.Add(2*time.Minute)) + if reason := conditionReason(status.Conditions, "Verified"); reason == "EvaluatorQuorum" { + t.Fatal("round 1 must not settle while a commitment is unrevealed inside the window") + } + + // Past the round-1 reveal window: the silent seat grades NonReveal — + // worst-case outlier, unpaid — and the median settles over the 6 reveals. + c.reconcileEvalMarket(context.Background(), sb, annotations, status, now0.Add(20*time.Minute)) + if !bountyConditionIsTrue(status.Conditions, "Verified") { + t.Fatal("round-1 median 80 must verify") + } + if status.WeightedScore != 80 { + t.Fatalf("WeightedScore = %d, want 80", status.WeightedScore) + } + var silentEval *monetizeapi.ServiceBountyEvaluation + for i := range status.Escalation.Evaluations { + if status.Escalation.Evaluations[i].Address == silent { + silentEval = &status.Escalation.Evaluations[i] + } + } + if silentEval == nil { + t.Fatal("silent round-1 evaluator missing from escalation evaluations") + } + if silentEval.Phase != evalPhaseNonReveal { + t.Fatalf("silent evaluator phase = %q, want NonReveal", silentEval.Phase) + } + if silentEval.WithinBand { + t.Fatal("a round-1 non-reveal must grade as a worst-case outlier") + } + if silentEval.Paid { + t.Fatal("a round-1 non-reveal must not be paid") + } + if len(fake.batches["uid-r1silent-eval-r1"]) != 6 { + t.Fatalf("round-1 batch = %d recipients, want 6 (non-reveal earns nothing)", len(fake.batches["uid-r1silent-eval-r1"])) + } +} + +// ── grounding ─────────────────────────────────────────────────────────────── + +func TestGrounding_Matrix(t *testing.T) { + const score = int64(90) + canonical := common.HexToAddress(evalA) + + cases := []struct { + name string + statuses map[common.Hash]erc8004.ValidationStatus + dialErr error + readErr error + grounded bool + wantReason string + wantInMsg string + }{ + { + name: "match grounds", + statuses: map[common.Hash]erc8004.ValidationStatus{ + erc8004.BountyEvalRequestHash("uid-ground", canonical.Hex()): {ValidatorAddress: canonical, Response: 90}, + }, + grounded: true, + wantReason: "Grounded", + }, + { + name: "wrong responder stays ungrounded", + statuses: map[common.Hash]erc8004.ValidationStatus{ + erc8004.BountyEvalRequestHash("uid-ground", canonical.Hex()): {ValidatorAddress: common.HexToAddress(evalB), Response: 90}, + }, + wantReason: "NotGrounded", + wantInMsg: "not the evaluator", + }, + { + name: "wrong score stays ungrounded", + statuses: map[common.Hash]erc8004.ValidationStatus{ + erc8004.BountyEvalRequestHash("uid-ground", canonical.Hex()): {ValidatorAddress: canonical, Response: 10}, + }, + wantReason: "NotGrounded", + wantInMsg: "on-chain response 10", + }, + { + name: "no on-chain entry stays ungrounded", + statuses: map[common.Hash]erc8004.ValidationStatus{}, + wantReason: "NotGrounded", + wantInMsg: "no on-chain validation entry", + }, + { + name: "chain down stays ungrounded", + dialErr: errors.New("erpc unreachable"), + wantReason: "ChainUnreachable", + wantInMsg: "unreachable", + }, + { + name: "chain read error stays ungrounded", + statuses: nil, + readErr: errors.New("rpc timeout"), + wantReason: "NotGrounded", + wantInMsg: "chain read failed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stubValidationReader(t, &fakeValidationReader{statuses: tc.statuses, readErr: tc.readErr}, tc.dialErr) + + c := newBountyTestController(t) + c.bountyEscrow = newFakeEscrow() + sb := testEvalBounty("ground") + sb.Spec.Eval.K = 1 + status := &monetizeapi.ServiceBountyStatus{} + annotations := map[string]string{ + bountyEvalCommitPrefix + evalA: monetizeapi.EvalCommitHash(score, "salt-g", evalA), + bountyEvalRevealPrefix + evalA: fmt.Sprintf(`{"score":%d,"salt":"salt-g","validationTx":"0xfeed"}`, score), + } + c.reconcileEvalMarket(context.Background(), sb, annotations, status, time.Now()) + + if len(status.Evaluations) != 1 { + t.Fatalf("evaluations = %d, want 1", len(status.Evaluations)) + } + if status.Evaluations[0].Grounded != tc.grounded { + t.Fatalf("Grounded = %v, want %v", status.Evaluations[0].Grounded, tc.grounded) + } + if reason := conditionReason(status.Conditions, "EvalGrounded"); reason != tc.wantReason { + t.Fatalf("EvalGrounded reason = %q, want %q", reason, tc.wantReason) + } + if tc.wantInMsg != "" { + if msg := bountyConditionMessage(status.Conditions, "EvalGrounded"); !strings.Contains(msg, tc.wantInMsg) { + t.Fatalf("EvalGrounded message %q must contain %q", msg, tc.wantInMsg) + } + } + // Grounding NEVER blocks or changes the verdict: median 90 passes + // in every case. + if !bountyConditionIsTrue(status.Conditions, "Verified") { + t.Fatal("verdict must not depend on grounding") + } + if status.WeightedScore != score { + t.Fatalf("WeightedScore = %d, want %d", status.WeightedScore, score) + } + }) + } +} + +func TestGrounding_NoValidationTxDialsNothing(t *testing.T) { + // A reveal without validationTx must never dial the chain: the factory + // stub fails the test if invoked. + orig := bountyValidationReaderFactory + bountyValidationReaderFactory = func(context.Context, string, string) (bountyValidationReader, func(), error) { + t.Fatal("grounding must not dial the chain when no reveal carries validationTx") + return nil, nil, nil + } + t.Cleanup(func() { bountyValidationReaderFactory = orig }) + + c := newBountyTestController(t) + c.bountyEscrow = newFakeEscrow() + sb := testEvalBounty("nodial") + sb.Spec.Eval.K = 1 + status := &monetizeapi.ServiceBountyStatus{} + annotations := map[string]string{ + bountyEvalCommitPrefix + evalA: monetizeapi.EvalCommitHash(90, "s", evalA), + bountyEvalRevealPrefix + evalA: `{"score":90,"salt":"s"}`, + } + c.reconcileEvalMarket(context.Background(), sb, annotations, status, time.Now()) + if reason := conditionReason(status.Conditions, "EvalGrounded"); reason != "" { + t.Fatalf("EvalGrounded condition must not exist without validationTx claims, got %q", reason) + } +} + +// ── escrow config provenance ──────────────────────────────────────────────── + +// TestBountyEscrowGateway_ConfigFromEnvOnly re-asserts the seam invariant: +// the escrow endpoint + bearer token come ONLY from controller env. Nothing +// in a bounty's spec or annotations selects or redirects the gateway. +func TestBountyEscrowGateway_ConfigFromEnvOnly(t *testing.T) { + t.Setenv("OBOL_BOUNTY_ESCROW_URL", "https://facilitator.internal.example") + t.Setenv("OBOL_BOUNTY_ESCROW_TOKEN", "release-authority-token") + gateway := newBountyEscrowGateway() + httpGateway, ok := gateway.(*escrow.HTTPGateway) + if !ok { + t.Fatalf("gateway = %T, want *escrow.HTTPGateway when env is set", gateway) + } + if httpGateway.Base != "https://facilitator.internal.example" || httpGateway.Token != "release-authority-token" { + t.Fatalf("gateway config = %q/%q, want env values", httpGateway.Base, httpGateway.Token) + } + + t.Setenv("OBOL_BOUNTY_ESCROW_URL", "") + if _, ok := newBountyEscrowGateway().(*escrow.LedgerGateway); !ok { + t.Fatal("no env URL must fall back to the dev ledger") + } +} + +func TestBountyEscrow_AnnotationsCannotRedirectGateway(t *testing.T) { + fake := newFakeEscrow() + sb := testBounty("hostile") + c := newBountyTestController(t, sb) + c.bountyEscrow = fake + ns := "hermes-obol-agent" + + // Hostile annotations trying to smuggle endpoint/credential config (and a + // voucher whose unknown fields are ignored by the typed decode). + annotateBounty(t, c, ns, "hostile", map[string]string{ + "obol.org/escrow-url": "http://attacker.example", + "obol.org/escrow-token": "stolen", + "obol.org/escrow-facilitator": "http://attacker.example", + bountyRewardVoucherAnnotation: `{"owner":"0x1111111111111111111111111111111111111111","base":"http://attacker.example","token":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","nonce":"1","deadline":1,"signature":"0x00"}`, + }) + reconcileBountyUntilSettled(t, c, ns+"/hostile") + + // The injected gateway received the reserve — the annotations selected + // nothing. The voucher decoded only its typed Permit2 fields. + req := fake.lastReserve(t, "uid-hostile") + if req.Voucher == nil || req.Voucher.Owner != "0x1111111111111111111111111111111111111111" { + t.Fatalf("voucher not ferried: %+v", req.Voucher) + } + got := getBounty(t, c, ns, "hostile") + if got.Status.EscrowState != escrow.StateReserved { + t.Fatalf("EscrowState = %q, want Reserved via the env-configured gateway", got.Status.EscrowState) + } +} diff --git a/internal/serviceoffercontroller/bounty_eval.go b/internal/serviceoffercontroller/bounty_eval.go index 5585acb4..85546772 100644 --- a/internal/serviceoffercontroller/bounty_eval.go +++ b/internal/serviceoffercontroller/bounty_eval.go @@ -19,18 +19,25 @@ package serviceoffercontroller // - quorum = MEDIAN of revealed scores (robust to one outlier, which is // what makes the future probation seat verdict-safe); // - WithinBand records divergence from the median per evaluator — the -// per-bounty bookkeeping hook the reputation ladder will key on. +// per-bounty bookkeeping hook the reputation ladder keys on; +// - a diverged or knife-edge round 0 escalates ONCE to a fresh 2k+1 panel +// whose median is final (bounty_escalation.go); +// - reveals carrying a validationTx are grounded against the on-chain +// ERC-8004 entry before ladder bookkeeping (bounty_grounding.go). // -// Deliberately NOT here yet: evaluator selection (needs an enrollment pool), -// the OBOL eval-payment leg (signed by the poster's agent at selection time, -// batch-settled at the facilitator — never by this controller), and -// cross-bounty ladder state. The controller signs NOTHING. +// Money legs ferried here: Permit2 vouchers ride in on annotations +// (obol.org/{reward,bond,eval}-voucher[-r1]) and are attached to the matching +// escrow ReserveRequest. The controller still signs NOTHING — a voucher is a +// poster-signed authorization the facilitator executes; the annotation channel +// can never carry escrow endpoint or credential config (that comes ONLY from +// controller env, see newBountyEscrowGateway). import ( "context" "encoding/json" "fmt" "log" + "math/big" "sort" "strconv" "strings" @@ -38,6 +45,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/bounty" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ObolNetwork/obol-stack/internal/x402" "github.com/ObolNetwork/obol-stack/internal/x402/escrow" "github.com/ethereum/go-ethereum/common" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -65,6 +73,25 @@ const ( defaultRevealWindow = 10 * time.Minute ) +// Voucher ferry: annotations carrying a JSON-encoded escrow.Permit2Voucher, +// signed by the poster's agent and attached to the matching ReserveRequest. +const ( + bountyRewardVoucherAnnotation = "obol.org/reward-voucher" + bountyBondVoucherAnnotation = "obol.org/bond-voucher" + bountyEvalVoucherAnnotation = "obol.org/eval-voucher" + bountyEvalVoucherR1Annotation = "obol.org/eval-voucher-r1" + + // escrowStateAwaitingVoucher is the facilitator's "request verified, + // waiting for the signed Permit2 voucher" reservation state. Reserves in + // this state re-run on later reconciles (idempotent at the facilitator) + // until the voucher annotation ferries in. + escrowStateAwaitingVoucher = "AwaitingVoucher" + + // escrowStateUnfunded parks an escalation whose voucher never arrived + // before the escalation window closed — the round-0 median stands. + escrowStateUnfunded = "Unfunded" +) + // bountyEvalReveal is the eval-reveal annotation payload. ValidationTx is the // optional ERC-8004 validationResponse transaction the evaluator submitted // with their OWN wallet — recorded as provenance, never required. @@ -81,87 +108,88 @@ func evalMarketActive(sb *monetizeapi.ServiceBounty) bool { sb.Spec.Acceptance.Method != "poster-manual" } -// reconcileEvalMarket promotes commit/reveal annotations into status and, once -// the quorum settles, writes the Verified condition with reason -// EvaluatorQuorum. Returns a positive duration when the bounty should be -// requeued (reveal-window expiry). -func (c *Controller) reconcileEvalMarket(ctx context.Context, sb *monetizeapi.ServiceBounty, annotations map[string]string, status *monetizeapi.ServiceBountyStatus, now time.Time) time.Duration { - // 0. Panel selection (once) + eval-budget reservation (once). The budget - // is the SEPARATE OBOL leg: k × perEvaluator, poster-funded, paid to - // evaluators win-or-lose. - c.ensurePanel(ctx, sb, status) - c.reserveEvalBudget(ctx, sb, status) - - // Seat lookup is by CANONICAL (EIP-55) address — enrollments may carry any - // case, annotations another; HexToAddress.Hex() is the one true form. - panelSeats := make(map[string]string, len(status.EvaluatorPanel)) - for _, seat := range status.EvaluatorPanel { - panelSeats[common.HexToAddress(seat.Address).Hex()] = seat.Seat - } +// evalRoundIO bundles one commit-reveal round: the annotation prefixes it +// reads and the status fields it mutates. Round 0 points at the top-level +// status fields; round 1 points into status.escalation. Same engine, same +// semantics (address-bound commits, K-gated reveal window, non-reveal = +// worst-case outlier). +type evalRoundIO struct { + commitPrefix string + revealPrefix string + // seats maps canonical (EIP-55) address → seat kind. With restrict=true + // only seated addresses are admitted (panel mode); otherwise the door is + // open (round-0 fallback when the enrolled pool is too small). + seats map[string]string + restrict bool + // k counting commitments close the commit window and open the reveal + // window. + k int64 + window time.Duration + evaluations *[]monetizeapi.ServiceBountyEvaluation + deadline **metav1.Time +} +// runEvalRound drives one commit-reveal round over the annotation channel. +// It reports whether the round settled (every commitment graded, or the +// reveal window closed) and a positive requeue duration when the reveal +// window was just opened. +func runEvalRound(annotations map[string]string, round evalRoundIO, now time.Time) (settled bool, requeue time.Duration) { // 1. Promote commitments (first write wins per address — a commitment is - // binding; later annotation edits must not rewrite history). With a panel - // selected, only panel members are admitted; shadows are admitted but - // never counted. + // binding; later annotation edits must not rewrite history). for key, value := range annotations { - addr, ok := strings.CutPrefix(key, bountyEvalCommitPrefix) + addr, ok := strings.CutPrefix(key, round.commitPrefix) if !ok || !common.IsHexAddress(addr) { continue } canonical := common.HexToAddress(addr).Hex() seat := "" - if len(panelSeats) > 0 { - s, selected := panelSeats[canonical] + if round.restrict { + s, selected := round.seats[canonical] if !selected { continue // not on the panel — the open door is closed } seat = s } - if findEvaluation(status.Evaluations, canonical) != nil { + if findEvaluation(*round.evaluations, canonical) != nil { continue } - status.Evaluations = append(status.Evaluations, monetizeapi.ServiceBountyEvaluation{ + *round.evaluations = append(*round.evaluations, monetizeapi.ServiceBountyEvaluation{ Address: canonical, CommitHash: strings.TrimSpace(value), Phase: evalPhaseCommitted, Seat: seat, }) } - sort.Slice(status.Evaluations, func(i, j int) bool { - return status.Evaluations[i].Address < status.Evaluations[j].Address + evaluations := *round.evaluations + sort.Slice(evaluations, func(i, j int) bool { + return evaluations[i].Address < evaluations[j].Address }) - k := sb.Spec.Eval.K - if k < 1 { - k = 1 - } - // 2. The commit window closes (and the reveal window opens) only when K // COUNTING commitments are in (shadows never gate the window). No reveal // is graded before that instant. - var requeue time.Duration - if status.RevealDeadline == nil { + if *round.deadline == nil { counting := int64(0) - for _, evaluation := range status.Evaluations { + for _, evaluation := range evaluations { if evaluation.Seat != monetizeapi.PanelSeatShadow { counting++ } } - if counting < k { - return 0 + if counting < round.k { + return false, 0 } - deadline := metav1.NewTime(now.Add(revealWindow(sb))) - status.RevealDeadline = &deadline + deadline := metav1.NewTime(now.Add(round.window)) + *round.deadline = &deadline requeue = time.Until(deadline.Time) + time.Second } // 3. Grade reveals against the address-bound commitment. for key, value := range annotations { - addr, ok := strings.CutPrefix(key, bountyEvalRevealPrefix) + addr, ok := strings.CutPrefix(key, round.revealPrefix) if !ok || !common.IsHexAddress(addr) { continue } - evaluation := findEvaluation(status.Evaluations, common.HexToAddress(addr).Hex()) + evaluation := findEvaluation(evaluations, common.HexToAddress(addr).Hex()) if evaluation == nil || evaluation.Phase != evalPhaseCommitted { continue } @@ -182,39 +210,67 @@ func (c *Controller) reconcileEvalMarket(ctx context.Context, sb *monetizeapi.Se } // 4. Past the reveal window, missing reveals become worst-case outliers. - deadlinePassed := now.After(status.RevealDeadline.Time) + deadlinePassed := now.After((*round.deadline).Time) if deadlinePassed { - for i := range status.Evaluations { - if status.Evaluations[i].Phase == evalPhaseCommitted { - status.Evaluations[i].Phase = evalPhaseNonReveal + for i := range evaluations { + if evaluations[i].Phase == evalPhaseCommitted { + evaluations[i].Phase = evalPhaseNonReveal } } } - // 5. Quorum settles when every commitment is graded (all revealed early) - // or the reveal window has closed. - settled := deadlinePassed + // 5. The round settles when every commitment is graded (all revealed + // early) or the reveal window has closed. + settled = deadlinePassed if !settled { settled = true - for _, evaluation := range status.Evaluations { + for _, evaluation := range evaluations { if evaluation.Phase == evalPhaseCommitted { settled = false break } } } + return settled, requeue +} + +// reconcileEvalMarket promotes commit/reveal annotations into status and, once +// the quorum settles (running at most one escalation round first), writes the +// Verified condition with reason EvaluatorQuorum. Returns a positive duration +// when the bounty should be requeued (reveal-window or escalation-window +// expiry). +func (c *Controller) reconcileEvalMarket(ctx context.Context, sb *monetizeapi.ServiceBounty, annotations map[string]string, status *monetizeapi.ServiceBountyStatus, now time.Time) time.Duration { + // 0. Panel selection (once) + eval-budget reservation. The budget is the + // SEPARATE OBOL leg: k × perEvaluator, poster-funded, paid to evaluators + // win-or-lose. + c.ensurePanel(ctx, sb, status) + c.reserveEvalBudget(ctx, sb, annotations, status) + + // Seat lookup is by CANONICAL (EIP-55) address — enrollments may carry any + // case, annotations another; HexToAddress.Hex() is the one true form. + panelSeats := make(map[string]string, len(status.EvaluatorPanel)) + for _, seat := range status.EvaluatorPanel { + panelSeats[common.HexToAddress(seat.Address).Hex()] = seat.Seat + } + + k := evalQuorumK(sb) + settled, requeue := runEvalRound(annotations, evalRoundIO{ + commitPrefix: bountyEvalCommitPrefix, + revealPrefix: bountyEvalRevealPrefix, + seats: panelSeats, + restrict: len(panelSeats) > 0, + k: k, + window: revealWindow(sb), + evaluations: &status.Evaluations, + deadline: &status.RevealDeadline, + }, now) if !settled { return requeue } // Median over COUNTING reveals only — shadows are graded against it but // never move it (the free reputation on-ramp can't sway verdicts). - var scores []int64 - for _, evaluation := range status.Evaluations { - if evaluation.Phase == evalPhaseRevealed && evaluation.Seat != monetizeapi.PanelSeatShadow { - scores = append(scores, evaluation.Score) - } - } + scores := countingScores(status.Evaluations) if len(scores) == 0 { setPurchaseCondition(&status.Conditions, "Verified", "False", "EvaluatorQuorum", "No valid reveals — submission unverifiable; poster may override or the deadline refunds") @@ -222,40 +278,86 @@ func (c *Controller) reconcileEvalMarket(ctx context.Context, sb *monetizeapi.Se } median := medianInt64(scores) - for i := range status.Evaluations { - evaluation := &status.Evaluations[i] - switch evaluation.Phase { - case evalPhaseRevealed: - diff := evaluation.Score - median - if diff < 0 { - diff = -diff + markOutlierBands(status.Evaluations, median) + + // Escalation trigger — checked after every counting reveal is graded and + // BEFORE the EvaluatorQuorum verdict is spoken. Single-round latch: + // status.escalation, once set, is never re-opened; a spoken + // EvaluatorQuorum verdict latches the thin-pool fallthrough so a pool + // that grows later can never re-open a settled bounty. + quorumAlreadySpoke := conditionReason(status.Conditions, "Verified") == "EvaluatorQuorum" + if sb.Spec.Eval.Mode == monetizeapi.EvalModeRequired && status.Escalation == nil && !quorumAlreadySpoke { + if reason := escalationTrigger(status.Evaluations, k, median, escalationEpsilon(sb)); reason != "" { + if opened, retry := c.openEscalation(ctx, sb, annotations, status, reason, now); !opened && retry { + // Transient selection failure — verdict not spoken. The + // deadline requeue may be 0 here (reveal deadline already + // passed), so schedule the retry explicitly or a deadline-less + // bounty would wait for an external event. + return maxDuration(requeue, seedRetryDelay) } - evaluation.WithinBand = diff <= evalOutlierBand - default: - evaluation.WithinBand = false } } - status.WeightedScore = median - if median >= evalPassThreshold { + finalMedian := median + finalReveals := len(scores) + escalated := false + if esc := status.Escalation; esc != nil { + done, escRequeue := c.runEscalation(ctx, sb, annotations, status, now) + if !done { + return maxDuration(requeue, escRequeue) + } + if r1Scores := countingScores(esc.Evaluations); len(r1Scores) > 0 { + // The round-1 median over the 2k+1 panel is FINAL. + finalMedian = medianInt64(r1Scores) + finalReveals = len(r1Scores) + markOutlierBands(esc.Evaluations, finalMedian) + escalated = true + } else if len(esc.Evaluations) > 0 { + markOutlierBands(esc.Evaluations, median) + setPurchaseCondition(&status.Conditions, "Escalated", "True", "EscalationNoReveals", + "Round-1 panel produced no valid reveals — the round-0 median stands") + } + } + + escalationNote := "" + if escalated { + escalationNote = fmt.Sprintf(" — escalated (%s); round-1 median is final", status.Escalation.Reason) + } + status.WeightedScore = finalMedian + if finalMedian >= evalPassThreshold { setPurchaseCondition(&status.Conditions, "Verified", "True", "EvaluatorQuorum", - fmt.Sprintf("Median score %d/100 from %d reveal(s) meets the %d threshold", median, len(scores), evalPassThreshold)) + fmt.Sprintf("Median score %d/100 from %d reveal(s) meets the %d threshold%s", finalMedian, finalReveals, evalPassThreshold, escalationNote)) if len(status.Claims) > 0 && status.Claims[0].Phase == bountyPhaseSubmitted { status.Claims[0].Phase = bountyPhaseVerified } } else { setPurchaseCondition(&status.Conditions, "Verified", "False", "EvaluatorQuorum", - fmt.Sprintf("Median score %d/100 from %d reveal(s) is below the %d threshold", median, len(scores), evalPassThreshold)) + fmt.Sprintf("Median score %d/100 from %d reveal(s) is below the %d threshold%s", finalMedian, finalReveals, evalPassThreshold, escalationNote)) if len(status.Claims) > 0 && status.Claims[0].Phase == bountyPhaseSubmitted { status.Claims[0].Phase = bountyPhaseRejected } } // 6. Settlement side-effects, once per bounty: pay the evaluators - // (win-or-lose — they did the work) and record the cross-bounty ladder. + // (win-or-lose — they did the work), ground reveals against the chain, + // and record the cross-bounty ladder. Grounding runs BEFORE ladder + // bookkeeping (recordLadder reads Grounded) and never changes the verdict. c.settleEvalBudget(ctx, sb, status) + c.settleEscalationBudget(ctx, sb, status) if !status.LadderRecorded { - if err := c.recordLadder(ctx, sb, status); err != nil { + c.groundEvaluations(ctx, sb, status, status.Evaluations) + if status.Escalation != nil { + c.groundEvaluations(ctx, sb, status, status.Escalation.Evaluations) + } + err := c.recordLadder(ctx, sb, status) + if err == nil && status.Escalation != nil && len(status.Escalation.Evaluations) > 0 { + // Ladder bookkeeping covers round-1 participants too, graded + // against the round-1 median (already banded above). + roundOne := *status + roundOne.Evaluations = status.Escalation.Evaluations + err = c.recordLadder(ctx, sb, &roundOne) + } + if err != nil { log.Printf("serviceoffer-controller: record evaluator ladder for %s/%s: %v", sb.Namespace, sb.Name, err) } else { status.LadderRecorded = true @@ -267,9 +369,14 @@ func (c *Controller) reconcileEvalMarket(ctx context.Context, sb *monetizeapi.Se // reserveEvalBudget holds the poster-funded OBOL eval budget (k × perEvaluator, // minus the newcomer discount when a probation seat is sitting) at the escrow // gateway under -eval. Errors are non-fatal: evaluation proceeds and the -// reserve retries on the next reconcile. -func (c *Controller) reserveEvalBudget(ctx context.Context, sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceBountyStatus) { - if status.EvalBudgetState != "" || sb.Spec.Eval.Payment.PerEvaluator == "" { +// reserve retries on the next reconcile. An AwaitingVoucher hold re-reserves +// each reconcile (idempotent) until the obol.org/eval-voucher annotation +// ferries the poster's Permit2 voucher in. +func (c *Controller) reserveEvalBudget(ctx context.Context, sb *monetizeapi.ServiceBounty, annotations map[string]string, status *monetizeapi.ServiceBountyStatus) { + if sb.Spec.Eval.Payment.PerEvaluator == "" { + return + } + if status.EvalBudgetState != "" && status.EvalBudgetState != escrowStateAwaitingVoucher { return } total := evalBudgetTotal(sb, status) @@ -283,12 +390,46 @@ func (c *Controller) reserveEvalBudget(ctx context.Context, sb *monetizeapi.Serv Asset: sb.Spec.Eval.Payment.Asset, Amount: total, Scheme: sb.Spec.Reward.Escrow.Scheme, + Voucher: voucherFromAnnotations(annotations, bountyEvalVoucherAnnotation), }) if err != nil { log.Printf("serviceoffer-controller: reserve eval budget for %s/%s: %v", sb.Namespace, sb.Name, err) return } status.EvalBudgetState = receipt.State + ferryEscrowSpender(status, receipt) +} + +// evalSeatAmounts resolves the per-evaluator eval price into the full and +// probation-half per-seat amount strings used for CaptureBatch recipients. +// When the asset resolves in the token registry the amounts are ATOMIC token +// units — escrow.BuildTransferDetails matches capture recipients against the +// poster's Permit2 voucher seats with exact integer comparison, and the CLI +// (cmd/obol bountyEvalFundRecipients) signs perAtomic / floor(perAtomic/2). +// An unresolvable asset falls back to human-unit strings: the dev ledger +// gateway treats amounts as opaque bookkeeping, and a real facilitator could +// never have verified a voucher for a token the CLI cannot resolve either. +func evalSeatAmounts(sb *monetizeapi.ServiceBounty) (full, half string, ok bool) { + per := strings.TrimSpace(sb.Spec.Eval.Payment.PerEvaluator) + perFloat, err := strconv.ParseFloat(per, 64) + if err != nil || perFloat <= 0 { + return "", "", false + } + full = strconv.FormatFloat(perFloat, 'f', 2, 64) + half = strconv.FormatFloat(perFloat/2, 'f', 2, 64) + entry, found := x402.ResolveToken(sb.Spec.Eval.Payment.Asset, sb.Spec.Reward.Network) + if !found { + return full, half, true + } + atomicStr, err := escrow.HumanToAtomic(per, entry.Decimals) + if err != nil { + return full, half, true + } + perAtomic, parsed := new(big.Int).SetString(atomicStr, 10) + if !parsed { + return full, half, true + } + return perAtomic.String(), new(big.Int).Div(perAtomic, big.NewInt(2)).String(), true } // settleEvalBudget batch-settles the held eval budget to every counting @@ -299,17 +440,14 @@ func (c *Controller) settleEvalBudget(ctx context.Context, sb *monetizeapi.Servi if status.EvalBudgetState != escrow.StateReserved { return } - per, err := strconv.ParseFloat(strings.TrimSpace(sb.Spec.Eval.Payment.PerEvaluator), 64) - if err != nil || per <= 0 { + fullAmount, halfAmount, ok := evalSeatAmounts(sb) + if !ok { return } var recipients []escrow.BatchRecipient paid := make(map[string]bool) - k := sb.Spec.Eval.K - if k < 1 { - k = 1 - } + k := evalQuorumK(sb) for i := range status.Evaluations { evaluation := &status.Evaluations[i] if evaluation.Phase != evalPhaseRevealed || evaluation.Seat == monetizeapi.PanelSeatShadow { @@ -318,13 +456,13 @@ func (c *Controller) settleEvalBudget(ctx context.Context, sb *monetizeapi.Servi if int64(len(recipients)) >= k { break // open-door can over-subscribe; the budget pays k seats } - amount := per + amount := fullAmount if evaluation.Seat == monetizeapi.PanelSeatProbation { - amount = per / 2 + amount = halfAmount } recipients = append(recipients, escrow.BatchRecipient{ Address: evaluation.Address, - Amount: strconv.FormatFloat(amount, 'f', 2, 64), + Amount: amount, }) paid[evaluation.Address] = true } @@ -333,6 +471,7 @@ func (c *Controller) settleEvalBudget(ctx context.Context, sb *monetizeapi.Servi } var receipt escrow.Receipt + var err error if batch, ok := c.escrowGateway().(escrow.BatchGateway); ok { receipt, err = batch.CaptureBatch(ctx, string(sb.UID)+"-eval", recipients) } else { @@ -344,6 +483,7 @@ func (c *Controller) settleEvalBudget(ctx context.Context, sb *monetizeapi.Servi } status.EvalBudgetState = receipt.State status.EvalPayoutTxHash = receipt.TxHash + ferryEscrowSpender(status, receipt) for i := range status.Evaluations { if paid[status.Evaluations[i].Address] { status.Evaluations[i].Paid = true @@ -358,11 +498,7 @@ func evalBudgetTotal(sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceB if err != nil || per <= 0 { return "" } - k := sb.Spec.Eval.K - if k < 1 { - k = 1 - } - total := float64(k) * per + total := float64(evalQuorumK(sb)) * per for _, seat := range status.EvaluatorPanel { if seat.Seat == monetizeapi.PanelSeatProbation { total -= per / 2 @@ -372,6 +508,45 @@ func evalBudgetTotal(sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceB return strconv.FormatFloat(total, 'f', 2, 64) } +// evalQuorumK is spec.eval.k floored at 1 (the median of one is that one). +func evalQuorumK(sb *monetizeapi.ServiceBounty) int64 { + k := sb.Spec.Eval.K + if k < 1 { + k = 1 + } + return k +} + +// countingScores collects the revealed scores of counting (non-shadow) seats. +func countingScores(evaluations []monetizeapi.ServiceBountyEvaluation) []int64 { + var scores []int64 + for _, evaluation := range evaluations { + if evaluation.Phase == evalPhaseRevealed && evaluation.Seat != monetizeapi.PanelSeatShadow { + scores = append(scores, evaluation.Score) + } + } + return scores +} + +// markOutlierBands grades every evaluation's divergence from the median: +// revealed scores within evalOutlierBand are in band; non/bad reveals are +// worst-case outliers by definition. +func markOutlierBands(evaluations []monetizeapi.ServiceBountyEvaluation, median int64) { + for i := range evaluations { + evaluation := &evaluations[i] + switch evaluation.Phase { + case evalPhaseRevealed: + diff := evaluation.Score - median + if diff < 0 { + diff = -diff + } + evaluation.WithinBand = diff <= evalOutlierBand + default: + evaluation.WithinBand = false + } + } +} + func findEvaluation(evaluations []monetizeapi.ServiceBountyEvaluation, address string) *monetizeapi.ServiceBountyEvaluation { for i := range evaluations { if evaluations[i].Address == address { @@ -406,3 +581,54 @@ func medianInt64(values []int64) int64 { } return (sorted[mid-1] + sorted[mid]) / 2 } + +// ── voucher ferry helpers ─────────────────────────────────────────────────── + +// voucherFromAnnotations decodes the JSON Permit2 voucher ferried on the given +// annotation. A voucher carries ONLY poster-signed transfer fields +// (escrow.Permit2Voucher); escrow endpoint/credential configuration comes from +// controller env alone (newBountyEscrowGateway) and can never ride in here. +// Malformed payloads are treated as absent — the facilitator keeps the hold in +// AwaitingVoucher until a valid voucher arrives. +func voucherFromAnnotations(annotations map[string]string, key string) *escrow.Permit2Voucher { + raw := strings.TrimSpace(annotations[key]) + if raw == "" { + return nil + } + var voucher escrow.Permit2Voucher + if err := json.Unmarshal([]byte(raw), &voucher); err != nil { + log.Printf("serviceoffer-controller: invalid %s annotation (ignored): %v", key, err) + return nil + } + return &voucher +} + +// ferryEscrowSpender records the FIRST non-empty facilitator spender address +// seen on any escrow receipt into status.escrowSpender, so poster-side signers +// know which executor to bind their Permit2 vouchers to. +func ferryEscrowSpender(status *monetizeapi.ServiceBountyStatus, receipt escrow.Receipt) { + if status.EscrowSpender == "" && receipt.Spender != "" { + status.EscrowSpender = receipt.Spender + } +} + +// isEscrowVoucherRefusal classifies a facilitator capture refusal caused by a +// missing/expired voucher (HTTPGateway surfaces the response body inside the +// error text). Such refusals park as a condition + requeue — a poster-side +// signing gap must never fail the reconcile loop. Only the facilitator's 409 +// awaiting-voucher refusal parks: a 400 seat-mismatch (recipients not in the +// stored voucher) must surface as a capture failure, not loop as "awaiting". +func isEscrowVoucherRefusal(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "facilitator returned 409") && strings.Contains(msg, "voucher") +} + +func maxDuration(a, b time.Duration) time.Duration { + if a > b { + return a + } + return b +} diff --git a/internal/serviceoffercontroller/bounty_eval_test.go b/internal/serviceoffercontroller/bounty_eval_test.go index ec5b8963..485c4c21 100644 --- a/internal/serviceoffercontroller/bounty_eval_test.go +++ b/internal/serviceoffercontroller/bounty_eval_test.go @@ -270,3 +270,73 @@ func TestMedianInt64(t *testing.T) { } } } + +// ── eval payment units: capture recipients must match the voucher seats ──── + +// The poster's Permit2 voucher seats are signed in ATOMIC token units +// (cmd/obol bountyEvalFundRecipients: perAtomic, probation floor(perAtomic/2)); +// escrow.BuildTransferDetails matches CaptureBatch recipients against those +// seats with exact integer comparison. The controller's settle paths must +// therefore speak atomic units whenever the asset resolves in the token +// registry — a human-unit "2.00" recipient would 4xx every real capture. +func TestEvalSeatAmounts_AtomicMatchesVoucherSeatMath(t *testing.T) { + sb := testEvalBounty("atomic-units") // Asset OBOL, PerEvaluator 2.00 + sb.Spec.Reward.Network = "base-sepolia" + + full, half, ok := evalSeatAmounts(sb) + if !ok { + t.Fatal("evalSeatAmounts must resolve a positive perEvaluator price") + } + wantFull, err := escrow.HumanToAtomic("2.00", 18) // OBOL is 18 decimals on base-sepolia + if err != nil { + t.Fatalf("HumanToAtomic: %v", err) + } + if full != wantFull || full != "2000000000000000000" { + t.Fatalf("full seat = %q, want atomic %q", full, wantFull) + } + if half != "1000000000000000000" { + t.Fatalf("probation seat = %q, want floor(perAtomic/2) = 1000000000000000000", half) + } + + // An asset/network pair outside the token registry (OBOL is not + // registered on base mainnet) falls back to human-unit bookkeeping + // strings — the dev ledger gateway treats amounts as opaque, and no + // CLI-signed voucher can exist for an unresolvable token anyway. + sb.Spec.Reward.Network = "base" + full, half, ok = evalSeatAmounts(sb) + if !ok || full != "2.00" || half != "1.00" { + t.Fatalf("unresolvable token fallback = (%q, %q, %v), want (2.00, 1.00, true)", full, half, ok) + } + + sb.Spec.Eval.Payment.PerEvaluator = "not-a-number" + if _, _, ok := evalSeatAmounts(sb); ok { + t.Fatal("a non-numeric perEvaluator price must not settle") + } +} + +func TestEvalSettle_CaptureRecipientsAreAtomic(t *testing.T) { + sb := testEvalBounty("atomic-settle") + sb.Spec.Reward.Network = "base-sepolia" // OBOL resolves → atomic units + c := newBountyTestController(t, sb) + fake := newFakeEscrow() + c.bountyEscrow = fake + ns := "hermes-obol-agent" + + claimAndSubmit(t, c, ns, "atomic-settle") + // All in band (median 85) — no escalation, straight to settle. + commitAndReveal(t, c, ns, "atomic-settle", map[string]int64{evalA: 90, evalB: 85, evalC: 80}) + + got := getBounty(t, c, ns, "atomic-settle") + if got.Status.EvalBudgetState != escrow.StateCaptured { + t.Fatalf("eval budget = %q, want Captured", got.Status.EvalBudgetState) + } + recipients := fake.batches["uid-atomic-settle-eval"] + if len(recipients) != 3 { + t.Fatalf("capture recipients = %d, want 3", len(recipients)) + } + for _, r := range recipients { + if r.Amount != "2000000000000000000" { + t.Fatalf("recipient %s amount = %q, want atomic 2000000000000000000 (matches the CLI voucher seat)", r.Address, r.Amount) + } + } +} diff --git a/internal/serviceoffercontroller/bounty_grounding.go b/internal/serviceoffercontroller/bounty_grounding.go new file mode 100644 index 00000000..9a62f51c --- /dev/null +++ b/internal/serviceoffercontroller/bounty_grounding.go @@ -0,0 +1,122 @@ +package serviceoffercontroller + +// Grounding: an annotation-level reveal that carries a validationTx claims an +// on-chain ERC-8004 validationResponse backs it. The controller READS the +// Validation Registry on the bounty's payment network (per-network client via +// eRPC, ERC8004_RPC_BASE — the registration watcher pattern) and marks the +// evaluation Grounded only when the on-chain responder is the evaluator AND +// the on-chain response equals the revealed score, for the request hash +// erc8004.BountyEvalRequestHash(bountyUID, evaluator). +// +// Grounding is ADVISORY reputation signal: chain unreachable, no entry, or a +// mismatch leaves Grounded=false with a condition explaining why — it never +// blocks, delays, or changes the quorum verdict. The controller still signs +// nothing; the validationResponse tx was submitted by the evaluator's own +// wallet. + +import ( + "context" + "fmt" + "strings" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" + "github.com/ethereum/go-ethereum/common" +) + +// bountyValidationReader is the narrow chain-read seam grounding needs. +type bountyValidationReader interface { + ValidationStatus(ctx context.Context, requestHash common.Hash) (erc8004.ValidationStatus, error) +} + +// bountyValidationReaderFactory dials a read-only ERC-8004 Validation Registry +// reader for the given network. It is a package seam (the grounding twin of +// the bountyEscrow fake) swapped by tests to inject a fake chain; it cannot be +// a Controller field without editing controller.go, which a parallel lane +// owns. The returned func() releases the underlying RPC client. +var bountyValidationReaderFactory = func(ctx context.Context, rpcBase, network string) (bountyValidationReader, func(), error) { + net, err := erc8004.ResolveNetwork(network) + if err != nil { + return nil, nil, err + } + registry, err := erc8004.ValidationRegistryAddress(network) + if err != nil { + return nil, nil, err + } + client, err := erc8004.NewClientForNetwork(ctx, rpcBase, net) + if err != nil { + return nil, nil, err + } + reader, err := erc8004.NewValidationReader(client.ETH(), registry) + if err != nil { + client.Close() + return nil, nil, err + } + return reader, client.Close, nil +} + +// groundEvaluations sets Grounded on every revealed evaluation in the slice +// whose validationTx claim is backed by a matching on-chain validation entry. +// It runs BEFORE ladder bookkeeping (recordLadder reads Grounded) and mutates +// only the Grounded flags + the EvalGrounded condition — never the verdict. +func (c *Controller) groundEvaluations(ctx context.Context, sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceBountyStatus, evaluations []monetizeapi.ServiceBountyEvaluation) { + var pending []int + for i := range evaluations { + if evaluations[i].Phase == evalPhaseRevealed && + strings.TrimSpace(evaluations[i].ValidationTxHash) != "" && + !evaluations[i].Grounded { + pending = append(pending, i) + } + } + if len(pending) == 0 { + return // nothing claims chain backing — touch no condition, dial nothing + } + + network := sb.Spec.Reward.Network + if _, err := erc8004.ValidationRegistryAddress(network); err != nil { + setPurchaseCondition(&status.Conditions, "EvalGrounded", "False", "RegistryUnavailable", + truncateMessage(fmt.Sprintf("no validation registry for network %q: %v", network, err))) + return + } + + rpcBase := c.registrationRPCBase + if rpcBase == "" { + rpcBase = erc8004.DefaultRPCBase + } + reader, closeReader, err := bountyValidationReaderFactory(ctx, rpcBase, network) + if err != nil { + setPurchaseCondition(&status.Conditions, "EvalGrounded", "False", "ChainUnreachable", + truncateMessage(fmt.Sprintf("validation registry on %s unreachable: %v", network, err))) + return + } + defer closeReader() + + grounded := 0 + var problems []string + for _, i := range pending { + evaluation := &evaluations[i] + requestHash := erc8004.BountyEvalRequestHash(string(sb.UID), evaluation.Address) + onchain, err := reader.ValidationStatus(ctx, requestHash) + switch { + case err != nil: + problems = append(problems, fmt.Sprintf("%s: chain read failed: %v", evaluation.Address, err)) + case onchain.ValidatorAddress == (common.Address{}): + problems = append(problems, fmt.Sprintf("%s: no on-chain validation entry", evaluation.Address)) + case onchain.ValidatorAddress != common.HexToAddress(evaluation.Address): + problems = append(problems, fmt.Sprintf("%s: on-chain responder %s is not the evaluator", evaluation.Address, onchain.ValidatorAddress.Hex())) + case int64(onchain.Response) != evaluation.Score: + problems = append(problems, fmt.Sprintf("%s: on-chain response %d != revealed score %d", evaluation.Address, onchain.Response, evaluation.Score)) + default: + evaluation.Grounded = true + grounded++ + } + } + + if len(problems) == 0 { + setPurchaseCondition(&status.Conditions, "EvalGrounded", "True", "Grounded", + fmt.Sprintf("%d evaluation(s) grounded by on-chain ERC-8004 validation entries", grounded)) + } else { + setPurchaseCondition(&status.Conditions, "EvalGrounded", "False", "NotGrounded", + truncateMessage(strings.Join(problems, "; "))) + } +} diff --git a/internal/serviceoffercontroller/bounty_lifecycle_test.go b/internal/serviceoffercontroller/bounty_lifecycle_test.go index 76baa835..c6bda966 100644 --- a/internal/serviceoffercontroller/bounty_lifecycle_test.go +++ b/internal/serviceoffercontroller/bounty_lifecycle_test.go @@ -2,6 +2,7 @@ package serviceoffercontroller import ( "context" + "fmt" "strings" "testing" "time" @@ -313,3 +314,227 @@ func TestBountyLifecycle_InvalidClaimAddress(t *testing.T) { t.Fatalf("phase = %q, want Open", got.Status.Phase) } } + +// ── voucher ferry (Permit2 vouchers ride annotations into ReserveRequests) ── + +func TestBountyLifecycle_RewardVoucherFerry(t *testing.T) { + fake := newFakeEscrow() + fake.spender = "0xFAC0000000000000000000000000000000000FAC" + fake.requireVoucher["uid-ferry"] = true + c := newBountyTestController(t, testBounty("ferry")) + c.bountyEscrow = fake + ns := "hermes-obol-agent" + key := ns + "/ferry" + + // No voucher yet: the hold parks in AwaitingVoucher — surfaced as a + // condition, never a reconcile error — and the facilitator's spender is + // ferried into status for the poster-side signer. + reconcileBountyUntilSettled(t, c, key) + sb := getBounty(t, c, ns, "ferry") + if sb.Status.EscrowState != escrowStateAwaitingVoucher { + t.Fatalf("EscrowState = %q, want AwaitingVoucher", sb.Status.EscrowState) + } + if reason := conditionReason(sb.Status.Conditions, "EscrowReserved"); reason != "EscrowAwaitingVoucher" { + t.Fatalf("EscrowReserved reason = %q, want EscrowAwaitingVoucher", reason) + } + if sb.Status.EscrowSpender != fake.spender { + t.Fatalf("EscrowSpender = %q, want %q ferried from the receipt", sb.Status.EscrowSpender, fake.spender) + } + + // The signed voucher ferries in → re-reserve picks it up → Reserved. + annotateBounty(t, c, ns, "ferry", map[string]string{ + bountyRewardVoucherAnnotation: `{"owner":"0x1111111111111111111111111111111111111111","token":"0x036CbD53842c5426634e7929541eC2318f3dCF7e","network":"base","spender":"0xFAC0000000000000000000000000000000000FAC","nonce":"7","deadline":1893456000,"recipients":[{"address":"0x2222222222222222222222222222222222222222","amount":"500000000"}],"signature":"0xabcd"}`, + }) + reconcileBountyUntilSettled(t, c, key) + sb = getBounty(t, c, ns, "ferry") + if sb.Status.EscrowState != escrow.StateReserved { + t.Fatalf("EscrowState = %q, want Reserved after the voucher arrived", sb.Status.EscrowState) + } + if !bountyConditionIsTrue(sb.Status.Conditions, "EscrowReserved") { + t.Fatal("EscrowReserved must be true once the voucher-backed hold lands") + } + req := fake.lastReserve(t, "uid-ferry") + if req.Voucher == nil || req.Voucher.Nonce != "7" || len(req.Voucher.Recipients) != 1 { + t.Fatalf("voucher not ferried intact: %+v", req.Voucher) + } + + // Claim → submit → accept → capture: the full transition chain + // AwaitingVoucher → Reserved → Captured. + annotateBounty(t, c, ns, "ferry", map[string]string{ + bountyClaimAnnotation: "0x2222222222222222222222222222222222222222", + }) + reconcileBountyUntilSettled(t, c, key) + annotateBounty(t, c, ns, "ferry", map[string]string{ + bountySubmitAnnotation: `{"resultHash":"0xbeef","reportURI":"http://x"}`, + bountyVerdictAnnotation: "accept", + }) + reconcileBountyUntilSettled(t, c, key) + sb = getBounty(t, c, ns, "ferry") + if sb.Status.EscrowState != escrow.StateCaptured { + t.Fatalf("EscrowState = %q, want Captured", sb.Status.EscrowState) + } + if sb.Status.Phase != bountyPhasePaid { + t.Fatalf("phase = %q, want Paid", sb.Status.Phase) + } +} + +func TestBountyLifecycle_BondAndEvalVoucherFerry(t *testing.T) { + fake := newFakeEscrow() + fake.requireVoucher["uid-legs-bond"] = true + fake.requireVoucher["uid-legs-eval"] = true + sb := testEvalBounty("legs") + sb.Spec.Trust.SelfBond = monetizeapi.ServiceBountySelfBond{Required: true, Amount: "10.00", Token: "OBOL"} + c := newBountyTestController(t, sb) + c.bountyEscrow = fake + ns := "hermes-obol-agent" + key := ns + "/legs" + + claimAndSubmit(t, c, ns, "legs") + got := getBounty(t, c, ns, "legs") + if got.Status.BondState != escrowStateAwaitingVoucher { + t.Fatalf("BondState = %q, want AwaitingVoucher (parked, not an error)", got.Status.BondState) + } + if got.Status.EvalBudgetState != escrowStateAwaitingVoucher { + t.Fatalf("EvalBudgetState = %q, want AwaitingVoucher", got.Status.EvalBudgetState) + } + + annotateBounty(t, c, ns, "legs", map[string]string{ + bountyBondVoucherAnnotation: `{"owner":"0x2222222222222222222222222222222222222222","token":"0xOB","network":"base","nonce":"1","deadline":1,"signature":"0x01"}`, + bountyEvalVoucherAnnotation: `{"owner":"0x1111111111111111111111111111111111111111","token":"0xOB","network":"base","nonce":"2","deadline":1,"signature":"0x02"}`, + }) + reconcileBountyUntilSettled(t, c, key) + got = getBounty(t, c, ns, "legs") + if got.Status.BondState != escrow.StateReserved { + t.Fatalf("BondState = %q, want Reserved after bond voucher", got.Status.BondState) + } + if got.Status.EvalBudgetState != escrow.StateReserved { + t.Fatalf("EvalBudgetState = %q, want Reserved after eval voucher", got.Status.EvalBudgetState) + } + if fake.lastReserve(t, "uid-legs-bond").Voucher.Nonce != "1" { + t.Fatal("bond voucher not attached to the bond reserve") + } + if fake.lastReserve(t, "uid-legs-eval").Voucher.Nonce != "2" { + t.Fatal("eval voucher not attached to the eval-budget reserve") + } +} + +func TestBountyLifecycle_EscrowSpenderFerriedOnce(t *testing.T) { + fake := newFakeEscrow() + fake.spender = "0xFAC0000000000000000000000000000000000001" + sb := testBounty("spender") + sb.Spec.Trust.SelfBond = monetizeapi.ServiceBountySelfBond{Required: true, Amount: "10.00", Token: "OBOL"} + c := newBountyTestController(t, sb) + c.bountyEscrow = fake + ns := "hermes-obol-agent" + key := ns + "/spender" + + reconcileBountyUntilSettled(t, c, key) + got := getBounty(t, c, ns, "spender") + if got.Status.EscrowSpender != "0xFAC0000000000000000000000000000000000001" { + t.Fatalf("EscrowSpender = %q, want first receipt's spender", got.Status.EscrowSpender) + } + + // A later receipt reporting a different spender must NOT overwrite the + // first — signers bind vouchers to one executor. + fake.mu.Lock() + fake.spender = "0xFAC0000000000000000000000000000000000002" + fake.mu.Unlock() + annotateBounty(t, c, ns, "spender", map[string]string{ + bountyClaimAnnotation: "0x2222222222222222222222222222222222222222", + }) + reconcileBountyUntilSettled(t, c, key) + got = getBounty(t, c, ns, "spender") + if got.Status.EscrowSpender != "0xFAC0000000000000000000000000000000000001" { + t.Fatalf("EscrowSpender = %q, want the FIRST spender preserved", got.Status.EscrowSpender) + } +} + +func TestBountyLifecycle_CaptureVoucherRefusalParksNotFails(t *testing.T) { + fake := newFakeEscrow() + fake.captureErr["uid-refuse"] = fmt.Errorf("escrow capture uid-refuse: facilitator returned 409: AwaitingVoucher: settlement voucher missing") + c := newBountyTestController(t, testBounty("refuse")) + c.bountyEscrow = fake + ns := "hermes-obol-agent" + key := ns + "/refuse" + + reconcileBountyUntilSettled(t, c, key) + annotateBounty(t, c, ns, "refuse", map[string]string{ + bountyClaimAnnotation: "0x2222222222222222222222222222222222222222", + bountySubmitAnnotation: `{"resultHash":"0x1","reportURI":"http://x"}`, + bountyVerdictAnnotation: "accept", + }) + // reconcileBountyUntilSettled fails the test on a reconcile error — a + // voucher-refused capture must park as a condition instead. + reconcileBountyUntilSettled(t, c, key) + + got := getBounty(t, c, ns, "refuse") + if reason := conditionReason(got.Status.Conditions, "Paid"); reason != "EscrowAwaitingVoucher" { + t.Fatalf("Paid reason = %q, want EscrowAwaitingVoucher", reason) + } + if got.Status.EscrowState != escrow.StateReserved { + t.Fatalf("EscrowState = %q, want still Reserved", got.Status.EscrowState) + } + if got.Status.Phase != bountyPhaseVerified { + t.Fatalf("phase = %q, want Verified (accepted, awaiting settlement voucher)", got.Status.Phase) + } + + // Once the facilitator stops refusing (voucher arrived on its side), the + // next reconcile captures. + fake.mu.Lock() + delete(fake.captureErr, "uid-refuse") + fake.mu.Unlock() + reconcileBountyUntilSettled(t, c, key) + got = getBounty(t, c, ns, "refuse") + if got.Status.Phase != bountyPhasePaid { + t.Fatalf("phase = %q, want Paid after the refusal clears", got.Status.Phase) + } +} + +func TestBountyLifecycle_RefundVoidsEscalationBudget(t *testing.T) { + fake := newFakeEscrow() + sb := testEvalBounty("evict") + past := metav1.NewTime(time.Now().Add(time.Hour)) + sb.Spec.Deadline = &past + c := newBountyTestController(t, sb) + c.bountyEscrow = fake + stubEscalationPanel(t, r1Panel(7), nil) + ns := "hermes-obol-agent" + key := ns + "/evict" + + claimAndSubmit(t, c, ns, "evict") + commitAndReveal(t, c, ns, "evict", map[string]int64{evalA: 10, evalB: 45, evalC: 100}) + + got := getBounty(t, c, ns, "evict") + if got.Status.Escalation == nil || got.Status.Escalation.BudgetState != escrow.StateReserved { + t.Fatalf("escalation = %+v, want a funded escalation", got.Status.Escalation) + } + + // Deadline passes with the escalation still unresolved → refund returns + // every held leg, including the round-1 eval budget. + expired := metav1.NewTime(time.Now().Add(-time.Minute)) + raw, err := c.dynClient.Resource(monetizeapi.ServiceBountyGVR).Namespace(ns).Get(context.Background(), "evict", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get bounty: %v", err) + } + if err := unstructured.SetNestedField(raw.Object, expired.UTC().Format(time.RFC3339), "spec", "deadline"); err != nil { + t.Fatalf("set deadline: %v", err) + } + if _, err := c.dynClient.Resource(monetizeapi.ServiceBountyGVR).Namespace(ns).Update(context.Background(), raw, metav1.UpdateOptions{}); err != nil { + t.Fatalf("update bounty: %v", err) + } + reconcileBountyUntilSettled(t, c, key) + + got = getBounty(t, c, ns, "evict") + if got.Status.Phase != bountyPhaseRefunded { + t.Fatalf("phase = %q, want Refunded", got.Status.Phase) + } + if got.Status.Escalation.BudgetState != escrow.StateVoided { + t.Fatalf("escalation budget = %q, want Voided on refund", got.Status.Escalation.BudgetState) + } + fake.mu.Lock() + state := fake.states["uid-evict-eval-r1"] + fake.mu.Unlock() + if state != escrow.StateVoided { + t.Fatalf("facilitator state for eval-r1 = %q, want Voided", state) + } +} diff --git a/internal/serviceoffercontroller/bounty_panel.go b/internal/serviceoffercontroller/bounty_panel.go index 4cea6490..6feda959 100644 --- a/internal/serviceoffercontroller/bounty_panel.go +++ b/internal/serviceoffercontroller/bounty_panel.go @@ -4,9 +4,11 @@ package serviceoffercontroller // // Selection is controller-side weighted sampling — the honest local-first // stand-in for VRF (the swap seam is exactly this function). It is -// DETERMINISTIC per bounty: seeded from the bounty UID so every reconcile -// computes the same panel (idempotence), and the poster cannot re-roll -// evaluators by touching the spec. +// DETERMINISTIC per bounty: seeded from the controller's seedSource (local: +// sha256(UID); drand: a beacon that does not exist yet at posting time) so +// every reconcile computes the same panel (idempotence), and the poster +// cannot re-roll evaluators by touching the spec. The seed's provenance is +// persisted into status.panelSeed so the draw is auditable. // // Seats: k counting seats (Full tier, plus at most ONE Probation seat on // value-capped bounties — the median absorbs one outlier, which is what makes @@ -16,17 +18,26 @@ package serviceoffercontroller // address may evaluate), and ladder bookkeeping still applies to enrolled // participants — open-door participation is how the first evaluators climb // out of Shadow. +// +// Reputation is read through the decay lens (internal/bounty/decay.go): the +// lottery weight uses the half-life-decayed completion count, a stored Full +// tier reads as Probation once stale, and chain-grounded verdicts earn a +// weight bonus. Stored counters are never mutated by decay. import ( "context" "crypto/sha256" "encoding/binary" "fmt" + "log" "math/rand" "slices" "sort" "strconv" "strings" + "time" + + "github.com/ethereum/go-ethereum/common" "github.com/ObolNetwork/obol-stack/internal/bounty" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" @@ -41,6 +52,10 @@ const ( // pairDiversityWeight down-weights an evaluator who recently judged the // same fulfiller (anti-collusion: break up cozy evaluator↔fulfiller pairs). pairDiversityWeight = 0.25 + + // escalationSeedSuffix derives the escalation-round seed from the round-0 + // seed: sha256(round0seed || suffix). Same beacon, distinct lottery. + escalationSeedSuffix = "escalation-r1" ) // evaluatorCandidate is one enrolled evaluator considered for selection. @@ -49,6 +64,16 @@ type evaluatorCandidate struct { Record monetizeapi.EvaluatorLadderRecord } +// panelSeedSource returns the controller's seed source, defaulting to the +// local deterministic seed when none was wired (tests construct Controller +// literals). +func (c *Controller) panelSeedSource() seedSource { + if c.seeds == nil { + return localSeedSource{} + } + return c.seeds +} + // listEnrollmentsForTask returns the enrolled evaluators for a task type in // the bounty's namespace. func (c *Controller) listEnrollmentsForTask(ctx context.Context, namespace, taskRef string) ([]monetizeapi.EvaluatorEnrollment, error) { @@ -81,17 +106,64 @@ func ladderRecordFor(enrollment *monetizeapi.EvaluatorEnrollment, taskRef string return monetizeapi.EvaluatorLadderRecord{TaskType: taskRef, Tier: monetizeapi.EvaluatorTierShadow} } +// ladderForTask resolves the task package's ladder for taskRef; zero Ladder +// (with parse-time defaults applied by callees) when the type is unknown. +func ladderForTask(taskRef string) bounty.Ladder { + if t, err := bounty.Resolve(taskRef); err == nil { + return t.Eval.Ladder + } + return bounty.Ladder{} +} + +// ladderWeight is THE lottery weight: 1 + 0.1×(effectiveCompleted − +// divergences) floored at 0.1, where effectiveCompleted is the half-life- +// decayed completion count; ×0.25 pair-diversity penalty for a recently +// judged fulfiller; ×(1 + min(1, grounded/completed)) bonus for verdicts +// grounded by on-chain ERC-8004 validation entries. +func ladderWeight(record monetizeapi.EvaluatorLadderRecord, fulfiller string, halfLife time.Duration, now time.Time) float64 { + var lastEval *time.Time + if record.LastEvalAt != nil { + lastEval = &record.LastEvalAt.Time + } + effective := bounty.EffectiveCompleted(int(record.Completed), lastEval, now, halfLife) + w := 1.0 + 0.1*(effective-float64(record.Divergences)) + if w < 0.1 { + w = 0.1 + } + if fulfiller != "" && slices.Contains(record.RecentFulfillers, fulfiller) { + w *= pairDiversityWeight + } + denom := record.Completed + if denom < 1 { + denom = 1 + } + bonus := float64(record.GroundedEvals) / float64(denom) + if bonus > 1 { + bonus = 1 + } + return w * (1 + bonus) +} + +// rngFromSeed turns the 32-byte panel seed into the deterministic lottery RNG. +func rngFromSeed(seed [32]byte) *rand.Rand { + return rand.New(rand.NewSource(int64(binary.BigEndian.Uint64(seed[:8])))) //nolint:gosec // deterministic-by-design selection, not crypto +} + // selectEvaluatorPanel performs the deterministic weighted sampling. Returns -// nil when the counting pool (Full+Probation) cannot fill k seats — the -// open-door fallback. -func selectEvaluatorPanel(uid string, pool []monetizeapi.EvaluatorEnrollment, taskRef string, k int64, rewardAmount, probationValueCap, fulfiller string) []monetizeapi.ServiceBountyPanelSeat { +// nil when the counting pool (Full+Probation, read through the decay lens) +// cannot fill k seats — the open-door fallback. +func selectEvaluatorPanel(seed [32]byte, pool []monetizeapi.EvaluatorEnrollment, taskRef string, k int64, rewardAmount string, ladder bounty.Ladder, fulfiller string, now time.Time) []monetizeapi.ServiceBountyPanelSeat { + halfLife := ladder.DecayHalfLifeDuration() + var full, probation, shadow []evaluatorCandidate for i := range pool { candidate := evaluatorCandidate{ Address: pool[i].Spec.Address, Record: ladderRecordFor(&pool[i], taskRef), } - switch candidate.Record.Tier { + // Tier gating goes through the decay lens: a stale Full reads as + // Probation here without mutating the stored record. + switch bounty.EffectiveTier(candidate.Record, ladder, now) { case monetizeapi.EvaluatorTierFull: full = append(full, candidate) case monetizeapi.EvaluatorTierProbation: @@ -106,19 +178,9 @@ func selectEvaluatorPanel(uid string, pool []monetizeapi.EvaluatorEnrollment, ta return nil // open-door fallback } - // Deterministic seed: same bounty → same panel, every reconcile. - sum := sha256.Sum256([]byte(uid)) - rng := rand.New(rand.NewSource(int64(binary.BigEndian.Uint64(sum[:8])))) //nolint:gosec // deterministic-by-design selection, not crypto - + rng := rngFromSeed(seed) weight := func(candidate evaluatorCandidate) float64 { - w := 1.0 + 0.1*float64(candidate.Record.Completed-candidate.Record.Divergences) - if w < 0.1 { - w = 0.1 - } - if slices.Contains(candidate.Record.RecentFulfillers, fulfiller) { - w *= pairDiversityWeight - } - return w + return ladderWeight(candidate.Record, fulfiller, halfLife, now) } var seats []monetizeapi.ServiceBountyPanelSeat @@ -127,7 +189,7 @@ func selectEvaluatorPanel(uid string, pool []monetizeapi.EvaluatorEnrollment, ta // absorbs one outlier, so the newcomer seat is verdict-safe by // construction — and only offered where the value cap allows. remaining := k - if len(probation) > 0 && withinValueCap(rewardAmount, probationValueCap) && k >= 3 { + if len(probation) > 0 && withinValueCap(rewardAmount, ladder.ProbationValueCap) && k >= 3 { pick := weightedPick(rng, probation, weight) seats = append(seats, monetizeapi.ServiceBountyPanelSeat{Address: pick.Address, Seat: monetizeapi.PanelSeatProbation}) probation = removeCandidate(probation, pick.Address) @@ -192,7 +254,9 @@ func withinValueCap(amount, cap string) bool { // ensurePanel runs selection exactly once per bounty (latched by the // PanelSelected condition so a growing pool can never re-gate a bounty whose -// evaluation already started). +// evaluation already started). A seed-source failure (drand relay down or a +// beacon failing verification) does NOT latch: the panel stays unselected and +// the bounty is requeued — never a silent fallback to the local seed. func (c *Controller) ensurePanel(ctx context.Context, sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceBountyStatus) { for _, condition := range status.Conditions { if condition.Type == "PanelSelected" { @@ -200,6 +264,16 @@ func (c *Controller) ensurePanel(ctx context.Context, sb *monetizeapi.ServiceBou } } + seed, provenance, err := c.panelSeedSource().Seed(ctx, string(sb.UID), sb.CreationTimestamp.Time) + if err != nil { + log.Printf("bounty %s/%s: panel seed unavailable, retrying in %s: %v", sb.Namespace, sb.Name, seedRetryDelay, err) + if c.bountyQueue != nil { + c.bountyQueue.AddAfter(sb.Namespace+"/"+sb.Name, seedRetryDelay) + } + return + } + status.PanelSeed = &provenance + taskRef := sb.Spec.Task.TypeRef pool, err := c.listEnrollmentsForTask(ctx, sb.Namespace, taskRef) if err != nil { @@ -213,16 +287,12 @@ func (c *Controller) ensurePanel(ctx context.Context, sb *monetizeapi.ServiceBou if k < 1 { k = 1 } - cap := "" - if t, err := bounty.Resolve(taskRef); err == nil { - cap = t.Eval.Ladder.ProbationValueCap - } fulfiller := "" if len(status.Claims) > 0 { fulfiller = status.Claims[0].FulfillerAddress } - seats := selectEvaluatorPanel(string(sb.UID), pool, taskRef, k, sb.Spec.Reward.Amount, cap, fulfiller) + seats := selectEvaluatorPanel(seed, pool, taskRef, k, sb.Spec.Reward.Amount, ladderForTask(taskRef), fulfiller, time.Now()) if seats == nil { setPurchaseCondition(&status.Conditions, "PanelSelected", "False", "OpenDoor", fmt.Sprintf("Enrolled pool has fewer than %d counting evaluators — open-door evaluation", k)) @@ -233,9 +303,76 @@ func (c *Controller) ensurePanel(ctx context.Context, sb *monetizeapi.ServiceBou fmt.Sprintf("%d counting seat(s) + %d shadow(s) selected from %d enrolled", k, len(seats)-int(k), len(pool))) } +// selectEscalationPanel draws the second-round panel for an escalated verdict: +// a FRESH, larger panel where every seat counts at full pay (no probation +// discount, no shadows — escalation is the tiebreaker, not the on-ramp), and +// every round-0 participant is excluded (keys of exclude are canonical EIP-55 +// addresses). The seed derives deterministically from the same round-0 seed +// ensurePanel used — sha256(round0seed || "escalation-r1") — recomputed via +// the seedSource (the provenance in status guarantees the same beacon), so +// repeated reconciles draw the same escalation panel. A pool smaller than +// size falls back to open-door (nil seats), same semantics as round 0. +func (c *Controller) selectEscalationPanel(ctx context.Context, sb *unstructured.Unstructured, size int, exclude map[string]bool) ([]monetizeapi.ServiceBountyPanelSeat, error) { + taskRef, _, _ := unstructured.NestedString(sb.Object, "spec", "task", "typeRef") + pool, err := c.listEnrollmentsForTask(ctx, sb.GetNamespace(), taskRef) + if err != nil { + return nil, err + } + + round0Seed, _, err := c.panelSeedSource().Seed(ctx, string(sb.GetUID()), sb.GetCreationTimestamp().Time) + if err != nil { + return nil, err + } + seed := sha256.Sum256(append(round0Seed[:], []byte(escalationSeedSuffix)...)) + + ladder := ladderForTask(taskRef) + halfLife := ladder.DecayHalfLifeDuration() + now := time.Now() + + fulfiller := "" + if claims, _, _ := unstructured.NestedSlice(sb.Object, "status", "claims"); len(claims) > 0 { + if claim, ok := claims[0].(map[string]any); ok { + fulfiller, _ = claim["fulfillerAddress"].(string) + } + } + + var counting []evaluatorCandidate + for i := range pool { + if exclude[common.HexToAddress(pool[i].Spec.Address).Hex()] { + continue // round-0 participants never re-judge their own divergence + } + candidate := evaluatorCandidate{ + Address: pool[i].Spec.Address, + Record: ladderRecordFor(&pool[i], taskRef), + } + switch bounty.EffectiveTier(candidate.Record, ladder, now) { + case monetizeapi.EvaluatorTierFull, monetizeapi.EvaluatorTierProbation: + counting = append(counting, candidate) + } + } + if len(counting) < size { + return nil, nil // open-door fallback, same as round 0's thin pool + } + + rng := rngFromSeed(seed) + weight := func(candidate evaluatorCandidate) float64 { + return ladderWeight(candidate.Record, fulfiller, halfLife, now) + } + + var seats []monetizeapi.ServiceBountyPanelSeat + for len(seats) < size && len(counting) > 0 { + pick := weightedPick(rng, counting, weight) + seats = append(seats, monetizeapi.ServiceBountyPanelSeat{Address: pick.Address, Seat: monetizeapi.PanelSeatFull}) + counting = removeCandidate(counting, pick.Address) + } + sort.Slice(seats, func(i, j int) bool { return seats[i].Address < seats[j].Address }) + return seats, nil +} + // recordLadder applies the one-shot cross-bounty bookkeeping after the quorum // settles: completion/divergence counters, shadow agreements, probation -// progress, tier promotions, and the pair-diversity history. +// progress, tier promotions, the decay anchor (lastEvalAt), grounded-verdict +// counts, and the pair-diversity history. func (c *Controller) recordLadder(ctx context.Context, sb *monetizeapi.ServiceBounty, status *monetizeapi.ServiceBountyStatus) error { taskRef := sb.Spec.Task.TypeRef thresholds := bounty.Ladder{ShadowAgreements: 5, ProbationEvals: 10} @@ -246,6 +383,7 @@ func (c *Controller) recordLadder(ctx context.Context, sb *monetizeapi.ServiceBo if len(status.Claims) > 0 { fulfiller = status.Claims[0].FulfillerAddress } + now := metav1.Now() for _, evaluation := range status.Evaluations { raw, err := c.findEnrollmentByAddress(ctx, sb.Namespace, evaluation.Address) @@ -259,6 +397,10 @@ func (c *Controller) recordLadder(ctx context.Context, sb *monetizeapi.ServiceBo record := ladderRecordFor(&enrollment, taskRef) record.Completed++ + record.LastEvalAt = now.DeepCopy() // the decay anchor: every counted participation re-stamps it + if evaluation.Grounded { + record.GroundedEvals++ + } if !evaluation.WithinBand { record.Divergences++ } diff --git a/internal/serviceoffercontroller/bounty_panel_test.go b/internal/serviceoffercontroller/bounty_panel_test.go index 6abea0dd..9cbe04b2 100644 --- a/internal/serviceoffercontroller/bounty_panel_test.go +++ b/internal/serviceoffercontroller/bounty_panel_test.go @@ -2,11 +2,15 @@ package serviceoffercontroller import ( "context" + "crypto/sha256" "fmt" + "math" "reflect" "strings" "testing" + "time" + "github.com/ObolNetwork/obol-stack/internal/bounty" "github.com/ObolNetwork/obol-stack/internal/monetizeapi" "github.com/ObolNetwork/obol-stack/internal/x402/escrow" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -17,6 +21,17 @@ import ( "k8s.io/client-go/util/workqueue" ) +// testPanelLadder mirrors the benchmark@v1 ladder shape used across panel +// tests: probation cap 50.00, default decay knobs. +var testPanelLadder = bounty.Ladder{ + ShadowAgreements: 5, + ProbationEvals: 10, + ProbationValueCap: "50.00", + DecayHalfLife: "720h", +} + +func seedOf(uid string) [32]byte { return sha256.Sum256([]byte(uid)) } + func testEnrollment(t *testing.T, name, address, tier string) *unstructured.Unstructured { t.Helper() enrollment := monetizeapi.EvaluatorEnrollment{ @@ -73,8 +88,9 @@ func TestSelectEvaluatorPanel_DeterministicPerBounty(t *testing.T) { }) } - a := selectEvaluatorPanel("uid-1", pool, "benchmark@v1", 3, "5.00", "50.00", "0xf") - b := selectEvaluatorPanel("uid-1", pool, "benchmark@v1", 3, "5.00", "50.00", "0xf") + now := time.Now() + a := selectEvaluatorPanel(seedOf("uid-1"), pool, "benchmark@v1", 3, "5.00", testPanelLadder, "0xf", now) + b := selectEvaluatorPanel(seedOf("uid-1"), pool, "benchmark@v1", 3, "5.00", testPanelLadder, "0xf", now) if !reflect.DeepEqual(a, b) { t.Fatalf("selection must be deterministic per bounty UID:\n%v\n%v", a, b) } @@ -92,7 +108,7 @@ func TestSelectEvaluatorPanel_OpenDoorWhenPoolThin(t *testing.T) { // Shadows are not counting candidates. {Spec: monetizeapi.EvaluatorEnrollmentSpec{Address: "0x" + strings.Repeat("2", 40), TaskTypes: []string{"benchmark@v1"}}}, } - if seats := selectEvaluatorPanel("uid", pool, "benchmark@v1", 3, "5.00", "50.00", ""); seats != nil { + if seats := selectEvaluatorPanel(seedOf("uid"), pool, "benchmark@v1", 3, "5.00", testPanelLadder, "", time.Now()); seats != nil { t.Fatalf("thin pool must fall back to open-door, got %v", seats) } } @@ -124,11 +140,11 @@ func TestSelectEvaluatorPanel_ProbationSeatValueCapped(t *testing.T) { return n } - under := selectEvaluatorPanel("uid", pool, "benchmark@v1", 3, "5.00", "50.00", "") + under := selectEvaluatorPanel(seedOf("uid"), pool, "benchmark@v1", 3, "5.00", testPanelLadder, "", time.Now()) if countProbation(under) != 1 { t.Errorf("reward under the cap must seat exactly one probationer, got %d (%v)", countProbation(under), under) } - over := selectEvaluatorPanel("uid", pool, "benchmark@v1", 3, "500.00", "50.00", "") + over := selectEvaluatorPanel(seedOf("uid"), pool, "benchmark@v1", 3, "500.00", testPanelLadder, "", time.Now()) if countProbation(over) != 0 { t.Errorf("reward above the cap must seat no probationer, got %d (%v)", countProbation(over), over) } @@ -315,3 +331,232 @@ func ladderStatusOf(t *testing.T, c *Controller, namespace, name string) monetiz } return enrollment.Status.Records[0] } + +// ── decay-aware weighting ─────────────────────────────────────────────────── + +func TestLadderWeight_DecayAfterHalfLifeIdle(t *testing.T) { + now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + halfLife := 720 * time.Hour + fresh := monetizeapi.EvaluatorLadderRecord{ + Completed: 10, + LastEvalAt: &metav1.Time{Time: now}, + } + if got := ladderWeight(fresh, "", halfLife, now); math.Abs(got-2.0) > 1e-9 { + t.Fatalf("fresh weight = %v, want 2.0 (1 + 0.1×10)", got) + } + stale := monetizeapi.EvaluatorLadderRecord{ + Completed: 10, + LastEvalAt: &metav1.Time{Time: now.Add(-halfLife)}, + } + if got := ladderWeight(stale, "", halfLife, now); math.Abs(got-1.5) > 1e-9 { + t.Fatalf("one-half-life-idle weight = %v, want 1.5 (effective completed halves to 5)", got) + } + legacy := monetizeapi.EvaluatorLadderRecord{Completed: 10} // nil LastEvalAt → no decay + if got := ladderWeight(legacy, "", halfLife, now); math.Abs(got-2.0) > 1e-9 { + t.Fatalf("legacy record weight = %v, want undecayed 2.0", got) + } +} + +func TestLadderWeight_GroundedBonus(t *testing.T) { + now := time.Now() + halfLife := 720 * time.Hour + allGrounded := monetizeapi.EvaluatorLadderRecord{ + Completed: 4, + GroundedEvals: 4, + LastEvalAt: &metav1.Time{Time: now}, + } + if got := ladderWeight(allGrounded, "", halfLife, now); math.Abs(got-2.8) > 1e-9 { + t.Fatalf("fully grounded weight = %v, want 2.8 (1.4 × 2)", got) + } + halfGrounded := monetizeapi.EvaluatorLadderRecord{ + Completed: 4, + GroundedEvals: 2, + LastEvalAt: &metav1.Time{Time: now}, + } + if got := ladderWeight(halfGrounded, "", halfLife, now); math.Abs(got-2.1) > 1e-9 { + t.Fatalf("half grounded weight = %v, want 2.1 (1.4 × 1.5)", got) + } + // The bonus is capped at ×2 even if counters drift (grounded > completed). + overGrounded := monetizeapi.EvaluatorLadderRecord{ + Completed: 1, + GroundedEvals: 5, + LastEvalAt: &metav1.Time{Time: now}, + } + if got := ladderWeight(overGrounded, "", halfLife, now); math.Abs(got-2.2) > 1e-9 { + t.Fatalf("over-grounded weight = %v, want capped 2.2 (1.1 × 2)", got) + } +} + +// A stored Full whose reputation decayed below the probation threshold reads +// as Probation at selection time: it takes the reserved probation seat, never +// a full one. +func TestSelectEvaluatorPanel_StaleFullReadsAsProbation(t *testing.T) { + now := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC) + staleAddr := "0x" + strings.Repeat("a", 40) + pool := []monetizeapi.EvaluatorEnrollment{} + for i := 0; i < 3; i++ { + pool = append(pool, monetizeapi.EvaluatorEnrollment{ + Spec: monetizeapi.EvaluatorEnrollmentSpec{Address: fmt.Sprintf("0x%040d", i), TaskTypes: []string{"benchmark@v1"}}, + Status: monetizeapi.EvaluatorEnrollmentStatus{Records: []monetizeapi.EvaluatorLadderRecord{ + {TaskType: "benchmark@v1", Tier: monetizeapi.EvaluatorTierFull, LastEvalAt: &metav1.Time{Time: now}}, + }}, + }) + } + pool = append(pool, monetizeapi.EvaluatorEnrollment{ + Spec: monetizeapi.EvaluatorEnrollmentSpec{Address: staleAddr, TaskTypes: []string{"benchmark@v1"}}, + Status: monetizeapi.EvaluatorEnrollmentStatus{Records: []monetizeapi.EvaluatorLadderRecord{ + { + TaskType: "benchmark@v1", + Tier: monetizeapi.EvaluatorTierFull, + Completed: 10, // effective ≈ 0.01 after 10 half-lives — under ProbationEvals 10 + LastEvalAt: &metav1.Time{Time: now.Add(-10 * 720 * time.Hour)}, + }, + }}, + }) + + seats := selectEvaluatorPanel(seedOf("uid"), pool, "benchmark@v1", 3, "5.00", testPanelLadder, "", now) + if seats == nil { + t.Fatal("3 fresh Full + 1 demoted probationer must still fill k=3") + } + for _, seat := range seats { + if strings.EqualFold(seat.Address, staleAddr) && seat.Seat != monetizeapi.PanelSeatProbation { + t.Fatalf("stale Full must hold the probation seat, got %s", seat.Seat) + } + if !strings.EqualFold(seat.Address, staleAddr) && seat.Seat == monetizeapi.PanelSeatProbation { + t.Fatalf("fresh Full %s must not hold the probation seat", seat.Address) + } + } +} + +// ── escalation panel ──────────────────────────────────────────────────────── + +func testEscalationBounty(name string) *monetizeapi.ServiceBounty { + return &monetizeapi.ServiceBounty{ + TypeMeta: metav1.TypeMeta{ + APIVersion: monetizeapi.Group + "/" + monetizeapi.Version, + Kind: monetizeapi.ServiceBountyKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "hermes-obol-agent", + UID: "esc-uid-1", + CreationTimestamp: metav1.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }, + Spec: monetizeapi.ServiceBountySpec{ + Task: monetizeapi.ServiceBountyTask{TypeRef: "benchmark@v1"}, + Reward: monetizeapi.ServiceBountyReward{Amount: "5.00"}, + Eval: monetizeapi.ServiceBountyEval{K: 3}, + }, + } +} + +func TestSelectEscalationPanel_DeterministicAndExcludesRound0(t *testing.T) { + sb := testEscalationBounty("esc") + var enrollments []*unstructured.Unstructured + var addrs []string + for i := 0; i < 8; i++ { + addr := fmt.Sprintf("0x%040d", i) + addrs = append(addrs, addr) + enrollments = append(enrollments, testEnrollment(t, fmt.Sprintf("ev-%d", i), addr, monetizeapi.EvaluatorTierFull)) + } + c := newPanelTestController(t, sb, enrollments...) + sbObj := mustBountyObject(t, sb) + + // Round-0 panel members are excluded by canonical EIP-55 address. + exclude := map[string]bool{ + canonicalAddress(addrs[0]): true, + canonicalAddress(addrs[1]): true, + } + + first, err := c.selectEscalationPanel(context.Background(), sbObj, 5, exclude) + if err != nil { + t.Fatalf("selectEscalationPanel: %v", err) + } + second, err := c.selectEscalationPanel(context.Background(), sbObj, 5, exclude) + if err != nil { + t.Fatalf("selectEscalationPanel (second draw): %v", err) + } + if !reflect.DeepEqual(first, second) { + t.Fatalf("escalation panel must be deterministic:\n%v\n%v", first, second) + } + if len(first) != 5 { + t.Fatalf("got %d escalation seats, want 5", len(first)) + } + for _, seat := range first { + if seat.Seat != monetizeapi.PanelSeatFull { + t.Errorf("escalation seat %s = %q, want all counting/full-pay", seat.Address, seat.Seat) + } + if exclude[canonicalAddress(seat.Address)] { + t.Errorf("round-0 evaluator %s must be excluded from the escalation panel", seat.Address) + } + } +} + +func TestSelectEscalationPanel_OpenDoorWhenPoolThin(t *testing.T) { + sb := testEscalationBounty("esc-thin") + enrollments := []*unstructured.Unstructured{ + testEnrollment(t, "ev-0", "0x"+strings.Repeat("1", 40), monetizeapi.EvaluatorTierFull), + testEnrollment(t, "ev-1", "0x"+strings.Repeat("2", 40), monetizeapi.EvaluatorTierFull), + } + c := newPanelTestController(t, sb, enrollments...) + sbObj := mustBountyObject(t, sb) + exclude := map[string]bool{canonicalAddress("0x" + strings.Repeat("1", 40)): true} + + seats, err := c.selectEscalationPanel(context.Background(), sbObj, 5, exclude) + if err != nil { + t.Fatalf("selectEscalationPanel: %v", err) + } + if seats != nil { + t.Fatalf("thin escalation pool must fall back to open-door (nil seats), got %v", seats) + } +} + +// ── seed provenance in ensurePanel ────────────────────────────────────────── + +func TestEnsurePanel_PersistsLocalSeedProvenance(t *testing.T) { + sb := testEscalationBounty("seeded") + c := newPanelTestController(t, sb, + testEnrollment(t, "ev-a", evalA, monetizeapi.EvaluatorTierFull), + testEnrollment(t, "ev-b", evalB, monetizeapi.EvaluatorTierFull), + testEnrollment(t, "ev-c", evalC, monetizeapi.EvaluatorTierFull), + ) + status := &monetizeapi.ServiceBountyStatus{} + c.ensurePanel(context.Background(), sb, status) + + if status.PanelSeed == nil || status.PanelSeed.Source != "local" { + t.Fatalf("status.panelSeed = %+v, want Source=local", status.PanelSeed) + } + if status.PanelSeed.Round != 0 || status.PanelSeed.Randomness != "" || status.PanelSeed.Signature != "" { + t.Fatalf("local provenance must carry no beacon fields, got %+v", status.PanelSeed) + } + if len(status.EvaluatorPanel) == 0 { + t.Fatal("panel must be selected from the enrolled Full pool") + } +} + +func TestEnsurePanel_SeedErrorDoesNotLatch(t *testing.T) { + sb := testEscalationBounty("seed-err") + c := newPanelTestController(t, sb, + testEnrollment(t, "ev-a", evalA, monetizeapi.EvaluatorTierFull), + ) + failing := &failingSeedSource{} + c.seeds = failing + + status := &monetizeapi.ServiceBountyStatus{} + c.ensurePanel(context.Background(), sb, status) + + if status.PanelSeed != nil || status.EvaluatorPanel != nil { + t.Fatalf("seed failure must leave the panel unselected, got seed=%+v panel=%v", status.PanelSeed, status.EvaluatorPanel) + } + for _, condition := range status.Conditions { + if condition.Type == "PanelSelected" { + t.Fatal("seed failure must NOT latch PanelSelected — the next reconcile retries the beacon") + } + } + + // Not latched: the next reconcile consults the seed source again. + c.ensurePanel(context.Background(), sb, status) + if failing.calls != 2 { + t.Fatalf("seed source consulted %d times, want 2 (retry, no latch)", failing.calls) + } +} diff --git a/internal/serviceoffercontroller/bounty_structure_test.go b/internal/serviceoffercontroller/bounty_structure_test.go index 19427217..6f4b329c 100644 --- a/internal/serviceoffercontroller/bounty_structure_test.go +++ b/internal/serviceoffercontroller/bounty_structure_test.go @@ -10,15 +10,27 @@ import ( // that a ServiceBounty must never become public ingress and the bounty pass // must never broker credentials: the reconcile source must not touch // HTTPRoute, Middleware, ReferenceGrant, or Secret resources. (The structural -// source-check style follows internal/x402/setup_structure_test.go.) +// source-check style follows internal/x402/setup_structure_test.go.) The scan +// covers every file the bounty reconcile spans — escrow, eval market, panel +// selection, escalation, grounding, and seed sourcing all carry the same +// invariant. func TestBountyReconcile_NeverCreatesIngressOrSecrets(t *testing.T) { - src, err := os.ReadFile("bounty.go") - if err != nil { - t.Fatalf("read bounty.go: %v", err) + files := []string{ + "bounty.go", + "bounty_eval.go", + "bounty_panel.go", + "bounty_escalation.go", + "bounty_grounding.go", + "seed.go", } - forbidden := regexp.MustCompile(`HTTPRouteGVR|MiddlewareGVR|ReferenceGrantGVR|SecretGVR|c\.httpRoutes|c\.middlewares|c\.referenceGrants`) - if match := forbidden.Find(src); match != nil { - t.Fatalf("bounty.go references %q — the bounty reconcile must never create routes, middlewares, reference grants, or secrets (a bounty must never become ingress)", match) + for _, file := range files { + src, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if match := forbidden.Find(src); match != nil { + t.Fatalf("%s references %q — the bounty reconcile must never create routes, middlewares, reference grants, or secrets (a bounty must never become ingress)", file, match) + } } } diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 732f7fda..41009cfa 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -83,6 +83,11 @@ type Controller struct { // newBountyEscrowGateway for why. bountyEscrow escrow.Gateway + // seeds produces the evaluator panel-lottery seed (local sha256(UID) or + // drand quicknet, selected by OBOL_BOUNTY_SEED at construction — never + // from a bounty's spec). Nil falls back to the local source (tests). + seeds seedSource + pendingAuths sync.Map // key: "ns/name" → []map[string]string httpClient *http.Client @@ -169,6 +174,7 @@ func New(cfg *rest.Config) (*Controller, error) { agentQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), bountyQueue: workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedControllerRateLimiter[string]()), bountyEscrow: newBountyEscrowGateway(), + seeds: newSeedSource(), httpClient: &http.Client{Timeout: 3 * time.Second}, registrationRPCBase: getenvDefault("ERC8004_RPC_BASE", erc8004.DefaultRPCBase), baseURLOverride: strings.TrimRight(os.Getenv("AGENT_BASE_URL"), "/"), diff --git a/internal/serviceoffercontroller/seed.go b/internal/serviceoffercontroller/seed.go new file mode 100644 index 00000000..dadd547f --- /dev/null +++ b/internal/serviceoffercontroller/seed.go @@ -0,0 +1,234 @@ +package serviceoffercontroller + +// Panel-draw randomness sources (design doc §11.4). The evaluator panel is a +// weighted lottery; whoever controls the lottery seed controls the panel, so +// the seed's provenance is recorded in status.panelSeed for auditability. +// +// - local: sha256(bounty UID) — deterministic, free, fine for local-first +// single-operator stacks (exactly the historical behavior). +// - drand: the quicknet beacon FIRST round strictly after the bounty's +// creation +30s, fetched over public HTTP relays and BLS-verified against +// the quicknet group key. The poster cannot know the randomness when the +// bounty is created, and the operator cannot grind it: a fetch or verify +// failure returns an error and the panel stays unselected (requeue) — it +// NEVER silently falls back to the local seed, because "break the relay, +// get the predictable seed" would hand the operator a grinding lever. +// +// Mode is selected once at controller construction from OBOL_BOUNTY_SEED +// ("drand" → drand, anything else → local); relays are overridable via +// OBOL_BOUNTY_DRAND_URLS (comma-separated). + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + bls12381 "github.com/drand/kyber-bls12381" + "github.com/drand/kyber/sign/bdn" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" +) + +// seedSource produces the 32-byte panel-lottery seed for a bounty plus the +// provenance record persisted into status.panelSeed. +type seedSource interface { + Seed(ctx context.Context, uid string, created time.Time) ([32]byte, monetizeapi.ServiceBountyPanelSeed, error) +} + +const ( + seedModeEnv = "OBOL_BOUNTY_SEED" + drandRelaysEnv = "OBOL_BOUNTY_DRAND_URLS" + seedSourceLocal = "local" + seedSourceDrand = "drand" + // seedRetryDelay is how long ensurePanel waits before re-trying a bounty + // whose beacon fetch/verify failed. + seedRetryDelay = 15 * time.Second +) + +// newSeedSource picks the seed source from the environment. Called once at +// controller construction. +func newSeedSource() seedSource { + if os.Getenv(seedModeEnv) == seedSourceDrand { + return newDrandSeedSource(nil) + } + return localSeedSource{} +} + +// localSeedSource is the historical deterministic seed: sha256(bounty UID). +type localSeedSource struct{} + +func (localSeedSource) Seed(_ context.Context, uid string, _ time.Time) ([32]byte, monetizeapi.ServiceBountyPanelSeed, error) { + return sha256.Sum256([]byte(uid)), monetizeapi.ServiceBountyPanelSeed{Source: seedSourceLocal}, nil +} + +// ── drand quicknet ────────────────────────────────────────────────────────── +// +// Chain parameters verified live against https://api.drand.sh/v2/beacons/quicknet/info +// (2026-06-10): scheme bls-unchained-g1-rfc9380 — signatures on G1, group +// public key on G2, signed message = sha256(8-byte big-endian round number) +// (drand/drand crypto/schemes.go, "unchained means we're only hashing the +// round number"). randomness = sha256(signature). +// +// Relay paths: api.drand.sh serves both /v2/beacons/quicknet/rounds/ and +// the chain-hash path //public/; drand.cloudflare.com serves +// ONLY the chain-hash path (v2 404s, verified live). The chain-hash path is +// therefore what we fetch — it works on every default relay and pins the +// chain hash into the URL itself. +const ( + quicknetChainHash = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" + quicknetGenesisUnix = int64(1692803367) + quicknetPeriodSec = int64(3) + quicknetPublicKeyHex = "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a" + + // drandSeedLag: the panel draws from the first beacon strictly after + // created+lag, so the randomness provably does not exist yet when the + // bounty is posted. + drandSeedLag = 30 * time.Second +) + +var defaultDrandRelays = []string{"https://api.drand.sh", "https://drand.cloudflare.com"} + +type drandSeedSource struct { + relays []string + client *http.Client +} + +// newDrandSeedSource builds the quicknet-backed source. relays == nil reads +// OBOL_BOUNTY_DRAND_URLS, then falls back to the public defaults. +func newDrandSeedSource(relays []string) *drandSeedSource { + if len(relays) == 0 { + if env := os.Getenv(drandRelaysEnv); env != "" { + for _, u := range strings.Split(env, ",") { + if u = strings.TrimSpace(u); u != "" { + relays = append(relays, strings.TrimRight(u, "/")) + } + } + } + } + if len(relays) == 0 { + relays = defaultDrandRelays + } + return &drandSeedSource{ + relays: relays, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// drandBeacon is the relay response on the chain-hash path +// (//public/). Randomness is present on this path, but we +// recompute and cross-check it from the signature anyway. +type drandBeacon struct { + Round uint64 `json:"round"` + Randomness string `json:"randomness"` + Signature string `json:"signature"` +} + +// drandRoundAfter returns the first quicknet round emitted STRICTLY after t. +// Round r is emitted at genesis + (r-1)×period. +func drandRoundAfter(t time.Time) uint64 { + d := t.Unix() - quicknetGenesisUnix + if d < 0 { + return 1 + } + return uint64(d/quicknetPeriodSec) + 2 +} + +func (s *drandSeedSource) Seed(ctx context.Context, uid string, created time.Time) ([32]byte, monetizeapi.ServiceBountyPanelSeed, error) { + round := drandRoundAfter(created.Add(drandSeedLag)) + + var errs []error + for _, relay := range s.relays { + beacon, err := s.fetch(ctx, relay, round) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", relay, err)) + continue + } + randomness, err := verifyQuicknetBeacon(beacon, round) + if err != nil { + // A relay serving a beacon that fails BLS verification is lying + // or corrupted — surface it, never trust it. + errs = append(errs, fmt.Errorf("%s: %w", relay, err)) + continue + } + seed := sha256.Sum256(append([]byte(uid), randomness...)) + return seed, monetizeapi.ServiceBountyPanelSeed{ + Source: seedSourceDrand, + Round: round, + Randomness: hex.EncodeToString(randomness), + Signature: beacon.Signature, + }, nil + } + // No silent fallback to the local seed — a broken relay must never become + // a seed-grinding lever. The caller leaves the panel unselected and the + // controller requeues. + return [32]byte{}, monetizeapi.ServiceBountyPanelSeed{}, fmt.Errorf("drand round %d unavailable from all relays: %w", round, errors.Join(errs...)) +} + +func (s *drandSeedSource) fetch(ctx context.Context, relay string, round uint64) (*drandBeacon, error) { + url := fmt.Sprintf("%s/%s/public/%d", strings.TrimRight(relay, "/"), quicknetChainHash, round) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d", resp.StatusCode) + } + var beacon drandBeacon + if err := json.NewDecoder(resp.Body).Decode(&beacon); err != nil { + return nil, fmt.Errorf("decode beacon: %w", err) + } + return &beacon, nil +} + +// verifyQuicknetBeacon BLS-verifies the beacon signature against the quicknet +// group key (scheme bls-unchained-g1-rfc9380: signature on G1, key on G2, +// message = sha256(big-endian round)) and returns the verified randomness +// (sha256 of the signature). +func verifyQuicknetBeacon(beacon *drandBeacon, wantRound uint64) ([]byte, error) { + if beacon.Round != wantRound { + return nil, fmt.Errorf("relay returned round %d, want %d", beacon.Round, wantRound) + } + sig, err := hex.DecodeString(beacon.Signature) + if err != nil { + return nil, fmt.Errorf("decode signature: %w", err) + } + + suite := bls12381.NewBLS12381Suite() + pubBytes, err := hex.DecodeString(quicknetPublicKeyHex) + if err != nil { + return nil, fmt.Errorf("decode quicknet group key: %w", err) + } + pub := suite.G2().Point() + if err := pub.UnmarshalBinary(pubBytes); err != nil { + return nil, fmt.Errorf("unmarshal quicknet group key: %w", err) + } + + var roundBytes [8]byte + binary.BigEndian.PutUint64(roundBytes[:], beacon.Round) + msg := sha256.Sum256(roundBytes[:]) + // bdn over the deprecated sign/bls: identical single-signature Verify; + // the bls deprecation concerns rogue-key attacks on AGGREGATION, which a + // fixed group key + single beacon signature never exercises. + if err := bdn.NewSchemeOnG1(suite).Verify(pub, msg[:], sig); err != nil { + return nil, fmt.Errorf("BLS verify round %d: %w", beacon.Round, err) + } + + randomness := sha256.Sum256(sig) + if beacon.Randomness != "" && !strings.EqualFold(beacon.Randomness, hex.EncodeToString(randomness[:])) { + return nil, fmt.Errorf("relay randomness does not match sha256(signature) for round %d", beacon.Round) + } + return randomness[:], nil +} diff --git a/internal/serviceoffercontroller/seed_test.go b/internal/serviceoffercontroller/seed_test.go new file mode 100644 index 00000000..94b37616 --- /dev/null +++ b/internal/serviceoffercontroller/seed_test.go @@ -0,0 +1,214 @@ +package serviceoffercontroller + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/ObolNetwork/obol-stack/internal/monetizeapi" +) + +// canonicalAddress is the one true (EIP-55) form used for panel/exclusion +// keys throughout the eval market. +func canonicalAddress(addr string) string { + return common.HexToAddress(addr).Hex() +} + +// failingSeedSource simulates an unreachable / lying drand relay set. +type failingSeedSource struct{ calls int } + +func (f *failingSeedSource) Seed(context.Context, string, time.Time) ([32]byte, monetizeapi.ServiceBountyPanelSeed, error) { + f.calls++ + return [32]byte{}, monetizeapi.ServiceBountyPanelSeed{}, errors.New("relay down") +} + +// Real quicknet beacon, recorded once from +// https://api.drand.sh/52db9b…/public/1000 (2026-06-10) and BLS-verified at +// recording time. Round 1000 is emitted at genesis + 999×3s = 1692806364. +const ( + fixtureRound = uint64(1000) + fixtureSignature = "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + fixtureRandomness = "fe290beca10872ef2fb164d2aa4442de4566183ec51c56ff3cd603d930e54fdd" + // fixtureCreatedUnix +30s lag = 1692806361 → first round strictly after + // is round 1000 (emitted at 1692806364). + fixtureCreatedUnix = int64(1692806331) +) + +func fixtureRelay(t *testing.T, body string, status int) *httptest.Server { + t.Helper() + wantPath := fmt.Sprintf("/%s/public/%d", quicknetChainHash, fixtureRound) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != wantPath { + t.Errorf("relay fetched %s, want %s", r.URL.Path, wantPath) + http.NotFound(w, r) + return + } + w.WriteHeader(status) + fmt.Fprint(w, body) + })) + t.Cleanup(server.Close) + return server +} + +func fixtureBody(round uint64, randomness, signature string) string { + return fmt.Sprintf(`{"round":%d,"randomness":"%s","signature":"%s"}`, round, randomness, signature) +} + +func TestLocalSeedSource_ProvenancePinned(t *testing.T) { + seed, provenance, err := localSeedSource{}.Seed(context.Background(), "uid-42", time.Now()) + if err != nil { + t.Fatal(err) + } + if want := sha256.Sum256([]byte("uid-42")); seed != want { + t.Fatalf("local seed must be exactly sha256(uid): got %x want %x", seed, want) + } + if want := (monetizeapi.ServiceBountyPanelSeed{Source: "local"}); !reflect.DeepEqual(provenance, want) { + t.Fatalf("local provenance = %+v, want %+v", provenance, want) + } +} + +func TestNewSeedSource_EnvModeSelection(t *testing.T) { + t.Setenv(seedModeEnv, "drand") + if _, ok := newSeedSource().(*drandSeedSource); !ok { + t.Fatal("OBOL_BOUNTY_SEED=drand must select the drand source") + } + t.Setenv(seedModeEnv, "") + if _, ok := newSeedSource().(localSeedSource); !ok { + t.Fatal("unset/other OBOL_BOUNTY_SEED must select the local source") + } + t.Setenv(seedModeEnv, "anything-else") + if _, ok := newSeedSource().(localSeedSource); !ok { + t.Fatal("unrecognized OBOL_BOUNTY_SEED must select the local source") + } +} + +func TestNewDrandSeedSource_RelayEnvOverride(t *testing.T) { + t.Setenv(drandRelaysEnv, " https://relay-a.example/ ,https://relay-b.example") + src := newDrandSeedSource(nil) + want := []string{"https://relay-a.example", "https://relay-b.example"} + if !reflect.DeepEqual(src.relays, want) { + t.Fatalf("relays = %v, want %v", src.relays, want) + } + t.Setenv(drandRelaysEnv, "") + if src := newDrandSeedSource(nil); !reflect.DeepEqual(src.relays, defaultDrandRelays) { + t.Fatalf("relays = %v, want defaults %v", src.relays, defaultDrandRelays) + } +} + +func TestDrandRoundAfter(t *testing.T) { + genesis := time.Unix(quicknetGenesisUnix, 0) + cases := []struct { + t time.Time + want uint64 + }{ + {genesis.Add(-time.Hour), 1}, // before genesis → first beacon + {genesis, 2}, // round 1 is AT genesis, not strictly after + {genesis.Add(1 * time.Second), 2}, // round 2 at genesis+3s + {genesis.Add(3 * time.Second), 3}, // exactly on round 2 → next + {time.Unix(fixtureCreatedUnix+30, 0), fixtureRound}, // the fixture anchor + } + for _, c := range cases { + if got := drandRoundAfter(c.t); got != c.want { + t.Errorf("drandRoundAfter(%s) = %d, want %d", c.t, got, c.want) + } + } +} + +func TestDrandSeedSource_RealFixtureVerifies(t *testing.T) { + server := fixtureRelay(t, fixtureBody(fixtureRound, fixtureRandomness, fixtureSignature), http.StatusOK) + src := newDrandSeedSource([]string{server.URL}) + + seed, provenance, err := src.Seed(context.Background(), "uid-7", time.Unix(fixtureCreatedUnix, 0)) + if err != nil { + t.Fatalf("Seed on the recorded quicknet beacon must verify: %v", err) + } + if provenance.Source != "drand" || provenance.Round != fixtureRound || + provenance.Randomness != fixtureRandomness || provenance.Signature != fixtureSignature { + t.Fatalf("provenance = %+v, want the recorded beacon", provenance) + } + randomness, _ := hex.DecodeString(fixtureRandomness) + if want := sha256.Sum256(append([]byte("uid-7"), randomness...)); seed != want { + t.Fatalf("seed = %x, want sha256(uid || randomness) = %x", seed, want) + } +} + +func TestDrandSeedSource_FlippedSignatureBitFails(t *testing.T) { + // Flip one bit in the last signature byte: 0x39 → 0x38. + tampered := fixtureSignature[:len(fixtureSignature)-1] + "8" + tamperedRandomness := sha256.Sum256(mustHex(t, tampered)) + server := fixtureRelay(t, fixtureBody(fixtureRound, hex.EncodeToString(tamperedRandomness[:]), tampered), http.StatusOK) + src := newDrandSeedSource([]string{server.URL}) + + _, _, err := src.Seed(context.Background(), "uid-7", time.Unix(fixtureCreatedUnix, 0)) + if err == nil { + t.Fatal("a flipped signature bit must fail BLS verification") + } + if !strings.Contains(err.Error(), "BLS verify") { + t.Fatalf("error must come from BLS verification, got: %v", err) + } +} + +func TestDrandSeedSource_TamperedRandomnessFails(t *testing.T) { + tampered := "ff" + fixtureRandomness[2:] + server := fixtureRelay(t, fixtureBody(fixtureRound, tampered, fixtureSignature), http.StatusOK) + src := newDrandSeedSource([]string{server.URL}) + + if _, _, err := src.Seed(context.Background(), "uid-7", time.Unix(fixtureCreatedUnix, 0)); err == nil { + t.Fatal("relay randomness that is not sha256(signature) must be rejected") + } +} + +func TestDrandSeedSource_WrongRoundFails(t *testing.T) { + server := fixtureRelay(t, fixtureBody(fixtureRound+1, fixtureRandomness, fixtureSignature), http.StatusOK) + src := newDrandSeedSource([]string{server.URL}) + + if _, _, err := src.Seed(context.Background(), "uid-7", time.Unix(fixtureCreatedUnix, 0)); err == nil { + t.Fatal("a relay answering with the wrong round must be rejected") + } +} + +func TestDrandSeedSource_AllRelaysDownErrorsNoLocalFallback(t *testing.T) { + server := fixtureRelay(t, `{"error":"boom"}`, http.StatusInternalServerError) + src := newDrandSeedSource([]string{server.URL, server.URL}) + + seed, provenance, err := src.Seed(context.Background(), "uid-7", time.Unix(fixtureCreatedUnix, 0)) + if err == nil { + t.Fatal("drand mode must surface relay failure — NEVER fall back to the local seed") + } + if seed != ([32]byte{}) || provenance.Source != "" { + t.Fatalf("failure must return zero seed/provenance, got %x / %+v", seed, provenance) + } +} + +func TestDrandSeedSource_SecondRelayServes(t *testing.T) { + down := fixtureRelay(t, "", http.StatusBadGateway) + up := fixtureRelay(t, fixtureBody(fixtureRound, fixtureRandomness, fixtureSignature), http.StatusOK) + src := newDrandSeedSource([]string{down.URL, up.URL}) + + _, provenance, err := src.Seed(context.Background(), "uid-7", time.Unix(fixtureCreatedUnix, 0)) + if err != nil { + t.Fatalf("second relay must serve when the first is down: %v", err) + } + if provenance.Round != fixtureRound { + t.Fatalf("provenance round = %d, want %d", provenance.Round, fixtureRound) + } +} + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/internal/stack/safety_test.go b/internal/stack/safety_test.go index e0fa3eb3..e3b32159 100644 --- a/internal/stack/safety_test.go +++ b/internal/stack/safety_test.go @@ -119,8 +119,8 @@ func TestErrSafetyAborted_IsExported(t *testing.T) { func TestRawOffer_GateReadyRequiresBothConditions(t *testing.T) { cases := []struct { - name string - conds [][2]string // (type, status) + name string + conds [][2]string // (type, status) wantGate bool }{ {"both true", [][2]string{{"PaymentGateReady", "True"}, {"RoutePublished", "True"}}, true}, diff --git a/internal/stack/stack.go b/internal/stack/stack.go index 29a77692..949f901e 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -959,6 +959,7 @@ var baseLocalImages = []localImage{ {tag: "ghcr.io/obolnetwork/x402-verifier:latest", dockerfile: "Dockerfile.x402-verifier"}, {tag: "ghcr.io/obolnetwork/serviceoffer-controller:latest", dockerfile: "Dockerfile.serviceoffer-controller"}, {tag: "ghcr.io/obolnetwork/x402-buyer:latest", dockerfile: "Dockerfile.x402-buyer"}, + {tag: "ghcr.io/obolnetwork/x402-escrow:latest", dockerfile: "Dockerfile.x402-escrow"}, {tag: "ghcr.io/obolnetwork/demo-server:latest", dockerfile: "Dockerfile.demo-server"}, {tag: "ghcr.io/obolnetwork/obol-stack-public-storefront:latest", dockerfile: "Dockerfile.public-storefront"}, } diff --git a/internal/x402/escrow/gateway.go b/internal/x402/escrow/gateway.go index a19847be..040ebd81 100644 --- a/internal/x402/escrow/gateway.go +++ b/internal/x402/escrow/gateway.go @@ -37,6 +37,33 @@ const ( StateVoided = "Voided" ) +// Permit2Voucher is a Uniswap Permit2 SignatureTransfer +// PermitBatchTransferFrom authorization signed by Owner, executable only by +// Spender (the escrow facilitator), with the recipients declared at signing +// time. The voucher binds owner, token, spender, nonce, deadline, and every +// recipient seat into one EIP-712 signature, so whoever holds it can only +// move the signed amounts to the signed recipients — or nothing. +type Permit2Voucher struct { + // Owner is the signer whose funds the voucher moves. + Owner string `json:"owner"` + // Token is the ERC-20 token contract address. + Token string `json:"token"` + // Network is the chain alias the voucher settles on (e.g. base-sepolia). + Network string `json:"network"` + // Spender is the only address allowed to execute the transfer — the + // escrow facilitator. + Spender string `json:"spender"` + // Nonce is the Permit2 unordered nonce as a uint256 decimal string. + Nonce string `json:"nonce"` + // Deadline is the unix timestamp the voucher expires at. + Deadline int64 `json:"deadline"` + // Recipients are the payout seats, amounts in atomic token units — one + // permitted entry per recipient seat. + Recipients []BatchRecipient `json:"recipients"` + // Signature is the 0x-hex 65-byte EIP-712 signature over the permit. + Signature string `json:"signature"` +} + // ReserveRequest identifies the reward authorization the facilitator should // verify and hold for a bounty. type ReserveRequest struct { @@ -51,12 +78,19 @@ type ReserveRequest struct { Amount string `json:"amount"` // Scheme is the x402 settlement scheme (upto today, authCapture later). Scheme string `json:"scheme"` + // Voucher is the optional Permit2 batch-transfer authorization backing + // the reservation (real escrow). Gateways that hold nothing (the dev + // ledger) may ignore it. + Voucher *Permit2Voucher `json:"voucher,omitempty"` } // Receipt is the gateway's record of an escrow operation. type Receipt struct { State string `json:"state"` TxHash string `json:"txHash,omitempty"` + // Spender is the facilitator address vouchers must name as the only + // executor; surfaced so signers can bind it before reserving. + Spender string `json:"spender,omitempty"` } // Gateway is the Hold/Release/Refund seam. Implementations must be safe for diff --git a/internal/x402/escrow/permit2.go b/internal/x402/escrow/permit2.go new file mode 100644 index 00000000..70ba88b2 --- /dev/null +++ b/internal/x402/escrow/permit2.go @@ -0,0 +1,280 @@ +package escrow + +import ( + "crypto/ecdsa" + "fmt" + "math/big" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/x402" +) + +// Permit2Address is the canonical Uniswap Permit2 deployment, CREATE2-deployed +// at the same address on every EVM chain. +const Permit2Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3" + +// ChainIDForNetwork resolves a chain alias ("base-sepolia") or CAIP-2 id +// ("eip155:84532") to its EIP-155 chain ID. Aliases route through the x402 +// chain registry so both legacy and CAIP-2 forms work (see pitfall: both +// forms must resolve or paid routes silently 404). +func ChainIDForNetwork(network string) (*big.Int, error) { + n := strings.TrimSpace(network) + if n == "" { + return nil, fmt.Errorf("permit2: empty network") + } + if rest, ok := strings.CutPrefix(strings.ToLower(n), "eip155:"); ok { + id, parsed := new(big.Int).SetString(rest, 10) + if !parsed || id.Sign() <= 0 { + return nil, fmt.Errorf("permit2: invalid CAIP-2 network %q", network) + } + return id, nil + } + info, err := x402.ResolveChainInfo(n) + if err != nil { + return nil, fmt.Errorf("permit2: resolve network %q: %w", network, err) + } + rest := strings.TrimPrefix(info.CAIP2Network, "eip155:") + id, parsed := new(big.Int).SetString(rest, 10) + if !parsed || id.Sign() <= 0 { + return nil, fmt.Errorf("permit2: chain registry returned invalid CAIP-2 id %q for %q", info.CAIP2Network, network) + } + return id, nil +} + +// voucherTypes is the EIP-712 type set for Permit2 SignatureTransfer +// PermitBatchTransferFrom. The domain deliberately has NO version field — +// Permit2's EIP712.sol hashes +// keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)") +// with name "Permit2" and nothing else. +func voucherTypes() apitypes.Types { + return apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + "PermitBatchTransferFrom": { + {Name: "permitted", Type: "TokenPermissions[]"}, + {Name: "spender", Type: "address"}, + {Name: "nonce", Type: "uint256"}, + {Name: "deadline", Type: "uint256"}, + }, + "TokenPermissions": { + {Name: "token", Type: "address"}, + {Name: "amount", Type: "uint256"}, + }, + } +} + +// parseUint256 parses a non-negative decimal uint256 string. +func parseUint256(field, s string) (*big.Int, error) { + v, ok := new(big.Int).SetString(strings.TrimSpace(s), 10) + if !ok || v.Sign() < 0 || v.BitLen() > 256 { + return nil, fmt.Errorf("permit2: %s %q is not a decimal uint256", field, s) + } + return v, nil +} + +// parsePositiveAmount parses a strictly positive decimal uint256 amount. +func parsePositiveAmount(field, s string) (*big.Int, error) { + v, err := parseUint256(field, s) + if err != nil { + return nil, err + } + if v.Sign() <= 0 { + return nil, fmt.Errorf("permit2: %s %q must be a positive integer", field, s) + } + return v, nil +} + +// validateVoucherFields checks every field needed to hash the voucher (it does +// NOT check signature or deadline-in-future — that is VerifyVoucher's job). +func validateVoucherFields(v Permit2Voucher) error { + if !common.IsHexAddress(v.Owner) { + return fmt.Errorf("permit2: invalid owner address %q", v.Owner) + } + if !common.IsHexAddress(v.Token) { + return fmt.Errorf("permit2: invalid token address %q", v.Token) + } + if !common.IsHexAddress(v.Spender) { + return fmt.Errorf("permit2: invalid spender address %q", v.Spender) + } + if _, err := parseUint256("nonce", v.Nonce); err != nil { + return err + } + if v.Deadline <= 0 { + return fmt.Errorf("permit2: deadline %d must be a positive unix timestamp", v.Deadline) + } + if len(v.Recipients) == 0 { + return fmt.Errorf("permit2: voucher has no recipients") + } + for i, r := range v.Recipients { + if !common.IsHexAddress(r.Address) { + return fmt.Errorf("permit2: recipient %d has invalid address %q", i, r.Address) + } + if _, err := parsePositiveAmount(fmt.Sprintf("recipient %d amount", i), r.Amount); err != nil { + return err + } + } + return nil +} + +// VoucherTypedData builds the EIP-712 payload for a voucher in both the +// go-ethereum apitypes form (canonical digest, local signing) and the +// erc8004.EIP712TypedData form (RemoteSigner.SignTypedData wire payload). +// permitted[i] pairs with v.Recipients[i]: one TokenPermissions entry per +// recipient seat, all on the same token. The chainId in the remote-signer +// domain is a decimal string (math.HexOrDecimal256 accepts both forms). +func VoucherTypedData(v Permit2Voucher, chainID *big.Int) (apitypes.TypedData, erc8004.EIP712TypedData, error) { + if chainID == nil || chainID.Sign() <= 0 { + return apitypes.TypedData{}, erc8004.EIP712TypedData{}, fmt.Errorf("permit2: chainID must be positive") + } + if err := validateVoucherFields(v); err != nil { + return apitypes.TypedData{}, erc8004.EIP712TypedData{}, err + } + + token := common.HexToAddress(v.Token).Hex() + permitted := make([]interface{}, len(v.Recipients)) + for i, r := range v.Recipients { + permitted[i] = map[string]interface{}{ + "token": token, + "amount": r.Amount, + } + } + message := map[string]interface{}{ + "permitted": permitted, + "spender": common.HexToAddress(v.Spender).Hex(), + "nonce": v.Nonce, + "deadline": strconv.FormatInt(v.Deadline, 10), + } + + typed := apitypes.TypedData{ + Types: voucherTypes(), + PrimaryType: "PermitBatchTransferFrom", + Domain: apitypes.TypedDataDomain{ + Name: "Permit2", + ChainId: (*math.HexOrDecimal256)(new(big.Int).Set(chainID)), + VerifyingContract: Permit2Address, + }, + Message: message, + } + + remoteTypes := make(map[string][]erc8004.EIP712Field, len(typed.Types)) + for name, fields := range typed.Types { + converted := make([]erc8004.EIP712Field, len(fields)) + for i, f := range fields { + converted[i] = erc8004.EIP712Field{Name: f.Name, Type: f.Type} + } + remoteTypes[name] = converted + } + remote := erc8004.EIP712TypedData{ + Types: remoteTypes, + PrimaryType: "PermitBatchTransferFrom", + Domain: map[string]interface{}{ + "name": "Permit2", + "chainId": chainID.String(), + "verifyingContract": Permit2Address, + }, + Message: message, + } + return typed, remote, nil +} + +// HashVoucher returns the canonical EIP-712 digest the voucher signature +// commits to. +func HashVoucher(v Permit2Voucher, chainID *big.Int) (common.Hash, error) { + typed, _, err := VoucherTypedData(v, chainID) + if err != nil { + return common.Hash{}, err + } + digest, _, err := apitypes.TypedDataAndHash(typed) + if err != nil { + return common.Hash{}, fmt.Errorf("permit2: hash typed data: %w", err) + } + return common.BytesToHash(digest), nil +} + +// SignVoucher signs the voucher with key and fills v.Signature (65-byte +// 0x-hex, v in Ethereum 27/28 convention). When v.Owner is empty it is filled +// from the key; when set it must match the key's address. +func SignVoucher(v *Permit2Voucher, chainID *big.Int, key *ecdsa.PrivateKey) error { + if v == nil { + return fmt.Errorf("permit2: nil voucher") + } + if key == nil { + return fmt.Errorf("permit2: nil signing key") + } + addr := crypto.PubkeyToAddress(key.PublicKey) + if v.Owner == "" { + v.Owner = addr.Hex() + } else if common.HexToAddress(v.Owner) != addr { + return fmt.Errorf("permit2: voucher owner %s does not match signing key %s", v.Owner, addr.Hex()) + } + + hash, err := HashVoucher(*v, chainID) + if err != nil { + return err + } + sig, err := crypto.Sign(hash.Bytes(), key) + if err != nil { + return fmt.Errorf("permit2: sign voucher: %w", err) + } + sig[64] += 27 + v.Signature = hexutil.Encode(sig) + return nil +} + +// VerifyVoucher checks that the voucher is internally valid (addresses parse, +// every amount is a positive integer), names expectedSpender as the only +// executor, has not expired, and carries a signature that recovers to Owner. +func VerifyVoucher(v Permit2Voucher, chainID *big.Int, expectedSpender common.Address) error { + if err := validateVoucherFields(v); err != nil { + return err + } + if expectedSpender == (common.Address{}) { + return fmt.Errorf("permit2: expected spender is unset") + } + if common.HexToAddress(v.Spender) != expectedSpender { + return fmt.Errorf("permit2: voucher spender %s is not the facilitator %s", v.Spender, expectedSpender.Hex()) + } + if v.Deadline <= time.Now().Unix() { + return fmt.Errorf("permit2: voucher expired at %d", v.Deadline) + } + + sig, err := hexutil.Decode(v.Signature) + if err != nil { + return fmt.Errorf("permit2: decode signature: %w", err) + } + if len(sig) != 65 { + return fmt.Errorf("permit2: signature must be 65 bytes, got %d", len(sig)) + } + // Normalize Ethereum 27/28 recovery id to 0/1 for SigToPub. + recoverable := make([]byte, 65) + copy(recoverable, sig) + if recoverable[64] >= 27 { + recoverable[64] -= 27 + } + + hash, err := HashVoucher(v, chainID) + if err != nil { + return err + } + pub, err := crypto.SigToPub(hash.Bytes(), recoverable) + if err != nil { + return fmt.Errorf("permit2: recover signer: %w", err) + } + recovered := crypto.PubkeyToAddress(*pub) + if recovered != common.HexToAddress(v.Owner) { + return fmt.Errorf("permit2: signature recovers to %s, voucher owner is %s", recovered.Hex(), v.Owner) + } + return nil +} diff --git a/internal/x402/escrow/permit2_test.go b/internal/x402/escrow/permit2_test.go new file mode 100644 index 00000000..8e2a1c8e --- /dev/null +++ b/internal/x402/escrow/permit2_test.go @@ -0,0 +1,325 @@ +package escrow + +import ( + "crypto/ecdsa" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// Anvil dev key 0 — fixed so signatures and digests are deterministic. +const anvilKey0 = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +var testSpender = common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") // anvil #1 + +func testKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + key, err := crypto.HexToECDSA(anvilKey0) + if err != nil { + t.Fatalf("parse test key: %v", err) + } + return key +} + +// goldenVoucher is the fixed voucher every pinned value derives from. +// Deadline 1893456000 = 2030-01-01T00:00:00Z. +func goldenVoucher(t *testing.T) (Permit2Voucher, *ecdsa.PrivateKey) { + t.Helper() + key := testKey(t) + return Permit2Voucher{ + Owner: crypto.PubkeyToAddress(key.PublicKey).Hex(), // 0xf39F...2266 + Token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Network: "base-sepolia", + Spender: testSpender.Hex(), + Nonce: "1", + Deadline: 1893456000, + Recipients: []BatchRecipient{ + {Address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", Amount: "1000"}, + {Address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906", Amount: "2500"}, + }, + }, key +} + +func TestChainIDForNetwork(t *testing.T) { + for _, tc := range []struct { + network string + want int64 + }{ + {"base-sepolia", 84532}, + {"base", 8453}, + {"eip155:84532", 84532}, + {"EIP155:31337", 31337}, // arbitrary CAIP-2 ids pass through + {"ethereum", 1}, + } { + got, err := ChainIDForNetwork(tc.network) + if err != nil { + t.Fatalf("ChainIDForNetwork(%q): %v", tc.network, err) + } + if got.Int64() != tc.want { + t.Errorf("ChainIDForNetwork(%q) = %s, want %d", tc.network, got, tc.want) + } + } + if _, err := ChainIDForNetwork("not-a-chain"); err == nil { + t.Error("ChainIDForNetwork(not-a-chain) should fail") + } + if _, err := ChainIDForNetwork(""); err == nil { + t.Error("ChainIDForNetwork(empty) should fail") + } +} + +func TestSignVoucher_VerifyRoundTrip(t *testing.T) { + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + chainID := big.NewInt(84532) + + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatalf("SignVoucher: %v", err) + } + if v.Signature == "" || !strings.HasPrefix(v.Signature, "0x") || len(v.Signature) != 132 { + t.Fatalf("Signature = %q, want 65-byte 0x-hex", v.Signature) + } + if err := VerifyVoucher(v, chainID, testSpender); err != nil { + t.Fatalf("VerifyVoucher: %v", err) + } +} + +func TestSignVoucher_FillsOwnerAndRejectsMismatch(t *testing.T) { + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + chainID := big.NewInt(84532) + + v.Owner = "" + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatalf("SignVoucher with empty owner: %v", err) + } + want := crypto.PubkeyToAddress(key.PublicKey) + if common.HexToAddress(v.Owner) != want { + t.Errorf("Owner = %s, want %s", v.Owner, want.Hex()) + } + + v.Owner = testSpender.Hex() // not the key's address + if err := SignVoucher(&v, chainID, key); err == nil { + t.Error("SignVoucher should reject owner/key mismatch") + } +} + +func TestVerifyVoucher_WrongSpender(t *testing.T) { + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + chainID := big.NewInt(84532) + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + + other := crypto.PubkeyToAddress(testKey(t).PublicKey) // owner, not spender + if err := VerifyVoucher(v, chainID, other); err == nil || !strings.Contains(err.Error(), "spender") { + t.Fatalf("VerifyVoucher with wrong spender = %v, want spender binding error", err) + } + if err := VerifyVoucher(v, chainID, common.Address{}); err == nil { + t.Fatal("VerifyVoucher with zero expected spender should fail") + } +} + +func TestVerifyVoucher_Expired(t *testing.T) { + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(-time.Minute).Unix() + chainID := big.NewInt(84532) + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + if err := VerifyVoucher(v, chainID, testSpender); err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("VerifyVoucher(expired) = %v, want expiry error", err) + } +} + +func TestVerifyVoucher_TamperedVoucherFails(t *testing.T) { + chainID := big.NewInt(84532) + + tamper := []struct { + name string + mutate func(*Permit2Voucher) + }{ + {"amount", func(v *Permit2Voucher) { v.Recipients[0].Amount = "999999" }}, + {"nonce", func(v *Permit2Voucher) { v.Nonce = "2" }}, + {"deadline", func(v *Permit2Voucher) { v.Deadline += 60 }}, + {"token", func(v *Permit2Voucher) { v.Token = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }}, + } + for _, tc := range tamper { + t.Run(tc.name, func(t *testing.T) { + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + tc.mutate(&v) + if err := VerifyVoucher(v, chainID, testSpender); err == nil { + t.Fatalf("VerifyVoucher should fail after tampering with %s", tc.name) + } + }) + } + + // Wrong chain id also breaks the signature (domain separator changes). + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + if err := VerifyVoucher(v, big.NewInt(8453), testSpender); err == nil { + t.Fatal("VerifyVoucher should fail on a different chain id") + } +} + +// TestVoucherRecipientAddressIsPolicyBoundNotSignatureBound documents a known +// property of standard (non-witness) Permit2 SignatureTransfer: the signature +// commits to the (token, amount) seats, spender, nonce, and deadline — NOT to +// recipient addresses, which live only in transferDetails at execution time. +// Recipient binding is therefore facilitator POLICY (capture pays only the +// stored voucher's seats, transported under the bearer-token reserve), not +// cryptography. Binding addresses into the signature would require the +// PermitBatchWitnessTransferFrom variant. +func TestVoucherRecipientAddressIsPolicyBoundNotSignatureBound(t *testing.T) { + chainID := big.NewInt(84532) + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + v.Recipients[1].Address = testSpender.Hex() // same amount, different payee + if err := VerifyVoucher(v, chainID, testSpender); err != nil { + t.Fatalf("recipient address is not part of the Permit2 digest; verify = %v", err) + } +} + +func TestVerifyVoucher_FieldValidation(t *testing.T) { + chainID := big.NewInt(84532) + cases := []struct { + name string + mutate func(*Permit2Voucher) + }{ + {"zero amount", func(v *Permit2Voucher) { v.Recipients[0].Amount = "0" }}, + {"negative amount", func(v *Permit2Voucher) { v.Recipients[0].Amount = "-5" }}, + {"non-numeric amount", func(v *Permit2Voucher) { v.Recipients[0].Amount = "1.5 USDC" }}, + {"no recipients", func(v *Permit2Voucher) { v.Recipients = nil }}, + {"bad owner", func(v *Permit2Voucher) { v.Owner = "owner" }}, + {"bad nonce", func(v *Permit2Voucher) { v.Nonce = "0xzz" }}, + {"zero deadline", func(v *Permit2Voucher) { v.Deadline = 0 }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + tc.mutate(&v) + if err := VerifyVoucher(v, chainID, testSpender); err == nil { + t.Fatalf("VerifyVoucher should reject %s", tc.name) + } + }) + } +} + +// TestHashVoucher_GoldenAndManualReconstruction pins the canonical digest and +// independently reconstructs it with raw keccak over Permit2's PermitHash +// semantics — proving the apitypes encoding matches the on-chain library +// (domain WITHOUT version, nested TokenPermissions[] array of struct hashes). +func TestHashVoucher_GoldenAndManualReconstruction(t *testing.T) { + v, _ := goldenVoucher(t) + chainID := big.NewInt(84532) + + got, err := HashVoucher(v, chainID) + if err != nil { + t.Fatalf("HashVoucher: %v", err) + } + const golden = "0x352592eb204c815305c91afb79b1136fe4714297bd5cbb0c6ed3fe75fa8e6a75" + if got.Hex() != golden { + t.Errorf("HashVoucher = %s, want pinned %s", got.Hex(), golden) + } + + // Manual reconstruction, mirroring permit2/src/libraries/PermitHash.sol. + pad := func(b []byte) []byte { return common.LeftPadBytes(b, 32) } + domainTypeHash := crypto.Keccak256([]byte("EIP712Domain(string name,uint256 chainId,address verifyingContract)")) + domainSep := crypto.Keccak256( + domainTypeHash, + crypto.Keccak256([]byte("Permit2")), + pad(chainID.Bytes()), + pad(common.HexToAddress(Permit2Address).Bytes()), + ) + + tokenPermTypeHash := crypto.Keccak256([]byte("TokenPermissions(address token,uint256 amount)")) + var permHashes []byte + for _, r := range v.Recipients { + amount, ok := new(big.Int).SetString(r.Amount, 10) + if !ok { + t.Fatalf("amount %q", r.Amount) + } + permHashes = append(permHashes, crypto.Keccak256( + tokenPermTypeHash, + pad(common.HexToAddress(v.Token).Bytes()), + pad(amount.Bytes()), + )...) + } + permittedHash := crypto.Keccak256(permHashes) + + batchTypeHash := crypto.Keccak256([]byte( + "PermitBatchTransferFrom(TokenPermissions[] permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)", + )) + nonce, _ := new(big.Int).SetString(v.Nonce, 10) + structHash := crypto.Keccak256( + batchTypeHash, + permittedHash, + pad(common.HexToAddress(v.Spender).Bytes()), + pad(nonce.Bytes()), + pad(big.NewInt(v.Deadline).Bytes()), + ) + + manual := crypto.Keccak256([]byte("\x19\x01"), domainSep, structHash) + if common.BytesToHash(manual) != got { + t.Errorf("manual PermitHash reconstruction %x != HashVoucher %s", manual, got.Hex()) + } +} + +func TestVoucherTypedData_RemotePayloadShape(t *testing.T) { + v, _ := goldenVoucher(t) + chainID := big.NewInt(84532) + + typed, remote, err := VoucherTypedData(v, chainID) + if err != nil { + t.Fatalf("VoucherTypedData: %v", err) + } + if typed.PrimaryType != "PermitBatchTransferFrom" || remote.PrimaryType != "PermitBatchTransferFrom" { + t.Errorf("primary types = %q / %q", typed.PrimaryType, remote.PrimaryType) + } + + // Permit2's domain has NO version field. + if _, ok := remote.Domain["version"]; ok { + t.Error("remote domain must not carry a version field (Permit2 omits it)") + } + for _, f := range remote.Types["EIP712Domain"] { + if f.Name == "version" { + t.Error("EIP712Domain type must not declare version") + } + } + if remote.Domain["name"] != "Permit2" { + t.Errorf("domain name = %v", remote.Domain["name"]) + } + if remote.Domain["chainId"] != "84532" { + t.Errorf("domain chainId = %v, want decimal string", remote.Domain["chainId"]) + } + if remote.Domain["verifyingContract"] != Permit2Address { + t.Errorf("verifyingContract = %v", remote.Domain["verifyingContract"]) + } + + permitted, ok := remote.Message["permitted"].([]interface{}) + if !ok || len(permitted) != len(v.Recipients) { + t.Fatalf("permitted = %#v, want one entry per recipient seat", remote.Message["permitted"]) + } + + if _, _, err := VoucherTypedData(v, nil); err == nil { + t.Error("VoucherTypedData(nil chainID) should fail") + } +} diff --git a/internal/x402/escrow/server.go b/internal/x402/escrow/server.go new file mode 100644 index 00000000..dc6b5488 --- /dev/null +++ b/internal/x402/escrow/server.go @@ -0,0 +1,382 @@ +package escrow + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "regexp" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" +) + +// idPattern bounds escrow ids to safe filenames (the store keys files by id). +// ServiceBounty UIDs (RFC-4122) always match. +var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +// ServerOptions configures the escrow facilitator HTTP surface. +type ServerOptions struct { + // Token is the bearer credential required on every /escrow/{op} POST. + // Empty disables auth, mirroring HTTPGateway, which omits the + // Authorization header entirely when its Token is empty. + Token string + // Spender is the facilitator's settlement address — the only executor + // vouchers may name. Zero means no signing identity is configured: + // voucher-less reserves still work (with an empty spender hint) but + // voucher verification and capture are refused. + Spender common.Address + // Networks are the chain aliases this facilitator settles on, surfaced + // via GET /escrow/info. When non-empty, vouchers on other chains are + // rejected at reserve time. + Networks []string + // Submitter executes captures on-chain. Nil disables capture (503) while + // reserve/void keep working. + Submitter Submitter +} + +// Server implements the facilitator side of the escrow wire protocol that +// HTTPGateway speaks: POST /escrow/{reserve,capture,void}/{id} plus +// GET /escrow/info and GET /healthz. The server holds no nonce-invalidation +// logic: Void is store-only, because the voucher deadline is the hard +// on-chain guarantee (v1 — no invalidateUnorderedNonces call). +type Server struct { + store *Store + token string + spender common.Address + networks []string + networkIDs []*big.Int + submitter Submitter + + mu sync.Mutex + locks map[string]*sync.Mutex +} + +// NewServer builds a Server over a Store. +func NewServer(store *Store, opts ServerOptions) *Server { + s := &Server{ + store: store, + token: opts.Token, + spender: opts.Spender, + networks: opts.Networks, + submitter: opts.Submitter, + locks: make(map[string]*sync.Mutex), + } + for _, n := range opts.Networks { + if id, err := ChainIDForNetwork(n); err == nil { + s.networkIDs = append(s.networkIDs, id) + } + } + return s +} + +// Handler returns the HTTP mux for the escrow surface. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /escrow/reserve/{id}", s.auth(s.handleReserve)) + mux.HandleFunc("POST /escrow/capture/{id}", s.auth(s.handleCapture)) + mux.HandleFunc("POST /escrow/void/{id}", s.auth(s.handleVoid)) + mux.HandleFunc("GET /escrow/info", s.handleInfo) + mux.HandleFunc("GET /healthz", s.handleHealthz) + return mux +} + +// auth enforces the bearer token with a constant-time comparison. An empty +// configured token disables auth (the HTTPGateway client omits the +// Authorization header when its token is empty, so this is the symmetric +// choice for local-first dev; production must set OBOL_ESCROW_TOKEN). +func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.token == "" { + next(w, r) + return + } + got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next(w, r) + } +} + +// lockID serializes operations per escrow id (captures can take minutes on +// chain; a global lock would head-of-line-block every other escrow). +func (s *Server) lockID(id string) func() { + s.mu.Lock() + m, ok := s.locks[id] + if !ok { + m = &sync.Mutex{} + s.locks[id] = m + } + s.mu.Unlock() + m.Lock() + return m.Unlock +} + +func (s *Server) spenderHex() string { + if s.spender == (common.Address{}) { + return "" + } + return s.spender.Hex() +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +func pathID(w http.ResponseWriter, r *http.Request) (string, bool) { + id := r.PathValue("id") + if !idPattern.MatchString(id) { + http.Error(w, "invalid escrow id", http.StatusBadRequest) + return "", false + } + return id, true +} + +// networkAllowed checks the voucher's chain against the configured networks. +func (s *Server) networkAllowed(chainID *big.Int) bool { + if len(s.networkIDs) == 0 { + return true + } + for _, id := range s.networkIDs { + if id.Cmp(chainID) == 0 { + return true + } + } + return false +} + +func (s *Server) handleReserve(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + + var req ReserveRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + http.Error(w, "decode reserve request: "+err.Error(), http.StatusBadRequest) + return + } + if req.ID != "" && req.ID != id { + http.Error(w, fmt.Sprintf("body id %q does not match path id %q", req.ID, id), http.StatusBadRequest) + return + } + req.ID = id + + unlock := s.lockID(id) + defer unlock() + + entry, exists, err := s.store.Get(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Already settled: idempotent success with the stored receipt. + if exists && entry.State == StateCaptured { + writeJSON(w, entry.Receipt) + return + } + // A voucher-less re-reserve never downgrades a held voucher. + if exists && entry.State == StateReserved && req.Voucher == nil { + writeJSON(w, entry.Receipt) + return + } + + var receipt Receipt + if req.Voucher == nil { + receipt = Receipt{State: StateAwaitingVoucher, Spender: s.spenderHex()} + } else { + if s.spender == (common.Address{}) { + http.Error(w, "facilitator signing address not configured; cannot verify voucher spender binding", http.StatusServiceUnavailable) + return + } + chainID, err := ChainIDForNetwork(req.Voucher.Network) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !s.networkAllowed(chainID) { + http.Error(w, fmt.Sprintf("voucher network %q is not served by this facilitator (networks: %s)", req.Voucher.Network, strings.Join(s.networks, ", ")), http.StatusBadRequest) + return + } + // Guard alias drift between the reserve leg and the voucher. + if req.Network != "" { + if reqChain, err := ChainIDForNetwork(req.Network); err == nil && reqChain.Cmp(chainID) != 0 { + http.Error(w, fmt.Sprintf("reserve network %q and voucher network %q resolve to different chains", req.Network, req.Voucher.Network), http.StatusBadRequest) + return + } + } + if err := VerifyVoucher(*req.Voucher, chainID, s.spender); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + receipt = Receipt{State: StateReserved, Spender: s.spenderHex()} + } + + if err := s.store.Put(Entry{ID: id, State: receipt.State, Request: &req, Receipt: receipt, UpdatedAt: time.Now().UTC()}); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, receipt) +} + +// captureRequest is the optional capture body. HTTPGateway.Capture sends no +// body (capture every voucher seat); HTTPGateway.CaptureBatch sends +// {"recipients":[...]} (capture the subset, omitted seats unpaid). +type captureRequest struct { + Recipients []BatchRecipient `json:"recipients"` +} + +func (s *Server) handleCapture(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "read capture request: "+err.Error(), http.StatusBadRequest) + return + } + var req captureRequest + if body := strings.TrimSpace(string(raw)); body != "" { + if err := json.Unmarshal(raw, &req); err != nil { + http.Error(w, "decode capture request: "+err.Error(), http.StatusBadRequest) + return + } + if req.Recipients != nil && len(req.Recipients) == 0 { + http.Error(w, "capture requested an explicitly empty recipient set", http.StatusBadRequest) + return + } + } + + unlock := s.lockID(id) + defer unlock() + + entry, exists, err := s.store.Get(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !exists { + http.Error(w, "no such escrow", http.StatusNotFound) + return + } + + switch entry.State { + case StateCaptured: + // Idempotent: the stored receipt, no second settlement. + writeJSON(w, entry.Receipt) + return + case StateVoided: + http.Error(w, "escrow already voided", http.StatusConflict) + return + case StateAwaitingVoucher: + http.Error(w, "no voucher attached; re-reserve with a signed voucher first", http.StatusConflict) + return + case StateReserved: + // fall through to settle + default: + http.Error(w, "unknown escrow state "+entry.State, http.StatusInternalServerError) + return + } + if entry.Request == nil || entry.Request.Voucher == nil { + http.Error(w, "reserved escrow has no voucher", http.StatusConflict) + return + } + if s.submitter == nil { + http.Error(w, "settlement unavailable: facilitator has no signing key (set OBOL_ESCROW_KEY or OBOL_ESCROW_SIGNER_URL)", http.StatusServiceUnavailable) + return + } + + voucher := *entry.Request.Voucher + requested := req.Recipients + if requested == nil { + requested = voucher.Recipients + } + details, err := BuildTransferDetails(voucher, requested) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + txHash, err := s.submitter.Submit(r.Context(), voucher, details) + if err != nil { + http.Error(w, "settlement failed: "+err.Error(), http.StatusBadGateway) + return + } + + receipt := Receipt{State: StateCaptured, TxHash: txHash, Spender: s.spenderHex()} + entry.State = StateCaptured + entry.Receipt = receipt + entry.UpdatedAt = time.Now().UTC() + if err := s.store.Put(entry); err != nil { + // The transfer settled; surface the receipt anyway and log loudly via + // the error body on the next idempotent call. + http.Error(w, fmt.Sprintf("settled in %s but failed to persist receipt: %v", txHash, err), http.StatusInternalServerError) + return + } + writeJSON(w, receipt) +} + +func (s *Server) handleVoid(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + + unlock := s.lockID(id) + defer unlock() + + entry, exists, err := s.store.Get(id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if !exists { + // Re-runnable refunds: voiding the unknown is a no-op success + // (mirrors LedgerGateway). Nothing is persisted for unknown ids. + writeJSON(w, Receipt{State: StateVoided}) + return + } + if entry.State == StateCaptured { + http.Error(w, "escrow already captured", http.StatusConflict) + return + } + + receipt := Receipt{State: StateVoided, Spender: s.spenderHex()} + entry.State = StateVoided + entry.Receipt = receipt + entry.UpdatedAt = time.Now().UTC() + if err := s.store.Put(entry); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, receipt) +} + +// infoResponse is the GET /escrow/info body: the facilitator settlement +// address signers must bind as the voucher spender, and the chains served. +type infoResponse struct { + Address string `json:"address"` + Networks []string `json:"networks"` +} + +func (s *Server) handleInfo(w http.ResponseWriter, _ *http.Request) { + networks := s.networks + if networks == nil { + networks = []string{} + } + writeJSON(w, infoResponse{Address: s.spenderHex(), Networks: networks}) +} + +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} diff --git a/internal/x402/escrow/server_test.go b/internal/x402/escrow/server_test.go new file mode 100644 index 00000000..80348366 --- /dev/null +++ b/internal/x402/escrow/server_test.go @@ -0,0 +1,416 @@ +package escrow + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// fakeSubmitter records submissions and returns a canned tx hash. +type fakeSubmitter struct { + calls atomic.Int64 + voucher Permit2Voucher + details []TransferDetail + err error +} + +func (f *fakeSubmitter) Submit(_ context.Context, v Permit2Voucher, details []TransferDetail) (string, error) { + f.calls.Add(1) + f.voucher = v + f.details = details + if f.err != nil { + return "", f.err + } + return "0xfeedface", nil +} + +// newTestServer wires a Server over a temp-dir store and returns an +// HTTPGateway client pointed at it — proving the server speaks exactly the +// wire shape the gateway client expects. +func newTestServer(t *testing.T, opts ServerOptions) (*httptest.Server, *HTTPGateway, *Store) { + t.Helper() + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return newTestServerWithStore(t, store, opts) +} + +func newTestServerWithStore(t *testing.T, store *Store, opts ServerOptions) (*httptest.Server, *HTTPGateway, *Store) { + t.Helper() + srv := httptest.NewServer(NewServer(store, opts).Handler()) + t.Cleanup(srv.Close) + g := &HTTPGateway{Base: srv.URL, Token: opts.Token, Client: srv.Client()} + return srv, g, store +} + +// signedTestVoucher returns a voucher bound to testSpender, expiring in 1h. +func signedTestVoucher(t *testing.T) Permit2Voucher { + t.Helper() + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + if err := SignVoucher(&v, big.NewInt(84532), key); err != nil { + t.Fatal(err) + } + return v +} + +func TestServer_ReserveAwaitingThenReserved(t *testing.T) { + sub := &fakeSubmitter{} + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Networks: []string{"base", "base-sepolia"}, Submitter: sub}) + ctx := context.Background() + + // 1. Reserve without a voucher: AwaitingVoucher + the spender to bind. + r, err := g.Reserve(ctx, ReserveRequest{ID: "b1", Network: "base-sepolia", Amount: "3500"}) + if err != nil { + t.Fatalf("Reserve: %v", err) + } + if r.State != StateAwaitingVoucher { + t.Fatalf("state = %q, want AwaitingVoucher", r.State) + } + if r.Spender != testSpender.Hex() { + t.Fatalf("spender hint = %q, want %s", r.Spender, testSpender.Hex()) + } + + // 2. Re-reserve with a voucher binding that spender: Reserved. + v := signedTestVoucher(t) + r2, err := g.Reserve(ctx, ReserveRequest{ID: "b1", Network: "base-sepolia", Voucher: &v}) + if err != nil { + t.Fatalf("re-Reserve with voucher: %v", err) + } + if r2.State != StateReserved || r2.Spender != testSpender.Hex() { + t.Fatalf("receipt = %+v, want Reserved", r2) + } + + // 3. A later voucher-less re-reserve must NOT downgrade the held voucher. + r3, err := g.Reserve(ctx, ReserveRequest{ID: "b1", Network: "base-sepolia"}) + if err != nil { + t.Fatalf("voucher-less re-Reserve: %v", err) + } + if r3.State != StateReserved { + t.Fatalf("voucher-less re-reserve downgraded state to %q", r3.State) + } +} + +func TestServer_ReserveRejectsBadVouchers(t *testing.T) { + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Networks: []string{"base-sepolia"}, Submitter: &fakeSubmitter{}}) + ctx := context.Background() + + // Wrong spender binding. + v, key := goldenVoucher(t) + v.Deadline = time.Now().Add(time.Hour).Unix() + v.Spender = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + if err := SignVoucher(&v, big.NewInt(84532), key); err != nil { + t.Fatal(err) + } + if _, err := g.Reserve(ctx, ReserveRequest{ID: "bad1", Voucher: &v}); err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("wrong-spender reserve = %v, want 400", err) + } + + // Tampered amount. + v2 := signedTestVoucher(t) + v2.Recipients[0].Amount = "999999" + if _, err := g.Reserve(ctx, ReserveRequest{ID: "bad2", Voucher: &v2}); err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("tampered reserve = %v, want 400", err) + } + + // Network not served by this facilitator. + v3, key3 := goldenVoucher(t) + v3.Deadline = time.Now().Add(time.Hour).Unix() + v3.Network = "base" + if err := SignVoucher(&v3, big.NewInt(8453), key3); err != nil { + t.Fatal(err) + } + if _, err := g.Reserve(ctx, ReserveRequest{ID: "bad3", Voucher: &v3}); err == nil || !strings.Contains(err.Error(), "not served") { + t.Errorf("off-network reserve = %v, want network rejection", err) + } + + // Reserve leg / voucher network drift. + v4 := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "bad4", Network: "base", Voucher: &v4}); err == nil || !strings.Contains(err.Error(), "different chains") { + t.Errorf("network-drift reserve = %v, want mismatch error", err) + } +} + +func TestServer_CaptureBatchHappyPathAndIdempotency(t *testing.T) { + sub := &fakeSubmitter{} + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Networks: []string{"base-sepolia"}, Submitter: sub}) + ctx := context.Background() + + v := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b2", Voucher: &v}); err != nil { + t.Fatal(err) + } + + // Capture a subset: seat 0 only; seat 1 must ride along index-wise at 0. + r, err := g.CaptureBatch(ctx, "b2", []BatchRecipient{{Address: v.Recipients[0].Address, Amount: "1000"}}) + if err != nil { + t.Fatalf("CaptureBatch: %v", err) + } + if r.State != StateCaptured || r.TxHash != "0xfeedface" { + t.Fatalf("receipt = %+v", r) + } + if got := sub.calls.Load(); got != 1 { + t.Fatalf("submitter calls = %d, want 1", got) + } + if len(sub.details) != 2 || sub.details[0].Amount.Cmp(big.NewInt(1000)) != 0 || sub.details[1].Amount.Sign() != 0 { + t.Fatalf("submitted details = %+v, want index-wise [1000, 0]", sub.details) + } + + // Idempotent: a second capture returns the stored receipt, no re-settle. + r2, err := g.Capture(ctx, "b2") + if err != nil { + t.Fatalf("re-Capture: %v", err) + } + if r2.TxHash != "0xfeedface" || sub.calls.Load() != 1 { + t.Fatalf("re-capture = %+v after %d submits, want stored receipt and 1 submit", r2, sub.calls.Load()) + } + + // Re-reserve after capture returns the captured receipt (settled is settled). + r3, err := g.Reserve(ctx, ReserveRequest{ID: "b2", Voucher: &v}) + if err != nil { + t.Fatal(err) + } + if r3.State != StateCaptured { + t.Fatalf("re-reserve after capture = %+v", r3) + } +} + +func TestServer_CaptureFullVoucherWhenNoBody(t *testing.T) { + sub := &fakeSubmitter{} + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Submitter: sub}) + ctx := context.Background() + + v := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b3", Voucher: &v}); err != nil { + t.Fatal(err) + } + // Plain Capture (no body) settles every voucher seat. + if _, err := g.Capture(ctx, "b3"); err != nil { + t.Fatalf("Capture: %v", err) + } + if len(sub.details) != 2 || sub.details[0].Amount.Cmp(big.NewInt(1000)) != 0 || sub.details[1].Amount.Cmp(big.NewInt(2500)) != 0 { + t.Fatalf("details = %+v, want full [1000, 2500]", sub.details) + } +} + +func TestServer_CaptureGuards(t *testing.T) { + sub := &fakeSubmitter{} + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Submitter: sub}) + ctx := context.Background() + + // Unknown escrow. + if _, err := g.Capture(ctx, "ghost"); err == nil || !strings.Contains(err.Error(), "404") { + t.Errorf("capture unknown = %v, want 404", err) + } + + // AwaitingVoucher (no voucher attached) cannot capture. + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b4"}); err != nil { + t.Fatal(err) + } + if _, err := g.Capture(ctx, "b4"); err == nil || !strings.Contains(err.Error(), "409") { + t.Errorf("capture awaiting = %v, want 409", err) + } + + // Subset rule: amount mismatch is rejected before any submission. + v := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b5", Voucher: &v}); err != nil { + t.Fatal(err) + } + if _, err := g.CaptureBatch(ctx, "b5", []BatchRecipient{{Address: v.Recipients[0].Address, Amount: "999"}}); err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("amount-mismatch capture = %v, want 400", err) + } + if _, err := g.CaptureBatch(ctx, "b5", []BatchRecipient{{Address: testSpender.Hex(), Amount: "1000"}}); err == nil || !strings.Contains(err.Error(), "400") { + t.Errorf("unknown-recipient capture = %v, want 400", err) + } + if sub.calls.Load() != 0 { + t.Fatalf("submitter was called %d times for rejected captures", sub.calls.Load()) + } + + // Voided escrow cannot capture. + if _, err := g.Void(ctx, "b5"); err != nil { + t.Fatal(err) + } + if _, err := g.Capture(ctx, "b5"); err == nil || !strings.Contains(err.Error(), "409") { + t.Errorf("capture voided = %v, want 409", err) + } + + // Settlement failure surfaces as 502 and leaves the escrow Reserved. + v6 := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b6", Voucher: &v6}); err != nil { + t.Fatal(err) + } + sub.err = fmt.Errorf("rpc down") + if _, err := g.Capture(ctx, "b6"); err == nil || !strings.Contains(err.Error(), "502") { + t.Errorf("failed settle = %v, want 502", err) + } + sub.err = nil + if _, err := g.Capture(ctx, "b6"); err != nil { + t.Errorf("retry after settle failure: %v", err) + } +} + +func TestServer_CaptureWithoutSubmitter503(t *testing.T) { + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Submitter: nil}) + ctx := context.Background() + + v := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b7", Voucher: &v}); err != nil { + t.Fatal(err) + } + _, err := g.Capture(ctx, "b7") + if err == nil || !strings.Contains(err.Error(), "503") || !strings.Contains(err.Error(), "OBOL_ESCROW_KEY") { + t.Fatalf("capture without submitter = %v, want 503 naming OBOL_ESCROW_KEY", err) + } + // Reserve and void still work without a submitter. + if _, err := g.Void(ctx, "b7"); err != nil { + t.Fatalf("void without submitter: %v", err) + } +} + +func TestServer_ReserveVoucherWithoutSpenderConfigured(t *testing.T) { + _, g, _ := newTestServer(t, ServerOptions{Token: "secret"}) + ctx := context.Background() + + // Voucher-less reserve still answers (empty spender hint). + r, err := g.Reserve(ctx, ReserveRequest{ID: "b8"}) + if err != nil || r.State != StateAwaitingVoucher || r.Spender != "" { + t.Fatalf("reserve = %+v, %v", r, err) + } + // Voucher verification is refused: nothing to bind the spender to. + v := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b8", Voucher: &v}); err == nil || !strings.Contains(err.Error(), "503") { + t.Fatalf("voucher reserve without spender = %v, want 503", err) + } +} + +func TestServer_VoidIdempotent(t *testing.T) { + _, g, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender}) + ctx := context.Background() + + // Voiding the unknown is a no-op success (refunds re-runnable). + if r, err := g.Void(ctx, "ghost"); err != nil || r.State != StateVoided { + t.Fatalf("void unknown = %+v, %v", r, err) + } + if _, err := g.Reserve(ctx, ReserveRequest{ID: "b9"}); err != nil { + t.Fatal(err) + } + if r, err := g.Void(ctx, "b9"); err != nil || r.State != StateVoided { + t.Fatalf("void = %+v, %v", r, err) + } + if r, err := g.Void(ctx, "b9"); err != nil || r.State != StateVoided { + t.Fatalf("re-void = %+v, %v", r, err) + } +} + +func TestServer_BearerAuth(t *testing.T) { + srv, _, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender}) + ctx := context.Background() + + wrong := &HTTPGateway{Base: srv.URL, Token: "wrong", Client: srv.Client()} + if _, err := wrong.Reserve(ctx, ReserveRequest{ID: "a1"}); err == nil || !strings.Contains(err.Error(), "401") { + t.Errorf("wrong token = %v, want 401", err) + } + missing := &HTTPGateway{Base: srv.URL, Client: srv.Client()} + if _, err := missing.Void(ctx, "a1"); err == nil || !strings.Contains(err.Error(), "401") { + t.Errorf("missing token = %v, want 401", err) + } + + // Empty server token disables auth — symmetric with the client omitting + // the Authorization header when its Token is empty. + srv2, g2, _ := newTestServer(t, ServerOptions{Token: "", Spender: testSpender}) + _ = srv2 + if _, err := g2.Reserve(ctx, ReserveRequest{ID: "a2"}); err != nil { + t.Errorf("unauthenticated reserve on tokenless server: %v", err) + } +} + +func TestServer_PersistenceAcrossRestart(t *testing.T) { + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sub := &fakeSubmitter{} + _, g, _ := newTestServerWithStore(t, store, ServerOptions{Token: "secret", Spender: testSpender, Submitter: sub}) + ctx := context.Background() + + v := signedTestVoucher(t) + if _, err := g.Reserve(ctx, ReserveRequest{ID: "p1", Voucher: &v}); err != nil { + t.Fatal(err) + } + if _, err := g.Capture(ctx, "p1"); err != nil { + t.Fatal(err) + } + + // "Restart": a new server over the same state dir, with a submitter that + // would fail if called — the stored receipt must be returned instead. + sub2 := &fakeSubmitter{err: fmt.Errorf("must not settle twice")} + _, g2, _ := newTestServerWithStore(t, store, ServerOptions{Token: "secret", Spender: testSpender, Submitter: sub2}) + r, err := g2.Capture(ctx, "p1") + if err != nil { + t.Fatalf("capture after restart: %v", err) + } + if r.State != StateCaptured || r.TxHash != "0xfeedface" { + t.Fatalf("receipt after restart = %+v", r) + } + if sub2.calls.Load() != 0 { + t.Fatal("restart capture re-submitted an already-captured escrow") + } +} + +func TestServer_InfoAndHealthz(t *testing.T) { + srv, _, _ := newTestServer(t, ServerOptions{Token: "secret", Spender: testSpender, Networks: []string{"base", "base-sepolia"}}) + + // /escrow/info is unauthenticated discovery: signers need the spender + // address before they hold any credential. + resp, err := srv.Client().Get(srv.URL + "/escrow/info") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("info status = %d", resp.StatusCode) + } + var info struct { + Address string `json:"address"` + Networks []string `json:"networks"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + t.Fatal(err) + } + if info.Address != testSpender.Hex() { + t.Errorf("info address = %q", info.Address) + } + if len(info.Networks) != 2 || info.Networks[0] != "base" { + t.Errorf("info networks = %v", info.Networks) + } + + hz, err := srv.Client().Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + hz.Body.Close() + if hz.StatusCode != http.StatusOK { + t.Errorf("healthz status = %d", hz.StatusCode) + } +} + +func TestServer_RejectsUnsafeIDs(t *testing.T) { + srv, _, _ := newTestServer(t, ServerOptions{Token: "", Spender: testSpender}) + + resp, err := srv.Client().Post(srv.URL+"/escrow/void/bad%20id", "application/json", nil) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("unsafe id status = %d, want 400", resp.StatusCode) + } +} diff --git a/internal/x402/escrow/settle.go b/internal/x402/escrow/settle.go new file mode 100644 index 00000000..fa9270d6 --- /dev/null +++ b/internal/x402/escrow/settle.go @@ -0,0 +1,342 @@ +package escrow + +import ( + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/ObolNetwork/obol-stack/internal/erc8004" + "github.com/ObolNetwork/obol-stack/internal/x402" +) + +// permit2TransferABI is the minimal ABI fragment for Permit2 +// SignatureTransfer's batch permitTransferFrom. Note the calldata permit tuple +// carries NO spender — Permit2 enforces spender == msg.sender on-chain, which +// is exactly why the facilitator address must be signed into the voucher. +const permit2TransferABI = `[{ + "name": "permitTransferFrom", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "permit", "type": "tuple", "components": [ + {"name": "permitted", "type": "tuple[]", "components": [ + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"} + ]}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"} + ]}, + {"name": "transferDetails", "type": "tuple[]", "components": [ + {"name": "to", "type": "address"}, + {"name": "requestedAmount", "type": "uint256"} + ]}, + {"name": "owner", "type": "address"}, + {"name": "signature", "type": "bytes"} + ], + "outputs": [] +}]` + +var permit2ABI = sync.OnceValues(func() (abi.ABI, error) { + return abi.JSON(strings.NewReader(permit2TransferABI)) +}) + +// permit2TokenPermissions mirrors Permit2's TokenPermissions struct for ABI +// packing (component names token/amount map to these fields). +type permit2TokenPermissions struct { + Token common.Address + Amount *big.Int +} + +// permit2PermitBatchTransferFrom mirrors ISignatureTransfer.PermitBatchTransferFrom. +type permit2PermitBatchTransferFrom struct { + Permitted []permit2TokenPermissions + Nonce *big.Int + Deadline *big.Int +} + +// permit2SignatureTransferDetails mirrors ISignatureTransfer.SignatureTransferDetails. +type permit2SignatureTransferDetails struct { + To common.Address + RequestedAmount *big.Int +} + +// TransferDetail is one entry of the on-chain transferDetails array. It pairs +// INDEX-WISE with the voucher's Recipients/permitted array: omitted seats stay +// in the array with Amount 0 (Permit2 allows requesting less than permitted, +// including zero) — the array is never shortened. +type TransferDetail struct { + To common.Address + Amount *big.Int +} + +// HumanToAtomic converts a human-unit decimal amount (e.g. "500.00") to +// atomic token units ("500000000" at 6 decimals) without float rounding. +// Trailing zeros beyond the token's precision are tolerated; non-zero +// sub-atomic remainders are an error. Every voucher seat amount and every +// capture recipient amount MUST be atomic — BuildTransferDetails matches +// requested recipients against signed seats with exact integer comparison. +func HumanToAtomic(amount string, decimals int) (string, error) { + s := strings.TrimSpace(amount) + if s == "" { + return "", fmt.Errorf("amount is empty") + } + whole, frac, _ := strings.Cut(s, ".") + if whole == "" { + whole = "0" + } + for _, r := range whole + frac { + if r < '0' || r > '9' { + return "", fmt.Errorf("amount %q is not a non-negative decimal number", amount) + } + } + if len(frac) > decimals { + if strings.Trim(frac[decimals:], "0") != "" { + return "", fmt.Errorf("amount %q has more than %d decimal places", amount, decimals) + } + frac = frac[:decimals] + } + frac += strings.Repeat("0", decimals-len(frac)) + v, ok := new(big.Int).SetString(whole+frac, 10) + if !ok { + return "", fmt.Errorf("amount %q is not a decimal number", amount) + } + if v.Sign() <= 0 { + return "", fmt.Errorf("amount %q must be positive", amount) + } + return v.String(), nil +} + +// BuildTransferDetails validates that requested is a subset of the voucher's +// recipient seats with exactly matching per-seat amounts, and returns the +// index-wise transferDetails array: matched seats carry their signed amount, +// omitted seats carry zero (unpaid). Duplicate seats are consumed one +// requested entry per seat. +func BuildTransferDetails(v Permit2Voucher, requested []BatchRecipient) ([]TransferDetail, error) { + if len(v.Recipients) == 0 { + return nil, fmt.Errorf("escrow settle: voucher has no recipients") + } + if len(requested) == 0 { + return nil, fmt.Errorf("escrow settle: no recipients requested") + } + + consumed := make([]bool, len(v.Recipients)) + amounts := make([]*big.Int, len(v.Recipients)) + for _, r := range requested { + want, err := parsePositiveAmount("requested amount", r.Amount) + if err != nil { + return nil, err + } + matched := false + for i, seat := range v.Recipients { + if consumed[i] || !strings.EqualFold(seat.Address, r.Address) { + continue + } + signed, err := parsePositiveAmount("voucher amount", seat.Amount) + if err != nil { + return nil, err + } + if signed.Cmp(want) != 0 { + continue // amount mismatch on this seat; another seat may match + } + consumed[i] = true + amounts[i] = want + matched = true + break + } + if !matched { + return nil, fmt.Errorf("escrow settle: requested recipient %s amount %s does not match any unconsumed voucher seat", r.Address, r.Amount) + } + } + + details := make([]TransferDetail, len(v.Recipients)) + for i, seat := range v.Recipients { + amount := amounts[i] + if amount == nil { + amount = new(big.Int) // omitted seat: requestedAmount = 0 + } + details[i] = TransferDetail{To: common.HexToAddress(seat.Address), Amount: amount} + } + return details, nil +} + +// BuildPermitTransferFromCalldata packs the permitTransferFrom calldata for a +// signed voucher and the index-wise transferDetails from BuildTransferDetails. +// len(details) must equal len(v.Recipients) — transferDetails[i] pairs with +// permitted[i]. +func BuildPermitTransferFromCalldata(v Permit2Voucher, details []TransferDetail) ([]byte, error) { + if err := validateVoucherFields(v); err != nil { + return nil, err + } + if len(details) != len(v.Recipients) { + return nil, fmt.Errorf("escrow settle: transferDetails length %d must equal voucher recipients %d (omitted seats get amount 0, the array is never shortened)", len(details), len(v.Recipients)) + } + + token := common.HexToAddress(v.Token) + permitted := make([]permit2TokenPermissions, len(v.Recipients)) + for i, seat := range v.Recipients { + amount, err := parsePositiveAmount("voucher amount", seat.Amount) + if err != nil { + return nil, err + } + permitted[i] = permit2TokenPermissions{Token: token, Amount: amount} + } + nonce, err := parseUint256("nonce", v.Nonce) + if err != nil { + return nil, err + } + permit := permit2PermitBatchTransferFrom{ + Permitted: permitted, + Nonce: nonce, + Deadline: big.NewInt(v.Deadline), + } + + transferDetails := make([]permit2SignatureTransferDetails, len(details)) + for i, d := range details { + amount := d.Amount + if amount == nil { + amount = new(big.Int) + } + if amount.Sign() < 0 { + return nil, fmt.Errorf("escrow settle: transferDetails[%d] amount is negative", i) + } + transferDetails[i] = permit2SignatureTransferDetails{To: d.To, RequestedAmount: amount} + } + + sig, err := hexutil.Decode(v.Signature) + if err != nil { + return nil, fmt.Errorf("escrow settle: decode voucher signature: %w", err) + } + + parsed, err := permit2ABI() + if err != nil { + return nil, fmt.Errorf("escrow settle: parse permit2 abi: %w", err) + } + calldata, err := parsed.Pack("permitTransferFrom", permit, transferDetails, common.HexToAddress(v.Owner), sig) + if err != nil { + return nil, fmt.Errorf("escrow settle: pack permitTransferFrom: %w", err) + } + return calldata, nil +} + +// Submitter executes a verified voucher on-chain. Abstracted so the server +// and its tests never need a live chain. +type Submitter interface { + // Submit sends permitTransferFrom(voucher, details) and waits for the + // receipt. details must pair index-wise with v.Recipients. + Submit(ctx context.Context, v Permit2Voucher, details []TransferDetail) (txHash string, err error) +} + +// erpcNetworkSuffix maps a voucher network alias to the eRPC path segment, +// following the same pattern as erc8004.NewClientForNetwork +// (/, e.g. "ethereum" → "mainnet"). +func erpcNetworkSuffix(network string) string { + if net, err := erc8004.ResolveNetwork(network); err == nil { + return net.ERPCNetwork + } + if info, err := x402.ResolveChainInfo(network); err == nil { + return info.Name + } + return strings.TrimSpace(network) +} + +// EthSubmitter submits permitTransferFrom transactions via JSON-RPC, signing +// locally with Key or remotely via Signer (exactly one should be set). +type EthSubmitter struct { + // RPCBase is the per-network JSON-RPC base; the endpoint is + // /. Defaults to erc8004.DefaultRPCBase + // (in-cluster eRPC). + RPCBase string + // Key signs locally when set. + Key *ecdsa.PrivateKey + // Signer signs via the remote-signer REST API when Key is nil. + Signer *erc8004.RemoteSigner + // SignerAddress is the remote signer's address; resolved via + // Signer.GetAddress when zero. + SignerAddress common.Address + // ReceiptTimeout bounds the wait for the settlement receipt (default 2m). + ReceiptTimeout time.Duration +} + +func (s *EthSubmitter) Submit(ctx context.Context, v Permit2Voucher, details []TransferDetail) (string, error) { + if s.Key == nil && s.Signer == nil { + return "", fmt.Errorf("escrow settle: no signing key configured (set OBOL_ESCROW_KEY or OBOL_ESCROW_SIGNER_URL)") + } + + base := s.RPCBase + if base == "" { + base = erc8004.DefaultRPCBase + } + rpcURL := strings.TrimRight(base, "/") + "/" + erpcNetworkSuffix(v.Network) + eth, err := ethclient.DialContext(ctx, rpcURL) + if err != nil { + return "", fmt.Errorf("escrow settle: dial %s: %w", rpcURL, err) + } + defer eth.Close() + + chainID, err := eth.ChainID(ctx) + if err != nil { + return "", fmt.Errorf("escrow settle: chain id: %w", err) + } + if want, resolveErr := ChainIDForNetwork(v.Network); resolveErr == nil && want.Cmp(chainID) != 0 { + return "", fmt.Errorf("escrow settle: rpc %s reports chain %s but voucher network %q expects %s", rpcURL, chainID, v.Network, want) + } + + calldata, err := BuildPermitTransferFromCalldata(v, details) + if err != nil { + return "", err + } + + var opts *bind.TransactOpts + if s.Key != nil { + opts, err = bind.NewKeyedTransactorWithChainID(s.Key, chainID) + if err != nil { + return "", fmt.Errorf("escrow settle: transactor: %w", err) + } + opts.Context = ctx + } else { + addr := s.SignerAddress + if addr == (common.Address{}) { + addr, err = s.Signer.GetAddress(ctx) + if err != nil { + return "", fmt.Errorf("escrow settle: resolve remote signer address: %w", err) + } + } + opts = s.Signer.RemoteTransactOpts(ctx, addr, chainID) + } + + parsed, err := permit2ABI() + if err != nil { + return "", fmt.Errorf("escrow settle: parse permit2 abi: %w", err) + } + contract := bind.NewBoundContract(common.HexToAddress(Permit2Address), parsed, eth, eth, eth) + tx, err := contract.RawTransact(opts, calldata) + if err != nil { + return "", fmt.Errorf("escrow settle: submit permitTransferFrom: %w", err) + } + + timeout := s.ReceiptTimeout + if timeout <= 0 { + timeout = 2 * time.Minute + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + receipt, err := bind.WaitMined(waitCtx, eth, tx) + if err != nil { + return "", fmt.Errorf("escrow settle: wait receipt for %s: %w", tx.Hash().Hex(), err) + } + if receipt.Status != types.ReceiptStatusSuccessful { + return "", fmt.Errorf("escrow settle: permitTransferFrom %s reverted", tx.Hash().Hex()) + } + return tx.Hash().Hex(), nil +} diff --git a/internal/x402/escrow/settle_test.go b/internal/x402/escrow/settle_test.go new file mode 100644 index 00000000..b6ac8549 --- /dev/null +++ b/internal/x402/escrow/settle_test.go @@ -0,0 +1,184 @@ +package escrow + +import ( + "encoding/hex" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func TestBuildTransferDetails_FullAndSubset(t *testing.T) { + v, _ := goldenVoucher(t) + + // Full capture: every seat paid. + full, err := BuildTransferDetails(v, v.Recipients) + if err != nil { + t.Fatalf("full capture: %v", err) + } + if len(full) != 2 || full[0].Amount.Cmp(big.NewInt(1000)) != 0 || full[1].Amount.Cmp(big.NewInt(2500)) != 0 { + t.Fatalf("full details = %+v", full) + } + + // Subset: only the second seat paid; the first stays in the array at 0 + // (index-wise pairing with permitted[i], never shortened). + subset, err := BuildTransferDetails(v, []BatchRecipient{{Address: strings.ToLower(v.Recipients[1].Address), Amount: "2500"}}) + if err != nil { + t.Fatalf("subset capture: %v", err) + } + if len(subset) != 2 { + t.Fatalf("subset details length = %d, want 2 (omitted seats stay at zero)", len(subset)) + } + if subset[0].Amount.Sign() != 0 { + t.Errorf("omitted seat amount = %s, want 0", subset[0].Amount) + } + if subset[0].To != common.HexToAddress(v.Recipients[0].Address) { + t.Errorf("omitted seat To = %s, want voucher seat address", subset[0].To.Hex()) + } + if subset[1].Amount.Cmp(big.NewInt(2500)) != 0 { + t.Errorf("paid seat amount = %s, want 2500", subset[1].Amount) + } +} + +func TestBuildTransferDetails_Errors(t *testing.T) { + v, _ := goldenVoucher(t) + + // Recipient not in the voucher. + if _, err := BuildTransferDetails(v, []BatchRecipient{{Address: testSpender.Hex(), Amount: "1000"}}); err == nil { + t.Error("unknown recipient should fail") + } + // Amount differs from the signed seat amount. + if _, err := BuildTransferDetails(v, []BatchRecipient{{Address: v.Recipients[0].Address, Amount: "999"}}); err == nil { + t.Error("amount mismatch should fail") + } + // More than the signed amount. + if _, err := BuildTransferDetails(v, []BatchRecipient{{Address: v.Recipients[0].Address, Amount: "100000"}}); err == nil { + t.Error("over-request should fail") + } + // Empty request. + if _, err := BuildTransferDetails(v, nil); err == nil { + t.Error("empty request should fail") + } + // Same seat requested twice (only one seat exists at that address). + if _, err := BuildTransferDetails(v, []BatchRecipient{ + {Address: v.Recipients[0].Address, Amount: "1000"}, + {Address: v.Recipients[0].Address, Amount: "1000"}, + }); err == nil { + t.Error("double-spending one seat should fail") + } +} + +func TestBuildTransferDetails_DuplicateSeats(t *testing.T) { + v, _ := goldenVoucher(t) + addr := v.Recipients[0].Address + v.Recipients = []BatchRecipient{ + {Address: addr, Amount: "1000"}, + {Address: addr, Amount: "1000"}, + } + + // Two identical seats: requesting twice consumes both. + details, err := BuildTransferDetails(v, []BatchRecipient{ + {Address: addr, Amount: "1000"}, + {Address: addr, Amount: "1000"}, + }) + if err != nil { + t.Fatalf("duplicate seats: %v", err) + } + if details[0].Amount.Cmp(big.NewInt(1000)) != 0 || details[1].Amount.Cmp(big.NewInt(1000)) != 0 { + t.Fatalf("details = %+v", details) + } + + // Requesting once pays one seat, leaves the other at zero. + one, err := BuildTransferDetails(v, []BatchRecipient{{Address: addr, Amount: "1000"}}) + if err != nil { + t.Fatal(err) + } + if one[0].Amount.Cmp(big.NewInt(1000)) != 0 || one[1].Amount.Sign() != 0 { + t.Fatalf("one-seat details = %+v", one) + } +} + +// goldenCalldata is the exact permitTransferFrom calldata for goldenVoucher +// signed with anvil key 0 on chain 84532, capturing ONLY the first seat — +// the second seat appears index-wise with requestedAmount 0. +// Selector edd9444b = keccak("permitTransferFrom(((address,uint256)[],uint256,uint256),(address,uint256)[],address,bytes)")[:4]. +const goldenCalldata = "edd9444b" + + "0000000000000000000000000000000000000000000000000000000000000080" + // permit tuple offset + "0000000000000000000000000000000000000000000000000000000000000180" + // transferDetails offset + "000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266" + // owner + "0000000000000000000000000000000000000000000000000000000000000220" + // signature offset + "0000000000000000000000000000000000000000000000000000000000000060" + // permit.permitted offset + "0000000000000000000000000000000000000000000000000000000000000001" + // nonce = 1 + "0000000000000000000000000000000000000000000000000000000070dbd880" + // deadline = 1893456000 + "0000000000000000000000000000000000000000000000000000000000000002" + // permitted length + "000000000000000000000000036cbd53842c5426634e7929541ec2318f3dcf7e" + // permitted[0].token + "00000000000000000000000000000000000000000000000000000000000003e8" + // permitted[0].amount = 1000 + "000000000000000000000000036cbd53842c5426634e7929541ec2318f3dcf7e" + // permitted[1].token + "00000000000000000000000000000000000000000000000000000000000009c4" + // permitted[1].amount = 2500 + "0000000000000000000000000000000000000000000000000000000000000002" + // transferDetails length + "0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc" + // details[0].to + "00000000000000000000000000000000000000000000000000000000000003e8" + // details[0].requestedAmount = 1000 + "00000000000000000000000090f79bf6eb2c4f870365e785982e1f101e93b906" + // details[1].to (omitted seat) + "0000000000000000000000000000000000000000000000000000000000000000" + // details[1].requestedAmount = 0 + "0000000000000000000000000000000000000000000000000000000000000041" + // signature length (65) + "8eb05e00fa60ef44b63ec69978e25ce2d2f3a142ce3d603e89b4e8c06811555a" + + "7c41076a83d3f1b24405b7418cb4041b269325c2f4fae161f01460aab0cb6f40" + + "1c00000000000000000000000000000000000000000000000000000000000000" + +func TestBuildPermitTransferFromCalldata_Golden(t *testing.T) { + v, key := goldenVoucher(t) + chainID := big.NewInt(84532) + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + + details, err := BuildTransferDetails(v, []BatchRecipient{{Address: v.Recipients[0].Address, Amount: "1000"}}) + if err != nil { + t.Fatal(err) + } + calldata, err := BuildPermitTransferFromCalldata(v, details) + if err != nil { + t.Fatalf("BuildPermitTransferFromCalldata: %v", err) + } + if got := hex.EncodeToString(calldata); got != goldenCalldata { + t.Errorf("calldata mismatch:\n got %s\nwant %s", got, goldenCalldata) + } + + // Independent cross-check: the ABI fragment must produce the canonical + // batch permitTransferFrom selector. + wantSelector := crypto.Keccak256([]byte("permitTransferFrom(((address,uint256)[],uint256,uint256),(address,uint256)[],address,bytes)"))[:4] + if hex.EncodeToString(calldata[:4]) != hex.EncodeToString(wantSelector) { + t.Errorf("selector = %x, want %x", calldata[:4], wantSelector) + } +} + +func TestBuildPermitTransferFromCalldata_LengthInvariant(t *testing.T) { + v, key := goldenVoucher(t) + chainID := big.NewInt(84532) + if err := SignVoucher(&v, chainID, key); err != nil { + t.Fatal(err) + } + + // transferDetails must pair index-wise with permitted — shortening it is + // a hard error, not a silent re-pairing. + short := []TransferDetail{{To: common.HexToAddress(v.Recipients[0].Address), Amount: big.NewInt(1000)}} + if _, err := BuildPermitTransferFromCalldata(v, short); err == nil { + t.Error("shortened transferDetails should fail") + } +} + +func TestErpcNetworkSuffix(t *testing.T) { + for _, tc := range []struct{ network, want string }{ + {"ethereum", "mainnet"}, // erc8004 eRPC alias convention + {"base-sepolia", "base-sepolia"}, + {"base", "base"}, + {"eip155:84532", "base-sepolia"}, // CAIP-2 falls through to the x402 registry + {"my-custom-net", "my-custom-net"}, + } { + if got := erpcNetworkSuffix(tc.network); got != tc.want { + t.Errorf("erpcNetworkSuffix(%q) = %q, want %q", tc.network, got, tc.want) + } + } +} diff --git a/internal/x402/escrow/store.go b/internal/x402/escrow/store.go new file mode 100644 index 00000000..495bdc70 --- /dev/null +++ b/internal/x402/escrow/store.go @@ -0,0 +1,109 @@ +package escrow + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// StateAwaitingVoucher is the escrow entry state between a voucher-less +// Reserve (which surfaces the facilitator's spender address for the signer to +// bind) and the re-reserve that attaches the signed voucher. +const StateAwaitingVoucher = "AwaitingVoucher" + +// Entry is one escrow's persisted lifecycle record. +type Entry struct { + // ID is the escrow key (the ServiceBounty UID). + ID string `json:"id"` + // State is one of AwaitingVoucher | Reserved | Captured | Voided. + State string `json:"state"` + // Request is the last accepted reserve request, including the voucher. + Request *ReserveRequest `json:"request,omitempty"` + // Receipt is the receipt last returned for this entry. + Receipt Receipt `json:"receipt"` + // UpdatedAt is the last state-transition time. + UpdatedAt time.Time `json:"updatedAt"` +} + +// Store is a file-backed JSON escrow store: one .json per entry, written +// via temp-file + atomic rename so a crash never leaves a torn entry. +type Store struct { + dir string + mu sync.Mutex +} + +// NewStore creates (if needed) and opens the state directory. +func NewStore(dir string) (*Store, error) { + if dir == "" { + return nil, fmt.Errorf("escrow store: empty state dir") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("escrow store: create %s: %w", dir, err) + } + return &Store{dir: dir}, nil +} + +func (s *Store) path(id string) string { + return filepath.Join(s.dir, id+".json") +} + +// Get loads the entry for id; ok is false when none exists. +func (s *Store) Get(id string) (Entry, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := os.ReadFile(s.path(id)) + if os.IsNotExist(err) { + return Entry{}, false, nil + } + if err != nil { + return Entry{}, false, fmt.Errorf("escrow store: read %s: %w", id, err) + } + var e Entry + if err := json.Unmarshal(raw, &e); err != nil { + return Entry{}, false, fmt.Errorf("escrow store: decode %s: %w", id, err) + } + return e, true, nil +} + +// Put persists the entry atomically (write temp file, fsync, rename). +func (s *Store) Put(e Entry) error { + if e.ID == "" { + return fmt.Errorf("escrow store: entry has no id") + } + s.mu.Lock() + defer s.mu.Unlock() + + raw, err := json.MarshalIndent(e, "", " ") + if err != nil { + return fmt.Errorf("escrow store: encode %s: %w", e.ID, err) + } + + tmp, err := os.CreateTemp(s.dir, "."+e.ID+".tmp-*") + if err != nil { + return fmt.Errorf("escrow store: temp file for %s: %w", e.ID, err) + } + tmpName := tmp.Name() + if _, err := tmp.Write(raw); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("escrow store: write %s: %w", e.ID, err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + os.Remove(tmpName) + return fmt.Errorf("escrow store: sync %s: %w", e.ID, err) + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return fmt.Errorf("escrow store: close %s: %w", e.ID, err) + } + if err := os.Rename(tmpName, s.path(e.ID)); err != nil { + os.Remove(tmpName) + return fmt.Errorf("escrow store: rename %s: %w", e.ID, err) + } + return nil +} diff --git a/internal/x402/verifier_test.go b/internal/x402/verifier_test.go index 036a9298..a20ae64f 100644 --- a/internal/x402/verifier_test.go +++ b/internal/x402/verifier_test.go @@ -13,9 +13,9 @@ import ( "testing" "time" - x402types "github.com/x402-foundation/x402/go/types" dto "github.com/prometheus/client_model/go" "github.com/prometheus/common/expfmt" + x402types "github.com/x402-foundation/x402/go/types" ) // ── Mock facilitator ──────────────────────────────────────────────────────── diff --git a/plans/servicebounty-technical-spec.md b/plans/servicebounty-technical-spec.md new file mode 100644 index 00000000..d655eec1 --- /dev/null +++ b/plans/servicebounty-technical-spec.md @@ -0,0 +1,349 @@ +# ServiceBounty + Evaluator Market + Real Escrow — Technical Specification + +> Status: implemented and live-smoked on a local k3d stack (sandbox branch). +> Scope: the demand-side bounty marketplace, the evaluator verification +> market, and the non-custodial Permit2 escrow leg, as built on obol-stack. +> Audience: engineers reviewing the design. + +## 1. System overview + +The obol stack already had a supply side: `obol sell` publishes x402 +payment-gated services (HTTP 402 micropayments, Traefik ForwardAuth, +ERC-8004 discovery). This work adds the demand side and the trust layer: + +``` + DISCOVERY ERC-8004 identity · /api/services.json · /skill.md + │ + ┌──────────────────────────┼──────────────────────────┐ + ▼ ▼ ▼ + SUPPLY DEMAND TRUST + ServiceOffer CRD ServiceBounty CRD EvaluatorEnrollment CRD + obol sell … obol bounty post commit-reveal quorums + x402 per-call escrowed reward reputation ladder + └──────────────────────────┼──────────────────────────┘ + ▼ + MONEY RAIL (shared) + x402-escrow facilitator: Permit2 vouchers, batch capture +``` + +One controller (`serviceoffer-controller`, `x402` namespace) reconciles all +three CRDs. The controller **never holds keys and never signs** — every +signature comes from an agent wallet (remote-signer) or the operator. + +## 2. CRDs (group `obol.org/v1alpha1`) + +### 2.1 ServiceBounty (`sb`) + +Demand-side inverse of ServiceOffer. + +Spec (abridged): +- `task.typeRef` — references an embedded task package (`benchmark@v1`, + `benchlocal@v1`, `finetune@v1` staged/disabled) with typed params + (unknown params are admission-rejected; `required` enforced). +- `reward` — amount + asset (USDC via EIP-3009 or OBOL via Permit2; asset + carries `eip712Name`/`eip712Version`), `payment.network` chain alias. +- `eval.mode` — `required` (default) | `dangerouslySkipped` + (CLI: `--dangerously-skip-verification`). Skipped bounties settle on the + poster's verdict only, produce no ERC-8004 entries, and suppress + reputation effects. +- `deliverable.report.variants[]` — A2UI report variants (see §8). + +Status: `conditions[]` are machine truth; `phase` is a rollup. Key fields: +`evaluatorPanel[]{address,seat}`, `evaluations[]{address, commitHash, +score, revealedAt, withinBand, phase, seat, paid, validationTxHash, +grounded}`, `revealDeadline`, `panelSeed{source,round,randomness,signature}`, +`escalation{…}`, `bondState`, `evalBudgetState`, `escrowSpender`, +`ladderRecorded`. + +Write channel: all participant input rides **annotations** (RBAC-scopeable, +no controller API surface): + +| Annotation | Writer | Payload | +|---|---|---| +| `obol.org/claim` | fulfiller | claim intent (address) | +| `obol.org/submit` | fulfiller | submission ref | +| `obol.org/verdict` | poster | explicit override verdict | +| `obol.org/eval-commit-` | evaluator | commit hash (round 0) | +| `obol.org/eval-reveal-` | evaluator | `{score, salt, validationTx?}` | +| `obol.org/eval-commit-r1-` / `eval-reveal-r1-` | evaluator | escalation round | +| `obol.org/reward-voucher` | poster agent | Permit2 voucher JSON (§5) | +| `obol.org/bond-voucher` | fulfiller agent | Permit2 voucher JSON | +| `obol.org/eval-voucher`, `obol.org/eval-voucher-r1` | poster agent | Permit2 voucher JSON | + +Bounty reconcile is structurally pinned (test-enforced) to **never create +HTTPRoute / Middleware / ReferenceGrant / Secret / Namespace** — a bounty +can never become ingress or broker credentials. + +### 2.2 EvaluatorEnrollment (`ee`) + +Per-evaluator registration: `spec{address (0x40-hex), taskTypes[], +attestation{scheme: none|secure-enclave, publicKey, signature}}`. +Controller is **read-only on spec** (no create/delete — test-pinned); +it owns only `status.records[]` (per task type): +`{taskType, tier: shadow|probation|full, shadowAgreements, probationEvals, +completed, divergences, groundedEvals, lastEvalAt, recentFulfillers[≤5]}`. + +### 2.3 Task packages (embedded, versioned) + +`task.yaml` per package declares params, eval policy and ladder constants: + +```yaml +eval: + defaultK: 3 # counting quorum size + ladder: + shadowAgreements: 5 # in-band shadow verdicts → Probation + probationEvals: 10 # clean paid evals → Full + probationValueCap: "50.00" # no probation seat above this reward + revealWindow: 10m # commits close before any reveal opens + nonRevealPenalty: outlier # non-reveal graded as worst-case outlier + decayHalfLife: 720h # reputation half-life on inactivity + escalationWindow: 30m # poster funding window for round 1 + escalationEpsilon: 5 # knife-edge band; negative disables +``` + +## 3. Lifecycle and verdicts + +``` + post ──► claim ──► submit ──► [eval market] ──► Verified | Rejected ──► Paid + │ │ │ │ + │ │ │ ├─ Verified: capture reward → fulfiller + │ │ │ │ batch-capture eval leg → panel + │ │ │ │ void bond ("Returned") + │ │ │ └─ Rejected: capture bond → poster + │ │ │ void reward; eval leg still paid + │ ├─ self-bond reserved (-bond) (win-or-lose) + │ └─ reward voucher signable (fulfiller known) + └─ escrow Reserve() — intent only, "AwaitingVoucher" +``` + +Verdict sources, in precedence order: poster override annotation +(`PosterOverride`, always explicit) > evaluator quorum (`EvaluatorQuorum`, +only when `eval.mode=required`). The quorum verdict latches: once spoken it +is never re-derived. + +## 4. Evaluator market protocol + +### 4.1 Commit–reveal quorum + +- Commitment (address-bound, anti-copy): + `commitHash = "0x" + hex(sha256("||"))` + Scores are integers 0–100. First write wins per address. +- The reveal window opens only after **k counting commits** exist + (`revealDeadline = now + revealWindow`). +- Reveal verification recomputes the hash; mismatch → `BadReveal`. + Non-reveal past deadline → graded as worst-case outlier (`NonReveal`). +- Quorum verdict: `median(counting reveals) >= 50` → Verified + (`evalPassThreshold = 50`). A reveal further than **20** points from the + median is out-of-band (`evalOutlierBand`) — divergence for ladder + bookkeeping. + +### 4.2 Panel selection + +Deterministic, seeded weighted lottery over enrolled evaluators for the +task type: + +- Seed source (`OBOL_BOUNTY_SEED` env): + - `local` (default): `sha256(bountyUID)`. + - `drand`: `sha256(bountyUID ‖ beacon.randomness)` where the beacon round + is the first quicknet round strictly after `creationTimestamp + 30s`. + The BLS signature (bls-unchained-g1-rfc9380, G1) is verified in-process + against the quicknet group key; provenance + `{source, round, randomness, signature}` is persisted to + `status.panelSeed` for third-party re-verification. Relay failure + requeues — **no silent local fallback** (fallback would be a + seed-grinding lever). +- Weight: `w = max(0.1, 1 + 0.1 × (effectiveCompleted − divergences))` + - decay: `effectiveCompleted = completed × 2^(−idle / decayHalfLife)` + (pure read-time math; `lastEvalAt` nil → no decay; no status writes) + - grounded bonus: `w ×= 1 + min(1, groundedEvals / max(1, completed))` + - pair diversity: `w ×= 0.25` for repeat evaluator↔fulfiller pairs + (`recentFulfillers`, capped 5). +- Tier gating at read time: a `full` record is treated as probation when + `effectiveCompleted < probationEvals` and idle exceeds the half-life. +- Panel shape: **k counting seats** (Full tier) + **1 probation seat** + (counts fully in the median, half pay, only on bounties under + `probationValueCap`, requires k ≥ 3) + **≤ 2 shadow seats** (free, + randomly assigned, never counted or paid, graded against the median for + ladder credit). +- Cold start: pool smaller than k → open-door fallback (latched by the + `PanelSelected` condition); open-door participants still earn ladder + records, bootstrapping the pool. +- Selection is idempotent: the `PanelSelected` latch guarantees a panel is + never re-rolled. + +### 4.3 Escalation (bribery / dispute defense) + +Trigger, checked after grading and before the quorum verdict +(`eval.mode=required`, single-round latch): +- dispersion: out-of-band counting reveals `≥ ⌈k/2⌉`, or +- knife-edge: `|median − 50| ≤ escalationEpsilon` (0 = unset → default 5; + negative disables). + +On trigger: fresh panel of **2k+1** (round-0 panel and fulfiller excluded; +seed = `sha256(round0seed ‖ "escalation-r1")`), all seats full-pay, funded +by the poster within `escalationWindow` via the `eval-voucher-r1` +annotation (escrow id `-eval-r1`). Funded → full commit-reveal cycle +with `-r1` annotation prefixes; the round-1 median over 2k+1 is **final**. +Unfunded past the deadline → `EscalationUnfunded`, round-0 median stands. + +### 4.4 ERC-8004 grounding + +Evaluators may submit `validationResponse` on-chain with their own wallets +(the CLI builds calldata; the controller never signs): + +- Canonical request hash: + `requestHash = keccak256("obol/bounty-eval/v1||")` +- ERC-8004 v2.0.0 registries (verified on-chain, `getVersion()=="2.0.0"`): + `validationResponse(bytes32,uint8,string,bytes32,string)` selector + `0x3d659a96`; `giveFeedback(...)` selector `0x3c036a7e`. + Base Sepolia ValidationRegistry: `0x8004Cb1BF31DAf7788923b405b754f57acEB4272`. +- On reveal with `validationTx`, the controller reads the registry through + in-cluster eRPC and sets `grounded=true` iff on-chain responder == + evaluator and on-chain response == revealed score. Grounding **never + blocks or changes the verdict** (chain down → ungrounded + condition + note). Grounded evals feed the selection weight bonus. + +### 4.5 Anti-griefing + +Fulfiller self-bond: reserved at claim (`-bond`), voided ("Returned") +on Verified or honest timeout, captured to the poster ("Forfeited") on +Rejected — offsets the poster's burned eval budget. + +## 5. Money rail — Permit2 vouchers + x402-escrow facilitator + +### 5.1 Voucher (agent-signed authorization) + +JSON object ferried via annotations: + +```json +{ + "owner": "0x…", // signer (poster or fulfiller agent wallet) + "token": "0x…", // asset contract + "network": "base-sepolia", // chain alias + "spender": "0x…", // facilitator address (signature-bound) + "nonce": "…", // uint256 decimal, deterministic (below) + "deadline": 1760000000, // unix; hard on-chain expiry + "recipients": [{"address":"0x…","amount":"…"}], // atomic units/seat + "signature": "0x…" // 65-byte EIP-712 signature +} +``` + +- EIP-712: Uniswap **Permit2 SignatureTransfer `PermitBatchTransferFrom`**. + Domain `{name:"Permit2", chainId, verifyingContract: + 0x000000000022D473030F116dDEE9F6B43aC78BA3}` (no version field). + `permitted[i] = {token, amount}` — one entry per recipient seat. +- Deterministic nonce: `uint256(keccak256(uid + "|" + leg))` with legs + `reward|bond|eval|eval-r1` — re-funding is idempotent and a consumed + nonce is unrepeatable (Permit2 unordered nonces), so a voucher cannot be + double-captured. +- Signing: agent remote-signer `SignTypedData` (REST) or a dev `--key`. +- Who signs when: reward → poster at claim (fulfiller known); bond → + fulfiller at claim; eval → poster at panel selection (seat addresses + known, probation seat at half price); eval-r1 → poster at escalation. + +### 5.2 Facilitator service (`x402-escrow`) + +In-cluster, ClusterIP-only (never tunnel-exposed), port 8403, distroless. + +| Route | Semantics | +|---|---| +| `POST /escrow/reserve/{id}` | no voucher → `{state:"AwaitingVoucher", spender}`; with voucher → verify (recover signer == owner, spender binding, future deadline, positive amounts) → `Reserved`. Re-reserve attaches/replaces a voucher pre-capture. | +| `POST /escrow/capture/{id}` | requires Reserved+voucher. Optional `recipients[]` body (batch): must be a **subset of the voucher's declared seats with exact amounts** — omitted seats are simply unpaid. Builds `permitBatchTransferFrom` (transferDetails pair index-wise with permitted; omitted seats get `requestedAmount=0`), submits with the facilitator wallet, waits for the receipt → `{state:"Captured", txHash}`. Idempotent. | +| `POST /escrow/void/{id}` | store-only; the voucher deadline is the hard guarantee. | +| `GET /escrow/info` | `{address, networks}` — agents fetch the spender before signing. | + +Auth: bearer token (constant-time compare). Settlement key from env +(`OBOL_ESCROW_KEY`) or a remote signer; RPC via in-cluster eRPC per +network. State: file-backed, atomic writes. + +**Custody model**: funds move owner → recipients **directly through +Permit2** in one transaction; the facilitator pays gas and is never +custodial. Loss is bounded by signed amounts + deadline. + +**Documented v1 trust residue**: Permit2 SignatureTransfer lets the +spender choose `to` on-chain, so recipient binding is enforced by +facilitator policy (stored-voucher subset rule) + namespaced RBAC on the +voucher annotations, not by the signature. Cryptographic binding requires +`permitWitnessTransferFrom` + a disperse contract (planned upgrade). A +forged/foreign voucher can never move third-party funds (signature +recovery binds the owner). + +**Controller coupling**: the controller reads escrow URL/token **only from +env** (`OBOL_BOUNTY_ESCROW_URL/TOKEN`) — never from CR spec or +annotations (exfiltration guard, test-pinned). Escrow ids: ``, +`-bond`, `-eval`, `-eval-r1`. A capture refused for a +missing voucher parks as condition `EscrowAwaitingVoucher` + requeue; +`obol bounty status` prints the exact fund command to run next. + +## 6. Convergence with the inference-exchange direction + +The facilitator's `/escrow/*` + batch-capture routes are deliberately the +same primitive a regional inference gateway needs to batch-settle earnings +to `obol sell inference` operators (one tx, k sellers). The bounty eval +leg and gateway payouts share this workstream; sellers need zero changes. + +## 7. CLI surface (additions) + +``` +obol bounty post [--dangerously-skip-verification] [--yes] … +obol bounty fund (--key|--signer-url) [--spender 0x…] # reward voucher +obol bounty claim … [--bond-key|--bond-signer-url] # bond voucher +obol bounty eval enroll|pool # ladder state +obol bounty eval fund # eval / eval-r1 voucher +obol bounty eval commit --address --score --salt +obol bounty eval reveal --address --score --salt [--validation-tx] +obol bounty eval calldata [--bounty --address | --request-hash] # ERC-8004, own wallet +obol bounty feedback # giveFeedback calldata +obol bounty status # seed/panel/escalation/grounding/escrow +``` + +## 8. Reports (A2UI v1.0) + +Task packages ship `deliverable.report.variants[]`: +`{kind: declarative|mcp-app, surface, catalogId}` — declarative variants +target the A2UI v1.0 basic catalog (schema-validated against the spec +repo); `mcp-app` variants are self-contained HTML served as a `custom` +node (`url_encoded:` content) — double-iframe isolation is entirely the +client's job. A free `bounty_report` MCP tool renders reports with +client-preference catalog negotiation (first supported variant wins) and +path-traversal guards. + +## 9. Security invariants (test-pinned) + +1. Controller never signs; holds no key material. +2. Escrow endpoint config from controller env only — never spec/annotations. +3. Bounty reconcile creates no HTTPRoute/Middleware/ReferenceGrant/Secret/ + Namespace (structural source scan across all bounty reconcile files). +4. Controller read-only on EvaluatorEnrollment spec; no create/delete. +5. Agent bounty/enrollment RBAC is namespaced. +6. CRD ↔ Go bidirectional parity test (walks every json tag against the + hand-written CRD schema; has caught real silent-pruning bugs). +7. Voucher capture ≤ signed amounts, to declared recipients only. +8. drand mode has no silent local fallback (no seed-grinding path). +9. `x402-escrow` is ClusterIP-internal; frontend/eRPC hostname + restrictions untouched. + +## 10. Validation status + +- Unit/controller: full `go build/vet/test` green, including commit-reveal, + panel, escalation, grounding, voucher-ferry, decay, drand-fixture (BLS + verify passes on a recorded beacon, fails on a flipped bit), Permit2 + golden calldata, and the parity/RBAC/structural pins. +- Live cluster smokes: eval-market quorum pass/reject; full panel mode + (3 full + 1 shadow, outsider gated, median excludes shadow, eval budget + batch-captured, shadow unpaid, ladder records written, validation-tx + provenance). +- Money-rail compatibility: flow-12 (OBOL Permit2 sell→buy→settle through + the x402-buyer sidecar) passes against the upstream-sync x402-rs + facilitator build (v1.5.6 overlay): 3 settlements `status=0x1`, exact + buyer/seller balance deltas, ~113k avg gas per settlement. + +## 11. Known gaps / next steps + +- Disperse contract + `permitWitnessTransferFrom` for signature-bound + recipients. +- Live Base Sepolia escrow smoke with deployed OBOL. +- VRF only if drand provenance proves insufficient cross-party. +- On-chain `giveFeedback` submission flow (calldata exists today). +- Frontend A2UI rendering (separate repo). +- x402-rs fork release (v1.5.6 overlay) push + image repin.