Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 89 additions & 5 deletions internal/solvers/bridgefacilitator/apiclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bridgefacilitator

import (
"context"
"math"
"math/big"
"net/http"
"strings"
Expand All @@ -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.
//
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
103 changes: 103 additions & 0 deletions internal/solvers/bridgefacilitator/apiclient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bridgefacilitator

import (
"context"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
Expand All @@ -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).
Expand Down Expand Up @@ -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)
}
}
15 changes: 15 additions & 0 deletions internal/solvers/bridgefacilitator/eip712.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
60 changes: 60 additions & 0 deletions internal/solvers/bridgefacilitator/eip712_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 23 additions & 5 deletions internal/solvers/bridgefacilitator/offercache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
Loading