From f041d1d9338f110f05c2928cfbe2a13c1e3006ba Mon Sep 17 00:00:00 2001 From: Nuno Date: Fri, 21 Aug 2026 20:08:53 +0100 Subject: [PATCH] fix(verify): assert deployer registration on-chain (pure-Go eth_call) and retry before bridging --- cmd/verify.go | 103 ++++++++++++++++++-------- internal/stacks/verifyregistration.go | 70 +++++++++++++++++ 2 files changed, 141 insertions(+), 32 deletions(-) create mode 100644 internal/stacks/verifyregistration.go diff --git a/cmd/verify.go b/cmd/verify.go index 2e9f739..1d5a2a0 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -205,19 +205,12 @@ func runVerifyPublicChain() error { } fmt.Printf(" public chain id: %s\n", chainID) - userIDBytes := make([]byte, 32) - if _, err := rand.Read(userIDBytes); err != nil { - return fmt.Errorf("rand: %w", err) - } - userID := "0x" + hex.EncodeToString(userIDBytes) - // Steps 2-3 are idempotent by intent and EXPECTED to fail on stacks whose - // deploy pre-registers the deployer (the 3.0.1 deploy does: + // deploy pre-registers the deployer (some deploys do: // RNUserGovernanceV1__PublicAddressAlreadyMapped) — run them silently, name // the KNOWN benign outcomes in one line, and surface anything else (the // tasks swallow their own errors and exit 0, so the captured output is the - // only evidence there is). Both steps continue either way: the bridge's - // final balance poll is the ground truth. + // only evidence there is). reportIdempotentStep := func(out string, err error, benignMarkers []string, benignNote string) { failure := contractsTaskFailed(out, err) if failure == nil { @@ -229,33 +222,79 @@ func runVerifyPublicChain() error { return } } - yellow.Printf(" step did not succeed (continuing — the bridge outcome below is the ground truth):\n %s\n", outputTail(out, 4)) + yellow.Printf(" step did not succeed:\n %s\n", outputTail(out, 4)) } - bold.Println(">> [2/8] Registering deployer as a user (idempotent)") - out2, err2 := stacks.ExecContractsCaptureSilent([]string{ - "npx", "hardhat", "createUser", - "--pn", "A", - "--user-id", userID, - "--public-address", verifyDeployerAddress, - "--private-address", verifyDeployerAddress, - }) - // 0x609fe1e4 is the RNUserGovernanceV1__PublicAddressAlreadyMapped() - // selector: the same benign outcome, seen raw when the task's ethers call - // cannot decode the custom error. - reportIdempotentStep(out2, err2, - []string{"PublicAddressAlreadyMapped", "already registered", "already exists", "0x609fe1e4"}, - "deployer address already registered — continuing (expected: the contracts deploy pre-registers it)") + registerDeployer := func() error { + userIDBytes := make([]byte, 32) + if _, err := rand.Read(userIDBytes); err != nil { + return fmt.Errorf("rand: %w", err) + } + userID := "0x" + hex.EncodeToString(userIDBytes) + + out2, err2 := stacks.ExecContractsCaptureSilent([]string{ + "npx", "hardhat", "createUser", + "--pn", "A", + "--user-id", userID, + "--public-address", verifyDeployerAddress, + "--private-address", verifyDeployerAddress, + }) + // 0x609fe1e4 is the RNUserGovernanceV1__PublicAddressAlreadyMapped() + // selector: the same benign outcome, seen raw when the task's ethers call + // cannot decode the custom error. + reportIdempotentStep(out2, err2, + []string{"PublicAddressAlreadyMapped", "already registered", "already exists", "0x609fe1e4"}, + "deployer address already registered — continuing (expected when the contracts deploy pre-registers it)") + + out3, err3 := stacks.ExecContractsCaptureSilent([]string{ + "npx", "hardhat", "approveUser", + "--pn", "A", + "--user-id", userID, + }) + reportIdempotentStep(out3, err3, + []string{"has no address pairs", "already approved", "User does not exist"}, + "approval not needed for this user — continuing (the deployer's mapping is already active)") + return nil + } + bold.Println(">> [2/8] Registering deployer as a user (idempotent)") bold.Println(">> [3/8] Approving the user") - out3, err3 := stacks.ExecContractsCaptureSilent([]string{ - "npx", "hardhat", "approveUser", - "--pn", "A", - "--user-id", userID, - }) - reportIdempotentStep(out3, err3, - []string{"has no address pairs", "already approved", "User does not exist"}, - "approval not needed for this user — continuing (the deployer's pre-registered mapping is already active)") + // The teleport in step 8 hard-requires the deployer to be a registered AND + // approved user on PN A (RNUserGovernanceV1__PrivateAddressNotMapped + // otherwise), and the registration tasks above tolerate benign + // already-mapped outcomes — so a transiently failed registration (seen live: + // a nonce race when verify runs right after init, while the deploy's + // background add-authorized-relayers txs from the SAME system account are + // still settling) must be caught HERE, not two minutes later as an opaque + // revert after the token deploy. Assert the mapping on-chain and retry the + // registration a few times before proceeding. + deployerApproved := false + for attempt := 1; attempt <= 3; attempt++ { + if attempt > 1 { + yellow.Printf(" deployer mapping not active yet — retrying registration (attempt %d/3)\n", attempt) + time.Sleep(5 * time.Second) + } + if err := registerDeployer(); err != nil { + return err + } + approved, checkErr := stacks.DeployerApprovedOnPNA(verifyDeployerAddress) + if checkErr != nil { + // The assertion exists to catch registration races, not to add its + // own failure mode: if the check itself can't run (RPC unreachable + // from the host, unexpected .env), warn and trust the tasks' output. + yellow.Printf(" could not verify the deployer registration on-chain (%v) — continuing on the registration tasks' word\n", checkErr) + deployerApproved = true + break + } + if approved { + deployerApproved = true + break + } + } + if !deployerApproved { + return fmt.Errorf("the deployer %s is not registered/approved on PN A after 3 attempts — the bridge in step 8 cannot work.\nInspect the registration tasks manually: docker exec npx hardhat createUser --pn A ... (see `rayls logs contracts`)", verifyDeployerAddress) + } + green.Println(" deployer registration confirmed on-chain (approved address pair on PN A)") suffixBytes := make([]byte, 3) if _, err := rand.Read(suffixBytes); err != nil { diff --git a/internal/stacks/verifyregistration.go b/internal/stacks/verifyregistration.go new file mode 100644 index 0000000..e384c02 --- /dev/null +++ b/internal/stacks/verifyregistration.go @@ -0,0 +1,70 @@ +package stacks + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "golang.org/x/crypto/sha3" +) + +// pnARPCURL is PN A's EVM JSON-RPC as published on the host: every generated +// compose maps privacy-node-a (participant index 0) to 127.0.0.1:8545. +const pnARPCURL = "http://127.0.0.1:8545" + +// DeployerApprovedOnPNA reports whether the given address has an APPROVED +// address pair in PN A's RNUserGovernanceV1 — the hard precondition of +// teleportToPublicChain. The governance proxy address exists only inside the +// contracts container (the deploy writes it into the image's .env), so it is +// read with an in-container grep; the check itself is a raw eth_call from the +// host, keeping the image's node/ethers runtime out of the loop. The contract +// reverts (PrivateAddressNotMapped) for unknown addresses, so an RPC-level +// error reads as "not approved" rather than a hard failure — the caller +// retries; only a broken transport/setup surfaces as err. +func DeployerApprovedOnPNA(deployer string) (bool, error) { + out, err := ExecContractsCaptureSilent([]string{"grep", "-m1", "^PRIVACY_NODE_A_RAYLS_NODE_USER_GOVERNANCE=", ".env"}) + if err != nil { + return false, fmt.Errorf("reading the PN A user-governance address from the contracts container: %w", err) + } + _, govAddr, found := strings.Cut(strings.TrimSpace(out), "=") + if !found || !strings.HasPrefix(govAddr, "0x") { + return false, fmt.Errorf("unexpected PRIVACY_NODE_A_RAYLS_NODE_USER_GOVERNANCE line %q in the contracts .env", strings.TrimSpace(out)) + } + + // checkUserIsApprovedByPrivateAddress(address): 4-byte selector + // (0xc4ba03ed) followed by the address as one left-padded 32-byte word. + h := sha3.NewLegacyKeccak256() + h.Write([]byte("checkUserIsApprovedByPrivateAddress(address)")) + callData := "0x" + hex.EncodeToString(h.Sum(nil)[:4]) + + strings.Repeat("0", 24) + strings.ToLower(strings.TrimPrefix(deployer, "0x")) + + reqBody, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, + "method": "eth_call", + "params": []any{map[string]string{"to": govAddr, "data": callData}, "latest"}, + }) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(pnARPCURL, "application/json", bytes.NewReader(reqBody)) + if err != nil { + return false, err + } + defer resp.Body.Close() + var rpcOut struct { + Result string `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&rpcOut); err != nil { + return false, err + } + if rpcOut.Error != nil { + // Reverts land here: the deployer is simply not mapped/approved yet. + return false, nil + } + return strings.HasSuffix(rpcOut.Result, "1"), nil +}