Skip to content

Commit 225f852

Browse files
committed
Merge #387: erc8004 wait for read-side consistency before setMetadata
Pulls in the WaitForAgent / AgentWallet helpers + post-Register wait in the CLI register paths, ahead of v0.9.0-rc1.
2 parents d3ce623 + 07f553c commit 225f852

3 files changed

Lines changed: 157 additions & 0 deletions

File tree

cmd/obol/sell.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1937,6 +1937,14 @@ func registerDirectViaSigner(ctx context.Context, cfg *config.Config, u *ui.UI,
19371937
u.Printf(" Agent ID: %s", agentID.String())
19381938
u.Printf(" Owner: %s", addr.Hex())
19391939

1940+
// The Register tx is mined on the WRITE upstream, but a follow-up
1941+
// setMetadata estimateGas goes through the READ upstream which can lag
1942+
// (we observed ERC721NonexistentToken reverts when a stale eRPC route was
1943+
// pinned to a parallel Anvil fork). Block until the reader sees the token.
1944+
if _, err := client.WaitForAgent(ctx, agentID, 30*time.Second); err != nil {
1945+
u.Warnf("agent not visible to reader after register: %v", err)
1946+
}
1947+
19401948
// Set x402 metadata.
19411949
x402Meta := []byte(`{"x402":true}`)
19421950
if err := client.SetMetadataWithOpts(ctx, opts, agentID, "x402", x402Meta); err != nil {
@@ -1971,6 +1979,12 @@ func registerDirectWithKey(ctx context.Context, cfg *config.Config, u *ui.UI, ne
19711979
u.Printf(" Agent ID: %s", agentID.String())
19721980
u.Printf(" Owner: %s", txAddr.Hex())
19731981

1982+
// Wait for the chain READER to catch up to the freshly-minted agent id;
1983+
// see comment in registerWithRemoteSigner for the rationale.
1984+
if _, err := client.WaitForAgent(ctx, agentID, 30*time.Second); err != nil {
1985+
u.Warnf("agent not visible to reader after register: %v", err)
1986+
}
1987+
19741988
x402Meta := []byte(`{"x402":true}`)
19751989
if err := client.SetMetadata(ctx, key, agentID, "x402", x402Meta); err != nil {
19761990
u.Warnf("failed to set x402 metadata: %v", err)

internal/erc8004/client.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"math/big"
99
"strings"
10+
"time"
1011

1112
ethereum "github.com/ethereum/go-ethereum"
1213
"github.com/ethereum/go-ethereum/accounts/abi"
@@ -294,6 +295,57 @@ func (c *Client) SetMetadata(ctx context.Context, key *ecdsa.PrivateKey, agentID
294295
return nil
295296
}
296297

298+
// AgentWallet returns the registered wallet for an agent id via the ERC-8004
299+
// `getAgentWallet` view, or an error if the READER reports a revert (the
300+
// pre-mint case appears as ERC721NonexistentToken). Used by WaitForAgent to
301+
// confirm the chain READER has caught up to a recent register tx before
302+
// follow-up calls (setMetadata, setAgentURI) try to estimate gas against a
303+
// state where the token id is still unknown.
304+
func (c *Client) AgentWallet(ctx context.Context, agentID *big.Int) (common.Address, error) {
305+
var out []interface{}
306+
if err := c.contract.Call(&bind.CallOpts{Context: ctx}, &out, "getAgentWallet", agentID); err != nil {
307+
return common.Address{}, fmt.Errorf("erc8004: getAgentWallet: %w", err)
308+
}
309+
if len(out) == 0 {
310+
return common.Address{}, fmt.Errorf("erc8004: getAgentWallet: empty return")
311+
}
312+
addr, ok := out[0].(common.Address)
313+
if !ok {
314+
return common.Address{}, fmt.Errorf("erc8004: getAgentWallet: unexpected type %T", out[0])
315+
}
316+
return addr, nil
317+
}
318+
319+
// WaitForAgent polls AgentWallet until the chain READER reports the agent id
320+
// as owned (any address). Returns when it does, or err on timeout. This closes
321+
// the read-side staleness window after Register: the Register tx confirms via
322+
// WaitMined on the WRITE upstream, but a subsequent setMetadata estimateGas
323+
// goes through the READ upstream which may still be a block or two behind
324+
// (we hit this in production when a stale eRPC route was pinned to a
325+
// parallel Anvil fork — the simulation reverted with ERC721NonexistentToken).
326+
func (c *Client) WaitForAgent(ctx context.Context, agentID *big.Int, timeout time.Duration) (common.Address, error) {
327+
if timeout <= 0 {
328+
timeout = 30 * time.Second
329+
}
330+
deadline := time.Now().Add(timeout)
331+
var lastErr error
332+
for {
333+
addr, err := c.AgentWallet(ctx, agentID)
334+
if err == nil {
335+
return addr, nil
336+
}
337+
lastErr = err
338+
if time.Now().After(deadline) {
339+
return common.Address{}, fmt.Errorf("erc8004: waitForAgent %s timed out after %s: %w", agentID, timeout, lastErr)
340+
}
341+
select {
342+
case <-ctx.Done():
343+
return common.Address{}, ctx.Err()
344+
case <-time.After(2 * time.Second):
345+
}
346+
}
347+
}
348+
297349
// GetMetadata reads metadata for the given key from the agent NFT.
298350
func (c *Client) GetMetadata(ctx context.Context, agentID *big.Int, k string) ([]byte, error) {
299351
var out []interface{}

internal/erc8004/client_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strings"
1313
"sync"
1414
"testing"
15+
"time"
1516

1617
"github.com/ethereum/go-ethereum/accounts/abi"
1718
"github.com/ethereum/go-ethereum/common"
@@ -633,3 +634,93 @@ func TestGetMetadata_EmptyResult(t *testing.T) {
633634
func parseABI() (abi.ABI, error) {
634635
return abi.JSON(strings.NewReader(identityRegistryABI))
635636
}
637+
638+
// TestWaitForAgent_RetriesUntilOwnerVisible verifies that WaitForAgent keeps
639+
// polling ownerOf until the reader returns a successful result, simulating
640+
// the read-side staleness window between Register's WaitMined (write upstream)
641+
// and a subsequent setMetadata estimateGas (read upstream).
642+
func TestWaitForAgent_RetriesUntilOwnerVisible(t *testing.T) {
643+
var attempts int
644+
owner := common.HexToAddress("0x2FbFe6cF08Ac224f97915ecF07eE29Be0b213f51")
645+
646+
parsedABI, err := parseABI()
647+
if err != nil {
648+
t.Fatalf("parseABI: %v", err)
649+
}
650+
651+
handlers := map[string]func([]json.RawMessage) (json.RawMessage, error){
652+
"eth_chainId": func(_ []json.RawMessage) (json.RawMessage, error) {
653+
return json.RawMessage(`"0x14a34"`), nil
654+
},
655+
"eth_call": func(_ []json.RawMessage) (json.RawMessage, error) {
656+
attempts++
657+
if attempts < 3 {
658+
// Simulate ERC721NonexistentToken on first two calls (read
659+
// upstream not yet caught up).
660+
return nil, fmt.Errorf("execution reverted")
661+
}
662+
// Third call: encode owner as ownerOf return.
663+
ownerBytes, encErr := parsedABI.Methods["getAgentWallet"].Outputs.Pack(owner)
664+
if encErr != nil {
665+
return nil, encErr
666+
}
667+
return json.RawMessage(fmt.Sprintf("%q", "0x"+common.Bytes2Hex(ownerBytes))), nil
668+
},
669+
}
670+
671+
srv := mockRPC(t, handlers)
672+
defer srv.Close()
673+
674+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
675+
defer cancel()
676+
677+
client, err := NewClient(ctx, srv.URL)
678+
if err != nil {
679+
t.Fatalf("NewClient: %v", err)
680+
}
681+
defer client.Close()
682+
683+
got, err := client.WaitForAgent(ctx, big.NewInt(5196), 20*time.Second)
684+
if err != nil {
685+
t.Fatalf("WaitForAgent: %v after %d attempts", err, attempts)
686+
}
687+
if got != owner {
688+
t.Errorf("expected owner %s, got %s", owner, got)
689+
}
690+
if attempts < 3 {
691+
t.Errorf("expected at least 3 attempts (2 reverts + 1 success), got %d", attempts)
692+
}
693+
}
694+
695+
// TestWaitForAgent_TimeoutReturnsError verifies that persistent reverts
696+
// surface as a timeout error.
697+
func TestWaitForAgent_TimeoutReturnsError(t *testing.T) {
698+
handlers := map[string]func([]json.RawMessage) (json.RawMessage, error){
699+
"eth_chainId": func(_ []json.RawMessage) (json.RawMessage, error) {
700+
return json.RawMessage(`"0x14a34"`), nil
701+
},
702+
"eth_call": func(_ []json.RawMessage) (json.RawMessage, error) {
703+
return nil, fmt.Errorf("execution reverted")
704+
},
705+
}
706+
707+
srv := mockRPC(t, handlers)
708+
defer srv.Close()
709+
710+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
711+
defer cancel()
712+
713+
client, err := NewClient(ctx, srv.URL)
714+
if err != nil {
715+
t.Fatalf("NewClient: %v", err)
716+
}
717+
defer client.Close()
718+
719+
_, err = client.WaitForAgent(ctx, big.NewInt(5196), 3*time.Second)
720+
if err == nil {
721+
t.Fatal("expected timeout error, got nil")
722+
}
723+
if !strings.Contains(err.Error(), "timed out") {
724+
t.Errorf("expected 'timed out' in error, got: %v", err)
725+
}
726+
}

0 commit comments

Comments
 (0)