diff --git a/internal/solvers/bridgefacilitator/apiclient.go b/internal/solvers/bridgefacilitator/apiclient.go index 77d21198..fa38522c 100644 --- a/internal/solvers/bridgefacilitator/apiclient.go +++ b/internal/solvers/bridgefacilitator/apiclient.go @@ -2,6 +2,7 @@ package bridgefacilitator import ( "context" + "math" "math/big" "net/http" "strings" @@ -19,6 +20,8 @@ import ( // getOffersDeadlineWindow is how far in the future the signed GetOffers deadline is set. const getOffersDeadlineWindow = 5 * time.Minute +const maxExactFloat32Int = 1 << 24 + // apiClient wraps the generated 3F client. It signs per-adapter requests via EIP-712 and injects // the resulting Authorization: Bearer header. // @@ -54,14 +57,21 @@ func (ac *apiClient) listAuctions(ctx context.Context) ([]threef.AuctionDto, err return auctions, nil } -// createOffer submits a signed offer. -func (ac *apiClient) createOffer(ctx context.Context, dto threef.CreateOfferDto) error { - _, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx).CreateOfferDto(dto).Execute() +// createOffer submits a signed offer and returns the remote offer ID assigned by 3F. +func (ac *apiClient) createOffer(ctx context.Context, dto threef.CreateOfferDto) (int64, error) { + resp, httpResp, e := ac.c.OfferAPI.OfferControllerCreateV1(ctx).CreateOfferDto(dto).Execute() closeResp(httpResp) if e != nil { - return apiErr("create offer", httpResp, e) + return 0, apiErr("create offer", httpResp, e) + } + if resp == nil { + return 0, errors.New("3f api: create offer: empty response") } - return nil + offerID, err := apiIDFromFloat(resp.Id, "create offer id") + if err != nil { + return 0, err + } + return offerID, nil } // listOffers returns the adapter's outstanding offers. Authenticated via a per-adapter EIP-712 @@ -87,6 +97,62 @@ func (ac *apiClient) listOffers(ctx context.Context, adapter common.Address) ([] return o, nil } +// getOfferByID returns one remote offer for adapter using the same signed authentication as listOffers. +func (ac *apiClient) getOfferByID(ctx context.Context, adapter common.Address, offerID int64) (*threef.OfferDto, error) { + apiID, err := apiIDToFloat(offerID, "offer id") + if err != nil { + return nil, err + } + deadline := big.NewInt(time.Now().Add(getOffersDeadlineWindow).Unix()) + sig, err := ac.sgnr.SignHash(GetOffersDigest(adapter, deadline, ac.chainID)) + if err != nil { + return nil, errors.Errorf("3f api: sign GetOffers: %w", err) + } + o, httpResp, e := ac.c.OfferAPI.OfferControllerGetByIdV1(ctx, apiID). + Maker(lowerAddr(adapter)). + ChainId(float32(ac.chainID.Int64())). + Deadline(deadline.String()). + Authorization("Bearer 0x" + common.Bytes2Hex(sig)). + Execute() + closeResp(httpResp) + if e != nil { + return nil, apiErr("get offer", httpResp, e) + } + return o, nil +} + +// cancelOffer signs and submits a 3F cancel request. Solver policy does not call this yet; it is wired +// here so follow-up repricing/cancel flows can use the generated API surface without leaking it outward. +func (ac *apiClient) cancelOffer(ctx context.Context, adapter common.Address, offerID int64) (int64, string, error) { + apiID, err := apiIDToFloat(offerID, "offer id") + if err != nil { + return 0, "", err + } + deadline := big.NewInt(time.Now().Add(getOffersDeadlineWindow).Unix()) + sig, err := ac.sgnr.SignHash(CancelOfferDigest(adapter, big.NewInt(offerID), deadline, ac.chainID)) + if err != nil { + return 0, "", errors.Errorf("3f api: sign CancelOffer: %w", err) + } + dto := threef.NewCancelOfferDto(apiID, lowerAddr(adapter)) + dto.SetChainId(float32(ac.chainID.Int64())) + dto.SetDeadline(deadline.String()) + dto.SetSignature("0x" + common.Bytes2Hex(sig)) + + resp, httpResp, e := ac.c.OfferAPI.OfferControllerCancelV1(ctx).CancelOfferDto(*dto).Execute() + closeResp(httpResp) + if e != nil { + return 0, "", apiErr("cancel offer", httpResp, e) + } + if resp == nil { + return 0, "", errors.New("3f api: cancel offer: empty response") + } + id, err := apiIDFromFloat(resp.Id, "cancel offer id") + if err != nil { + return 0, "", err + } + return id, resp.Status, nil +} + // closeResp closes the response body. The generated client already closes it inside Execute; this // satisfies bodyclose, which can't see across that call boundary. func closeResp(resp *http.Response) { @@ -117,3 +183,21 @@ func statusOf(resp *http.Response) string { // lowerAddr renders an address as a lowercase hex string; the 3F API rejects checksummed addresses. func lowerAddr(a common.Address) string { return strings.ToLower(a.Hex()) } + +func apiIDFromFloat(v float32, field string) (int64, error) { + f := float64(v) + if f < 1 || math.Trunc(f) != f { + return 0, errors.Errorf("3f api: %s: invalid id %v", field, v) + } + return int64(v), nil +} + +func apiIDToFloat(v int64, field string) (float32, error) { + if v < 1 { + return 0, errors.Errorf("3f api: %s: invalid id %d", field, v) + } + if v > maxExactFloat32Int { + return 0, errors.Errorf("3f api: %s: id %d exceeds exact float32 range", field, v) + } + return float32(v), nil +} diff --git a/internal/solvers/bridgefacilitator/apiclient_test.go b/internal/solvers/bridgefacilitator/apiclient_test.go index 26274f72..976df6af 100644 --- a/internal/solvers/bridgefacilitator/apiclient_test.go +++ b/internal/solvers/bridgefacilitator/apiclient_test.go @@ -2,6 +2,7 @@ package bridgefacilitator import ( "context" + "encoding/json" "math/big" "net/http" "net/http/httptest" @@ -13,6 +14,8 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/go-logr/logr" + + "github.com/symbioticfi/vault-solver/api/threef" ) // fakeSigner is a minimal signer.Signer test double that signs nothing meaningful (65 zero bytes). @@ -50,3 +53,103 @@ func TestAPIClient_ListOffers_SignedPerAdapter(t *testing.T) { t.Fatalf("maker=%q chainId=%q deadline=%q auth=%q key=%q", gotMaker, gotChainID, gotDeadline, gotAuth, gotKey) } } + +func TestAPIClient_CreateOfferReturnsID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/offer" { + t.Fatalf("request = %s %s, want POST /v1/offer", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":123}`)) + })) + defer srv.Close() + + dto := threef.NewCreateOfferDto(42, lowerAddr(common.Address{0x42}), "100", "5", "7", "4102444800", true) + ac := newAPIClient(srv.URL, fakeSigner{}, big.NewInt(11155111), 5*time.Second, logr.Discard()) + got, err := ac.createOffer(context.Background(), *dto) + if err != nil { + t.Fatalf("createOffer: %v", err) + } + if got != 123 { + t.Fatalf("offer id = %d, want 123", got) + } +} + +func TestAPIClient_GetOfferByID_SignedPerAdapter(t *testing.T) { + adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") + var gotMaker, gotAuth, gotDeadline, gotChainID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/offer/123" { + t.Fatalf("request = %s %s, want GET /v1/offer/123", r.Method, r.URL.Path) + } + gotMaker = r.URL.Query().Get("maker") + gotDeadline = r.URL.Query().Get("deadline") + gotChainID = r.URL.Query().Get("chainId") + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":123, + "auctionId":42, + "status":"SUBMITTED", + "maker":"` + lowerAddr(adapter) + `", + "requestId":"0x0000000000000000000000000000000000000043", + "asset":null, + "vault":null, + "amount":"100", + "expectedReturn":"5", + "nonce":"7", + "expiration":"4102444800", + "signature":null + }`)) + })) + defer srv.Close() + + ac := newAPIClient(srv.URL, fakeSigner{}, big.NewInt(11155111), 5*time.Second, logr.Discard()) + offer, err := ac.getOfferByID(context.Background(), adapter, 123) + if err != nil { + t.Fatalf("getOfferByID: %v", err) + } + chainID, _ := strconv.ParseFloat(gotChainID, 64) + if gotMaker != lowerAddr(adapter) || gotDeadline == "" || chainID != 11155111 || + !strings.HasPrefix(gotAuth, "Bearer 0x") { + t.Fatalf("maker=%q chainId=%q deadline=%q auth=%q", gotMaker, gotChainID, gotDeadline, gotAuth) + } + if offer == nil || offer.Id != 123 || offer.AuctionId != 42 || offer.Status != offerStatusSubmitted { + t.Fatalf("offer = %+v", offer) + } +} + +func TestAPIClient_CancelOfferSignsPayload(t *testing.T) { + adapter := common.HexToAddress("0x0000000000000000000000000000000000000042") + var gotBody struct { + OfferID float32 `json:"offerId"` + Maker string `json:"maker"` + ChainID float32 `json:"chainId"` + Deadline string `json:"deadline"` + Signature string `json:"signature"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/offer/cancel" { + t.Fatalf("request = %s %s, want POST /v1/offer/cancel", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":123,"status":"CANCELLED"}`)) + })) + defer srv.Close() + + ac := newAPIClient(srv.URL, fakeSigner{}, big.NewInt(11155111), 5*time.Second, logr.Discard()) + id, status, err := ac.cancelOffer(context.Background(), adapter, 123) + if err != nil { + t.Fatalf("cancelOffer: %v", err) + } + if id != 123 || status != "CANCELLED" { + t.Fatalf("cancel response = %d/%q, want 123/CANCELLED", id, status) + } + if gotBody.OfferID != 123 || gotBody.Maker != lowerAddr(adapter) || gotBody.ChainID != 11155111 || + gotBody.Deadline == "" || !strings.HasPrefix(gotBody.Signature, "0x") { + t.Fatalf("cancel body = %+v", gotBody) + } +} diff --git a/internal/solvers/bridgefacilitator/eip712.go b/internal/solvers/bridgefacilitator/eip712.go index 6de16b26..b33c447f 100644 --- a/internal/solvers/bridgefacilitator/eip712.go +++ b/internal/solvers/bridgefacilitator/eip712.go @@ -118,6 +118,21 @@ func GetOffersDigest(maker common.Address, deadline, chainID *big.Int) common.Ha return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator(chainID).Bytes(), sh.Bytes()) } +// cancelOfferTypeHash is the EIP-712 type the maker signs to cancel a mutable 3F offer. +var cancelOfferTypeHash = crypto.Keccak256Hash( + []byte("CancelOffer(address maker,uint256 offerId,uint256 deadline)")) + +// CancelOfferDigest computes the EIP-712 digest signed for POST /v1/offer/cancel. +func CancelOfferDigest(maker common.Address, offerID, deadline, chainID *big.Int) common.Hash { + sh := crypto.Keccak256Hash( + cancelOfferTypeHash.Bytes(), + word(maker.Bytes()), + word(offerID.Bytes()), + word(deadline.Bytes()), + ) + return crypto.Keccak256Hash([]byte{0x19, 0x01}, gruntAPIDomainSeparator(chainID).Bytes(), sh.Bytes()) +} + // APIKeyDigest computes the EIP-712 digest a facilitator signs to generate a 3F API key (chainId 1). func APIKeyDigest(facilitator common.Address, deadline *big.Int) common.Hash { sh := crypto.Keccak256Hash(apiKeyTypeHash.Bytes(), word(facilitator.Bytes()), word(deadline.Bytes())) diff --git a/internal/solvers/bridgefacilitator/eip712_test.go b/internal/solvers/bridgefacilitator/eip712_test.go index dad21c05..d54e3081 100644 --- a/internal/solvers/bridgefacilitator/eip712_test.go +++ b/internal/solvers/bridgefacilitator/eip712_test.go @@ -174,6 +174,66 @@ func TestGetOffersDigest_MatchesApitypes(t *testing.T) { } } +func TestCancelOfferDigest_Golden(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + got := CancelOfferDigest(maker, big.NewInt(123), big.NewInt(4102444800), big.NewInt(11155111)).Hex() + // GOLDEN: pinned from TestCancelOfferDigest_MatchesApitypes cross-check (chainId 11155111). + want := "0xedd4e81f1c6199ac2ea6552ec75d9c517f4469b4c485e244d3412dbe566439fb" + if got != want { + t.Fatalf("digest = %s, want %s", got, want) + } +} + +// TestCancelOfferDigest_MatchesApitypes cross-checks the signed cancellation payload used by +// POST /v1/offer/cancel against go-ethereum's independent EIP-712 implementation. +func TestCancelOfferDigest_MatchesApitypes(t *testing.T) { + maker := common.HexToAddress("0x0000000000000000000000000000000000000042") + offerID := big.NewInt(123) + deadline := big.NewInt(4102444800) + chainID := big.NewInt(11155111) + + got := CancelOfferDigest(maker, offerID, deadline, chainID) + + typed := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + }, + "CancelOffer": { + {Name: "maker", Type: "address"}, + {Name: "offerId", Type: "uint256"}, + {Name: "deadline", Type: "uint256"}, + }, + }, + PrimaryType: "CancelOffer", + Domain: apitypes.TypedDataDomain{ + Name: apiKeyDomainName, + Version: apiKeyDomainVersion, + ChainId: (*math.HexOrDecimal256)(chainID), + }, + Message: apitypes.TypedDataMessage{ + "maker": maker.Hex(), + "offerId": offerID.String(), + "deadline": deadline.String(), + }, + } + domainSep, err := typed.HashStruct("EIP712Domain", typed.Domain.Map()) + if err != nil { + t.Fatalf("hash domain: %v", err) + } + msgHash, err := typed.HashStruct("CancelOffer", typed.Message) + if err != nil { + t.Fatalf("hash message: %v", err) + } + want := crypto.Keccak256Hash([]byte{0x19, 0x01}, domainSep, msgHash) + + if got != want { + t.Fatalf("digest mismatch:\n manual %s\n apitypes %s", got.Hex(), want.Hex()) + } +} + // TestGetOffersDigest_MatchesLiveAcceptedSignature verifies that the scaffolded GetOffers type // string is accepted by the live 3F API. Skipped offline (SOLVER_LIVE_AUTH != "1"). // A correctly-formed sig returns 200/empty or 403 (unauthorized maker) — NOT a signature error. diff --git a/internal/solvers/bridgefacilitator/offercache.go b/internal/solvers/bridgefacilitator/offercache.go index c271c9f1..936e6a95 100644 --- a/internal/solvers/bridgefacilitator/offercache.go +++ b/internal/solvers/bridgefacilitator/offercache.go @@ -17,8 +17,12 @@ type offerKey struct { // offerState is one outstanding offer: when it expires and the principal it covers. type offerState struct { - expiry time.Time - principal *big.Int + id int64 + expiry time.Time + principal *big.Int + expectedReturn *big.Int + nonce *big.Int + status string } // offerTracker remembers our outstanding offers per (adapter, auction) so we don't re-offer through @@ -45,9 +49,16 @@ func (t *offerTracker) liveEntries(now time.Time) []offerKey { return keys } -// record stores the expiration and principal of an offer we hold through adapter for auctionID. -func (t *offerTracker) record(adapter common.Address, auctionID int64, expiration time.Time, principal *big.Int) { - t.offers[offerKey{adapter, auctionID}] = offerState{expiry: expiration, principal: new(big.Int).Set(principal)} +// record stores the remote identity and local lifecycle state of an offer we hold through adapter for auctionID. +func (t *offerTracker) record(adapter common.Address, auctionID int64, st offerState) { + t.offers[offerKey{adapter, auctionID}] = offerState{ + id: st.id, + expiry: st.expiry, + principal: cloneBigOrZero(st.principal), + expectedReturn: cloneBig(st.expectedReturn), + nonce: cloneBig(st.nonce), + status: st.status, + } } // retainAdapters drops cached offers made by adapters that are no longer usable. In particular, @@ -90,3 +101,10 @@ func parseUnixTime(s string) (time.Time, error) { } return time.Unix(sec, 0), nil } + +func cloneBigOrZero(n *big.Int) *big.Int { + if n == nil { + return new(big.Int) + } + return new(big.Int).Set(n) +} diff --git a/internal/solvers/bridgefacilitator/offercache_test.go b/internal/solvers/bridgefacilitator/offercache_test.go index 0b763727..d710761e 100644 --- a/internal/solvers/bridgefacilitator/offercache_test.go +++ b/internal/solvers/bridgefacilitator/offercache_test.go @@ -8,6 +8,17 @@ import ( "github.com/ethereum/go-ethereum/common" ) +func testOfferState(expiry time.Time, principal *big.Int) offerState { + return offerState{ + id: 123, + expiry: expiry, + principal: principal, + expectedReturn: big.NewInt(5), + nonce: big.NewInt(7), + status: offerStatusSubmitted, + } +} + func TestOfferTracker(t *testing.T) { tr := newOfferTracker() now := time.Unix(1_000_000, 0) @@ -27,7 +38,7 @@ func TestOfferTracker(t *testing.T) { t.Fatal("empty tracker should report no live offers") } - tr.record(adapterA, 42, now.Add(30*time.Minute), big.NewInt(100)) + tr.record(adapterA, 42, testOfferState(now.Add(30*time.Minute), big.NewInt(100))) if !live(now, adapterA, 42) { t.Fatal("offer should be live before expiry") } @@ -54,9 +65,9 @@ func TestOfferTrackerLiveCoverage(t *testing.T) { } // Coverage sums principals across adapters on the same auction. - tr.record(adapterA, 42, now.Add(30*time.Minute), big.NewInt(100)) - tr.record(adapterB, 42, now.Add(30*time.Minute), big.NewInt(60)) - tr.record(adapterA, 7, now.Add(30*time.Minute), big.NewInt(999)) // other auction, excluded + tr.record(adapterA, 42, testOfferState(now.Add(30*time.Minute), big.NewInt(100))) + tr.record(adapterB, 42, testOfferState(now.Add(30*time.Minute), big.NewInt(60))) + tr.record(adapterA, 7, testOfferState(now.Add(30*time.Minute), big.NewInt(999))) // other auction, excluded if got := tr.liveCoverage(42, now); got.Cmp(big.NewInt(160)) != 0 { t.Fatalf("coverage = %s, want 160", got) } @@ -67,6 +78,36 @@ func TestOfferTrackerLiveCoverage(t *testing.T) { } } +func TestOfferTrackerRecordsRemoteLifecycleState(t *testing.T) { + tr := newOfferTracker() + now := time.Unix(1_000_000, 0) + adapter := common.Address{0xAA} + principal := big.NewInt(100) + expectedReturn := big.NewInt(5) + nonce := big.NewInt(7) + + tr.record(adapter, 42, offerState{ + id: 123, + expiry: now.Add(time.Hour), + principal: principal, + expectedReturn: expectedReturn, + nonce: nonce, + status: offerStatusSubmitted, + }) + principal.SetInt64(999) + expectedReturn.SetInt64(999) + nonce.SetInt64(999) + + got := tr.offers[offerKey{adapter: adapter, auction: 42}] + if got.id != 123 || got.status != offerStatusSubmitted { + t.Fatalf("state id/status = %d/%q", got.id, got.status) + } + if got.principal.String() != "100" || got.expectedReturn.String() != "5" || got.nonce.String() != "7" { + t.Fatalf("state amounts were not cloned: principal=%s expectedReturn=%s nonce=%s", + got.principal, got.expectedReturn, got.nonce) + } +} + func TestParseUnixTime(t *testing.T) { got, err := parseUnixTime("4102444800") if err != nil { diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index a8838055..ddb2e9a6 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -20,13 +20,16 @@ import ( "github.com/symbioticfi/vault-solver/internal/solvers/bridgefacilitator/strategies/types" ) -// offerStatusIgnored are 3F offer statuses that are not live coverage when hydrating the cache: a -// FAILED consume or a NOT_ACCEPTED bid won't cover the auction, so discovery should re-offer. +// offerStatusIgnored are 3F offer statuses that are not live coverage when hydrating the cache. var offerStatusIgnored = map[string]bool{ + "CANCELED": true, + "CANCELLED": true, "FAILED": true, "NOT_ACCEPTED": true, } +const offerStatusSubmitted = "SUBMITTED" + // Name is the registry key that selects this solver from config. const Name = "3f-bridge-facilitator" @@ -166,13 +169,41 @@ func (s *Solver) hydrateOfferCache(ctx context.Context, targets []Target) { if perr != nil || !exp.After(now) { continue // unparseable or already expired — we may freely re-offer } - principal, ok := new(big.Int).SetString(o.Amount, 10) + principal, ok := parseUint256String(o.Amount) if !ok { s.log.V(1).Info("offer cache: unparseable amount; coverage may undercount", "adapter", t.Adapter.Hex(), "amount", o.Amount) principal = new(big.Int) } - s.offers.record(t.Adapter, int64(o.AuctionId), exp, principal) + offerID, ierr := apiIDFromFloat(o.Id, "offer id") + if ierr != nil { + s.log.V(1).Info("offer cache: invalid offer id; lifecycle state may be incomplete", + "adapter", t.Adapter.Hex(), "offerId", o.Id) + } + auctionID, ierr := apiIDFromFloat(o.AuctionId, "auction id") + if ierr != nil { + s.log.V(1).Info("offer cache: invalid auction id; skipping offer", + "adapter", t.Adapter.Hex(), "auctionId", o.AuctionId) + continue + } + expectedReturn, ok := parseUint256String(o.ExpectedReturn) + if !ok { + s.log.V(1).Info("offer cache: unparseable expectedReturn", + "adapter", t.Adapter.Hex(), "expectedReturn", o.ExpectedReturn) + } + nonce, ok := parseUint256String(o.Nonce) + if !ok { + s.log.V(1).Info("offer cache: unparseable nonce", + "adapter", t.Adapter.Hex(), "nonce", o.Nonce) + } + s.offers.record(t.Adapter, auctionID, offerState{ + id: offerID, + expiry: exp, + principal: principal, + expectedReturn: expectedReturn, + nonce: nonce, + status: normalizedOfferStatus(o.Status), + }) live++ } } @@ -240,17 +271,39 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { s.log.Error(buildErr, "offer: build", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) continue } - if subErr := s.api.createOffer(ctx, dto); subErr != nil { + offerID, subErr := s.api.createOffer(ctx, dto) + if subErr != nil { s.log.Error(subErr, "offer: submit", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) continue } if exp, perr := parseUnixTime(dto.Expiration); perr == nil { - s.offers.record(offer.Maker, offer.AuctionID, exp, offer.Principal) + nonce, _ := parseUint256String(dto.Nonce) + s.offers.record(offer.Maker, offer.AuctionID, offerState{ + id: offerID, + expiry: exp, + principal: offer.Principal, + expectedReturn: offer.ExpectedReturn, + nonce: nonce, + status: offerStatusSubmitted, + }) } s.log.Info("offer submitted", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex(), - "request", offer.Request.Hex(), "principal", offer.Principal.String(), "expectedReturn", dto.ExpectedReturn) + "offerId", offerID, "request", offer.Request.Hex(), + "principal", offer.Principal.String(), "expectedReturn", dto.ExpectedReturn) + } +} + +func parseUint256String(s string) (*big.Int, bool) { + n, ok := new(big.Int).SetString(s, 10) + if !ok || n.Sign() < 0 { + return nil, false } + return n, true +} + +func normalizedOfferStatus(status string) string { + return strings.ToUpper(strings.TrimSpace(status)) } // redeemAll runs the redeemer for every matched adapter. diff --git a/internal/solvers/bridgefacilitator/solver_test.go b/internal/solvers/bridgefacilitator/solver_test.go index 067d23bf..55f48239 100644 --- a/internal/solvers/bridgefacilitator/solver_test.go +++ b/internal/solvers/bridgefacilitator/solver_test.go @@ -91,7 +91,7 @@ func TestRefreshTargets_RetainsLastKnownGoodOnWholeRefreshFailure(t *testing.T) t.Fatalf("first refresh added=%v targets=%v", added, s.targets) } now := time.Now() - s.offers.record(adapterAddr, 42, now.Add(time.Hour), big.NewInt(100)) + s.offers.record(adapterAddr, 42, testOfferState(now.Add(time.Hour), big.NewInt(100))) if _, err := s.refreshTargets(t.Context()); err == nil { t.Fatal("expected the second refresh to fail") @@ -135,7 +135,7 @@ func TestRefreshTargets_RemovesAndReaddsWhenSignerEligibilityChanges(t *testing. t.Fatalf("initial refresh added=%v targets=%v err=%v", added, s.targets, err) } now := time.Now() - s.offers.record(adapterAddr, 42, now.Add(time.Hour), big.NewInt(100)) + s.offers.record(adapterAddr, 42, testOfferState(now.Add(time.Hour), big.NewInt(100))) added, err = s.refreshTargets(t.Context()) if err != nil || len(added) != 0 || len(s.targets) != 0 { t.Fatalf("removal refresh added=%v targets=%v err=%v", added, s.targets, err) diff --git a/internal/solvers/bridgefacilitator/strategy_test.go b/internal/solvers/bridgefacilitator/strategy_test.go index 7faa2714..c3d0cd1c 100644 --- a/internal/solvers/bridgefacilitator/strategy_test.go +++ b/internal/solvers/bridgefacilitator/strategy_test.go @@ -76,7 +76,7 @@ func TestBuildStrategyInputKeepsFullyCoveredAuctions(t *testing.T) { adapter := common.HexToAddress("0x0000000000000000000000000000000000000001") collateral := common.HexToAddress("0x0000000000000000000000000000000000000003") offers := newOfferTracker() - offers.record(adapter, 10, now.Add(time.Minute), big.NewInt(100)) + offers.record(adapter, 10, testOfferState(now.Add(time.Minute), big.NewInt(100))) input := buildStrategyInput( []threef.AuctionDto{testAuctionDto(10, collateral, "100")},